diff --git a/.github/actions/setup-flutter-git/action.yml b/.github/actions/setup-flutter-git/action.yml new file mode 100644 index 00000000..96ef0e87 --- /dev/null +++ b/.github/actions/setup-flutter-git/action.yml @@ -0,0 +1,46 @@ +name: Set up Flutter from git +description: >- + Clone the pinned Flutter SDK from its release tag and put it on PATH, for + runners without a published archive. Flutter ships no windows-arm64 SDK, so + subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry + in the stable manifest) and `channel: master` would clone master HEAD, whose + engine is not the patched revision install-patched-engine.ps1 asserts + (4c525dac). This file is the only place that pin lives: the tag is fetched so + the SDK reports its own version, then verified against the immutable commit + and against the version Flutter itself reports, so a moved tag fails the job + instead of quietly changing SDKs. + +runs: + using: composite + steps: + - name: Clone Flutter from its immutable commit + shell: pwsh + run: | + $version = "3.44.0" + $expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + $root = "$env:RUNNER_TEMP\flutter" + git init $root + git -C $root remote add origin https://github.com/flutter/flutter.git + git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}" + git -C $root checkout --detach "refs/tags/$version" + $actualCommit = git -C $root rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) { + throw "Flutter $version resolved to $actualCommit, expected $expectedCommit" + } + "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + & "$root\bin\flutter.bat" --version + if ($LASTEXITCODE -ne 0) { + throw "Unable to bootstrap the Flutter SDK" + } + $versionOutput = & "$root\bin\flutter.bat" --version --machine + if ($LASTEXITCODE -ne 0) { + throw "Unable to resolve the Flutter SDK version" + } + $versionJson = $versionOutput -join "`n" + if ([string]::IsNullOrWhiteSpace($versionJson)) { + throw "Flutter did not report machine-readable version JSON" + } + $reportedVersion = ($versionJson | ConvertFrom-Json).frameworkVersion + if ($reportedVersion -ne $version) { + throw "Flutter reported version $reportedVersion, expected $version" + } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 0f41aa99..c5220d2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -25,10 +25,10 @@ on: type: boolean env: - SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} - GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }} - SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github - DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true + # Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release. + FLUTTER_VERSION: "3.44.0" + # Shared by every release build command; SENTRY_DIST stays per-platform. + RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1 LINUX_APT_PACKAGES: > clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev @@ -76,7 +76,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -110,7 +110,7 @@ jobs: EOF - name: Build APKs - run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json + run: flutter build apk --release --split-per-abi ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-android-apk --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -163,7 +163,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -188,7 +188,7 @@ jobs: run: flutter pub get --enforce-lockfile --no-example - name: Build iOS (no codesign) - run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios + run: flutter build ios --release --no-codesign ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-ios --split-debug-info=debug-info/ios - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -231,7 +231,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -256,7 +256,7 @@ jobs: run: flutter pub get --enforce-lockfile --no-example - name: Build macOS - run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos + run: flutter build macos --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-macos --split-debug-info=debug-info/macos - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -436,45 +436,14 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false - - name: Set up Flutter 3.44.0 from its immutable commit + - name: Set up Flutter from its pinned commit if: matrix.flutter_setup == 'git' - # Flutter publishes no windows-arm64 SDK archive, so fetch the release tag for - # version discovery and verify it resolves to the pinned commit. That tag uses - # engine revision 4c525dac, which install-patched-engine.ps1 asserts before swapping. - shell: pwsh - run: | - $root = "$env:RUNNER_TEMP\flutter" - $expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" - git init $root - git -C $root remote add origin https://github.com/flutter/flutter.git - git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0 - git -C $root checkout --detach refs/tags/3.44.0 - $actualCommit = git -C $root rev-parse HEAD - if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) { - throw "Flutter 3.44.0 resolved to $actualCommit, expected $expectedCommit" - } - "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - & "$root\bin\flutter.bat" --version - if ($LASTEXITCODE -ne 0) { - throw "Unable to bootstrap the Flutter SDK" - } - $versionOutput = & "$root\bin\flutter.bat" --version --machine - if ($LASTEXITCODE -ne 0) { - throw "Unable to resolve the Flutter SDK version" - } - $versionJson = $versionOutput -join "`n" - if ([string]::IsNullOrWhiteSpace($versionJson)) { - throw "Flutter did not report machine-readable version JSON" - } - $version = $versionJson | ConvertFrom-Json - if ($version.frameworkVersion -ne "3.44.0") { - throw "Flutter reported version $($version.frameworkVersion), expected 3.44.0" - } + uses: ./.github/actions/setup-flutter-git - name: Cache Pub dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 @@ -495,7 +464,7 @@ jobs: - name: Build Windows ${{ matrix.arch }} shell: pwsh - run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-${{ matrix.arch }} + run: flutter build windows --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} --split-debug-info=debug-info/windows-${{ matrix.arch }} - name: Upload symbols to bugs.plezy.app if: github.repository == 'edde746/plezy' @@ -528,7 +497,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -641,7 +610,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: ${{ matrix.flutter_channel }} - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" pub-cache: false @@ -698,7 +667,7 @@ jobs: - name: Build Linux ${{ matrix.arch }} shell: bash - run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-${{ matrix.arch }} + run: flutter build linux --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} --split-debug-info=debug-info/linux-${{ matrix.arch }} env: PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index da84beb7..26a2664c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,6 +10,10 @@ on: - main workflow_dispatch: +env: + # Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release. + FLUTTER_VERSION: "3.44.0" + jobs: analyze: name: Code Analysis @@ -26,7 +30,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -53,31 +57,7 @@ jobs: run: python3 scripts/clean_translations.py --check --strict - name: Verify workflow and script guards - run: | - python3 scripts/check_build_workflow.py - python3 scripts/test_check_build_workflow.py - python3 scripts/check_apple_spm_locks.py - python3 scripts/test_check_apple_spm_locks.py - python3 scripts/verify_runtime_inputs.py - python3 scripts/test_verify_runtime_inputs.py - python3 scripts/check_workflow_security.py - python3 scripts/test_check_workflow_security.py - python3 scripts/check_workflow_action_pins.py - python3 scripts/test_check_workflow_action_pins.py - python3 scripts/check_container_image_pins.py - python3 scripts/test_check_container_image_pins.py - python3 scripts/test_fetch_tvos_engine.py - python3 scripts/test_check_codegen.py - python3 scripts/test_generate_relay_protocol.py - python3 scripts/test_format_native.py - python3 scripts/test_run_maestro.py - python3 scripts/test_maestro_flow_contracts.py - python3 scripts/test_maestro_jellyfin_proxy.py - python3 scripts/test_maestro_real_jellyfin.py - python3 scripts/check_update_packages_workflow.py - python3 scripts/test_pubspec_version.py - python3 scripts/test_clean_translations.py - python3 scripts/test_check_icon_consistency.py + run: bash scripts/ci_guard_checks.sh - name: Verify formatting run: | @@ -131,7 +111,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -188,7 +168,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -282,7 +262,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -356,7 +336,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false @@ -479,41 +459,13 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false - - name: Setup Flutter 3.44.0 from its immutable commit + - name: Set up Flutter from its pinned commit if: matrix.flutter_setup == 'git' - shell: pwsh - run: | - $root = "$env:RUNNER_TEMP\flutter" - $expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" - git init $root - git -C $root remote add origin https://github.com/flutter/flutter.git - git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0 - git -C $root checkout --detach refs/tags/3.44.0 - $actualCommit = git -C $root rev-parse HEAD - if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) { - throw "Flutter 3.44.0 resolved to $actualCommit, expected $expectedCommit" - } - "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - & "$root\bin\flutter.bat" --version - if ($LASTEXITCODE -ne 0) { - throw "Unable to bootstrap the Flutter SDK" - } - $versionOutput = & "$root\bin\flutter.bat" --version --machine - if ($LASTEXITCODE -ne 0) { - throw "Unable to resolve the Flutter SDK version" - } - $versionJson = $versionOutput -join "`n" - if ([string]::IsNullOrWhiteSpace($versionJson)) { - throw "Flutter did not report machine-readable version JSON" - } - $version = $versionJson | ConvertFrom-Json - if ($version.frameworkVersion -ne "3.44.0") { - throw "Flutter reported version $($version.frameworkVersion), expected 3.44.0" - } + uses: ./.github/actions/setup-flutter-git - name: Install locked Dart dependencies shell: pwsh @@ -567,7 +519,7 @@ jobs: uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 with: channel: "stable" - flutter-version: "3.44.0" + flutter-version: ${{ env.FLUTTER_VERSION }} cache: true pub-cache: false diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 26ca7c52..2e7ded8f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -243,7 +243,7 @@ jobs: uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 with: path: ~/.pub-cache - key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} + key: ${{ steps.pub-cache.outputs.cache-primary-key }} - name: Save Gradle cache if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true' @@ -252,7 +252,7 @@ jobs: path: | ~/.gradle/caches ~/.gradle/wrapper - key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + key: ${{ steps.gradle-cache.outputs.cache-primary-key }} - name: Save Maestro CLI cache if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true' @@ -261,7 +261,7 @@ jobs: path: | ~/.maestro/bin ~/.maestro/lib - key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }} + key: ${{ steps.maestro-cache.outputs.cache-primary-key }} - name: Save Android 15 AVD cache if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true' @@ -270,7 +270,7 @@ jobs: path: | ~/.android/avd/maestro-api35.avd ~/.android/avd/maestro-api35.ini - key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6 + key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }} - name: Save Android 9 AVD cache if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true' @@ -279,7 +279,7 @@ jobs: path: | ~/.android/avd/maestro-api28.avd ~/.android/avd/maestro-api28.ini - key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore + key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }} - name: Upload Maestro diagnostics if: always() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index ea81bd7d..29492402 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -77,6 +77,7 @@ import com.edde746.plezy.shared.FlutterOverlayHelper import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.MediaCodecQuery import com.edde746.plezy.shared.PlayerSurfaceHost +import com.edde746.plezy.shared.SurfacePlayerCore import java.util.concurrent.Executors import java.util.concurrent.atomic.AtomicLong import org.chromium.net.CronetEngine @@ -102,7 +103,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate { internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null @OptIn(UnstableApi::class) -class ExoPlayerCore(private val activity: Activity) : Player.Listener { +class ExoPlayerCore(private val activity: Activity) : Player.Listener, SurfacePlayerCore { companion object { private const val TAG = "ExoPlayerCore" @@ -3501,7 +3502,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { else -> null } - fun setVisible(visible: Boolean) { + override fun setVisible(visible: Boolean) { if (disposing) return currentVisible = visible activity.runOnUiThread { @@ -3594,7 +3595,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - fun onPipModeChanged(isInPipMode: Boolean) { + override fun onPipModeChanged(isInPipMode: Boolean) { if (disposing) return activity.runOnUiThread { if (disposing) return@runOnUiThread @@ -3610,7 +3611,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - fun updateFrame() { + override fun updateFrame() { if (disposing) return activity.runOnUiThread { if (disposing) return@runOnUiThread @@ -3625,15 +3626,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { // Audio Focus - fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false + override fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false - fun abandonAudioFocus() { + override fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() } // Frame Rate Matching - fun setVideoFrameRate( + override fun setVideoFrameRate( fps: Float, videoDurationMs: Long, extraDelayMs: Long, @@ -3649,7 +3650,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete) } - fun clearVideoFrameRate() { + override fun clearVideoFrameRate() { frameRateManager?.clearVideoFrameRate() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index bfbd58f4..3f47ceb6 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -12,6 +12,7 @@ import com.edde746.plezy.shared.MpvContentUriResolver import com.edde746.plezy.shared.PlayerChannelBinding import com.edde746.plezy.shared.PlayerDelegate import com.edde746.plezy.shared.ResolvedMpvUri +import com.edde746.plezy.shared.SurfacePlayerCore import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding @@ -46,6 +47,10 @@ class ExoPlayerPlugin : private var activity: Activity? = null private var activityBinding: ActivityPluginBinding? = null + /** Whichever core currently owns the surface; both expose [SurfacePlayerCore] identically. */ + private val activeSurfaceCore: SurfacePlayerCore? + get() = if (usingMpvFallback) mpvCore else playerCore + // Every Dart observeProperty registration, kept so an ExoPlayer→MPV // fallback can re-observe exactly what Dart asked for instead of // maintaining a parallel hard-coded list. @@ -951,20 +956,12 @@ class ExoPlayerPlugin : return } - if (usingMpvFallback) { - mpvCore?.setVisible(visible) - } else { - playerCore?.setVisible(visible) - } + activeSurfaceCore?.setVisible(visible) result.success(null) } private fun handleUpdateFrame(result: MethodChannel.Result) { - if (usingMpvFallback) { - mpvCore?.updateFrame() - } else { - playerCore?.updateFrame() - } + activeSurfaceCore?.updateFrame() result.success(null) } @@ -976,51 +973,31 @@ class ExoPlayerPlugin : val videoHeight = call.argument("videoHeight")?.toInt() ?: 0 Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight") - val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) } - if (usingMpvFallback) { - val core = mpvCore - if (core == null) { - result.success(false) - } else { - core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete) - } - } else { - val core = playerCore - if (core == null) { - result.success(false) - } else { - core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete) - } + val core = activeSurfaceCore + if (core == null) { + result.success(false) + return + } + core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched -> + result.success(switched) } } private fun handleClearVideoFrameRate(result: MethodChannel.Result) { Log.d(TAG, "clearVideoFrameRate") - if (usingMpvFallback) { - mpvCore?.clearVideoFrameRate() - } else { - playerCore?.clearVideoFrameRate() - } + activeSurfaceCore?.clearVideoFrameRate() result.success(null) } private fun handleRequestAudioFocus(result: MethodChannel.Result) { Log.d(TAG, "requestAudioFocus") - val granted = if (usingMpvFallback) { - mpvCore?.requestAudioFocus() ?: false - } else { - playerCore?.requestAudioFocus() ?: false - } + val granted = activeSurfaceCore?.requestAudioFocus() ?: false result.success(granted) } private fun handleAbandonAudioFocus(result: MethodChannel.Result) { Log.d(TAG, "abandonAudioFocus") - if (usingMpvFallback) { - mpvCore?.abandonAudioFocus() - } else { - playerCore?.abandonAudioFocus() - } + activeSurfaceCore?.abandonAudioFocus() result.success(null) } @@ -1230,11 +1207,7 @@ class ExoPlayerPlugin : fun onPipModeChanged(isInPipMode: Boolean) { activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.onPipModeChanged(isInPipMode) - } else { - playerCore?.onPipModeChanged(isInPipMode) - } + activeSurfaceCore?.onPipModeChanged(isInPipMode) } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index bf88fb89..525ec21b 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -19,6 +19,7 @@ import com.edde746.plezy.shared.AudioFocusManager import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.PlayerDelegate import com.edde746.plezy.shared.PlayerSurfaceHost +import com.edde746.plezy.shared.SurfacePlayerCore import dev.jdtech.mpv.* import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex @@ -40,7 +41,7 @@ class MpvPlayerCore private constructor( private val audioOnly: Boolean, private val propertyWriterOverride: (suspend (String, String) -> Unit)?, initializedForTesting: Boolean -) : SurfaceHolder.Callback { +) : SurfaceHolder.Callback, SurfacePlayerCore { constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false) internal constructor( @@ -405,7 +406,7 @@ class MpvPlayerCore private constructor( // Audio Focus - fun requestAudioFocus(): Boolean { + override fun requestAudioFocus(): Boolean { val granted = audioFocusManager?.requestAudioFocus() ?: false if (granted && pausedForAudioFocusLoss) { resumeAfterAudioFocusGain("audio focus request granted") @@ -413,7 +414,7 @@ class MpvPlayerCore private constructor( return granted } - fun abandonAudioFocus() { + override fun abandonAudioFocus() { audioFocusManager?.abandonAudioFocus() } @@ -1096,7 +1097,7 @@ class MpvPlayerCore private constructor( } } - fun setVisible(visible: Boolean) { + override fun setVisible(visible: Boolean) { // Audio-only: no render layer to show or hide — tolerated no-op. if (audioOnly || disposing) return runOnMain { @@ -1121,11 +1122,11 @@ class MpvPlayerCore private constructor( } } - fun onPipModeChanged(isInPipMode: Boolean) { + override fun onPipModeChanged(isInPipMode: Boolean) { // MPV handles aspect ratio internally via its own surface management } - fun updateFrame() { + override fun updateFrame() { // Audio-only: no surface to refresh — tolerated no-op. if (audioOnly || disposing) return runOnMain { @@ -1160,7 +1161,7 @@ class MpvPlayerCore private constructor( // Frame Rate Matching - fun setVideoFrameRate( + override fun setVideoFrameRate( fps: Float, videoDurationMs: Long, extraDelayMs: Long, @@ -1182,7 +1183,7 @@ class MpvPlayerCore private constructor( } } - fun clearVideoFrameRate() { + override fun clearVideoFrameRate() { frameRateManager?.clearVideoFrameRate() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt new file mode 100644 index 00000000..bac39951 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt @@ -0,0 +1,27 @@ +package com.edde746.plezy.shared + +/** + * Surface and display concerns that the ExoPlayer and mpv cores implement + * identically, so a plugin holding either one dispatches without branching + * on which backend is active. + * + * Only backend-independent members belong here: playback control + * (play/seek/track selection) stays off this interface because mpv drives it + * through properties and commands where ExoPlayer uses direct method calls. + */ +interface SurfacePlayerCore { + fun setVisible(visible: Boolean) + fun updateFrame() + fun onPipModeChanged(isInPipMode: Boolean) + fun requestAudioFocus(): Boolean + fun abandonAudioFocus() + fun clearVideoFrameRate() + fun setVideoFrameRate( + fps: Float, + videoDurationMs: Long, + extraDelayMs: Long, + videoWidth: Int, + videoHeight: Int, + onComplete: (switched: Boolean) -> Unit + ) +} diff --git a/lib/connection/connection_auth_service.dart b/lib/connection/connection_auth_service.dart deleted file mode 100644 index 304b0a39..00000000 --- a/lib/connection/connection_auth_service.dart +++ /dev/null @@ -1,20 +0,0 @@ -import 'connection.dart'; - -/// Backend-neutral auth service interface. Each backend's implementation -/// (`PlexConnectionAuthService`, `JellyfinConnectionAuthService`) drives its -/// own UX (PIN flow vs. password) but produces the same opaque -/// [Connection] record at the end. -abstract class ConnectionAuthService { - /// Best-effort check that an existing token still works. Returns false on - /// 401/403; throws on transport failures the caller should retry. - Future validate(Connection connection); - - /// Refresh whatever side-channel state belongs to a connection — for Plex - /// that's the discovered server list and Home users; for Jellyfin it's a - /// no-op once auth has succeeded. Returns the updated connection. - Future refresh(Connection connection); - - /// Revoke the token server-side and forget local credentials. The caller - /// is responsible for removing the row from [ConnectionRegistry]. - Future signOut(Connection connection); -} diff --git a/lib/connection/connection_bootstrap.dart b/lib/connection/connection_bootstrap.dart index 92c0c377..4d7ed992 100644 --- a/lib/connection/connection_bootstrap.dart +++ b/lib/connection/connection_bootstrap.dart @@ -29,7 +29,7 @@ class ConnectionBootstrap { required this.profileRegistry, Future> Function(String accountToken)? plexHomeUserFetcher, Future> Function(String accountToken)? plexUserInfoFetcher, - }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers, + }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers, _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo; final StorageService storage; @@ -283,16 +283,6 @@ class ConnectionBootstrap { } } -Future> _fetchPlexHomeUsers(String accountToken) async { - final auth = await PlexAuthService.create(); - try { - final home = await auth.getHomeUsers(accountToken); - return home.users; - } finally { - auth.dispose(); - } -} - Future> _fetchPlexUserInfo(String accountToken) async { final auth = await PlexAuthService.create(); try { diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 8d9398cc..19827976 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -246,197 +246,56 @@ class AppDatabase extends _$AppDatabase { final joinRows = await (select( profileConnections, )..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get(); + // Drift's generated serializer is the recovery image's column schema: + // `toJson`/`fromJson` use these camelCase keys, so the read and restore + // sides can never drift apart when a column is added or renamed. return { - 'connections': [ - for (final row in connectionRows) - { - 'id': row.id, - 'kind': row.kind, - 'displayName': row.displayName, - 'configJson': row.configJson, - 'isDefault': row.isDefault, - 'createdAt': row.createdAt, - 'lastAuthenticatedAt': row.lastAuthenticatedAt, - }, - ], - 'profiles': [ - for (final row in profileRows) - { - 'id': row.id, - 'kind': row.kind, - 'displayName': row.displayName, - 'avatarThumbUrl': row.avatarThumbUrl, - 'configJson': row.configJson, - 'sortOrder': row.sortOrder, - 'createdAt': row.createdAt, - 'lastUsedAt': row.lastUsedAt, - }, - ], - 'profileConnections': [ - for (final row in joinRows) - { - 'profileId': row.profileId, - 'connectionId': row.connectionId, - 'userToken': row.userToken, - 'userIdentifier': row.userIdentifier, - 'isDefault': row.isDefault, - 'tokenAcquiredAt': row.tokenAcquiredAt, - 'lastUsedAt': row.lastUsedAt, - }, - ], + 'connections': [for (final row in connectionRows) row.toJson()], + 'profiles': [for (final row in profileRows) row.toJson()], + 'profileConnections': [for (final row in joinRows) row.toJson()], }; } Future> _readPendingRecoveryRows() async { final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(); return { - 'offlineWatchProgress': [ - for (final row in rows) - { - 'id': row.id, - 'profileId': row.profileId, - 'serverId': row.serverId, - 'clientScopeId': row.clientScopeId, - 'ratingKey': row.ratingKey, - 'globalKey': row.globalKey, - 'actionType': row.actionType, - 'viewOffset': row.viewOffset, - 'duration': row.duration, - 'shouldMarkWatched': row.shouldMarkWatched, - 'createdAt': row.createdAt, - 'updatedAt': row.updatedAt, - 'syncAttempts': row.syncAttempts, - 'lastError': row.lastError, - }, - ], + 'offlineWatchProgress': [for (final row in rows) row.toJson()], }; } Future _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async { - final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', const { - 'id', - 'kind', - 'displayName', - 'configJson', - 'isDefault', - 'createdAt', - 'lastAuthenticatedAt', - }); - final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', const { - 'id', - 'kind', - 'displayName', - 'avatarThumbUrl', - 'configJson', - 'sortOrder', - 'createdAt', - 'lastUsedAt', - }); - final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', const { - 'profileId', - 'connectionId', - 'userToken', - 'userIdentifier', - 'isDefault', - 'tokenAcquiredAt', - 'lastUsedAt', - }); - final pendingRows = _decodeRecoveryRows(snapshot.pending, 'offlineWatchProgress', const { - 'id', - 'profileId', - 'serverId', - 'clientScopeId', - 'ratingKey', - 'globalKey', - 'actionType', - 'viewOffset', - 'duration', - 'shouldMarkWatched', - 'createdAt', - 'updatedAt', - 'syncAttempts', - 'lastError', - }); + final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', ConnectionRow.fromJson); + final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', ProfileRow.fromJson); + final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', ProfileConnectionRow.fromJson); + final pendingRows = _decodeRecoveryRows( + snapshot.pending, + 'offlineWatchProgress', + OfflineWatchProgressItem.fromJson, + ); // Recovery images from releases before the credential vault may contain // plaintext secrets. Protect them before they cross into Drift; already // protected values remain byte-identical because vault protection is // idempotent. - for (final row in connectionRows) { - final kind = _requiredRecoveryValue(row, 'kind'); - final configJson = _requiredRecoveryValue(row, 'configJson'); - final decoded = jsonDecode(configJson); + for (var index = 0; index < connectionRows.length; index++) { + final row = connectionRows[index]; + final decoded = jsonDecode(row.configJson); if (decoded is! Map) { throw const FormatException('Invalid connection configuration'); } - if (_containsPlaintextConnectionCredential(kind, decoded)) { - row['configJson'] = jsonEncode(await CredentialVault.protectConnectionConfig(kind, decoded)); + if (_containsPlaintextConnectionCredential(row.kind, decoded)) { + connectionRows[index] = row.copyWith( + configJson: jsonEncode(await CredentialVault.protectConnectionConfig(row.kind, decoded)), + ); } } - for (final row in joinRows) { - final token = _requiredRecoveryValue(row, 'userToken'); - if (token.isNotEmpty && !CredentialVault.isProtected(token)) { - row['userToken'] = await CredentialVault.protect(token); + for (var index = 0; index < joinRows.length; index++) { + final row = joinRows[index]; + if (row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken)) { + joinRows[index] = row.copyWith(userToken: await CredentialVault.protect(row.userToken)); } } - final connectionCompanions = [ - for (final row in connectionRows) - ConnectionsCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - kind: Value(_requiredRecoveryValue(row, 'kind')), - displayName: Value(_requiredRecoveryValue(row, 'displayName')), - configJson: Value(_requiredRecoveryValue(row, 'configJson')), - isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - lastAuthenticatedAt: Value(_nullableRecoveryValue(row, 'lastAuthenticatedAt')), - ), - ]; - final profileCompanions = [ - for (final row in profileRows) - ProfilesCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - kind: Value(_requiredRecoveryValue(row, 'kind')), - displayName: Value(_requiredRecoveryValue(row, 'displayName')), - avatarThumbUrl: Value(_nullableRecoveryValue(row, 'avatarThumbUrl')), - configJson: Value(_requiredRecoveryValue(row, 'configJson')), - sortOrder: Value(_requiredRecoveryValue(row, 'sortOrder')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), - ), - ]; - final joinCompanions = [ - for (final row in joinRows) - ProfileConnectionsCompanion( - profileId: Value(_requiredRecoveryValue(row, 'profileId')), - connectionId: Value(_requiredRecoveryValue(row, 'connectionId')), - userToken: Value(_requiredRecoveryValue(row, 'userToken')), - userIdentifier: Value(_requiredRecoveryValue(row, 'userIdentifier')), - isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), - tokenAcquiredAt: Value(_nullableRecoveryValue(row, 'tokenAcquiredAt')), - lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), - ), - ]; - final pendingCompanions = [ - for (final row in pendingRows) - OfflineWatchProgressCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - profileId: Value(_nullableRecoveryValue(row, 'profileId')), - serverId: Value(_requiredRecoveryValue(row, 'serverId')), - clientScopeId: Value(_nullableRecoveryValue(row, 'clientScopeId')), - ratingKey: Value(_requiredRecoveryValue(row, 'ratingKey')), - globalKey: Value(_requiredRecoveryValue(row, 'globalKey')), - actionType: Value(_requiredRecoveryValue(row, 'actionType')), - viewOffset: Value(_nullableRecoveryValue(row, 'viewOffset')), - duration: Value(_nullableRecoveryValue(row, 'duration')), - shouldMarkWatched: Value(_requiredRecoveryValue(row, 'shouldMarkWatched')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - updatedAt: Value(_requiredRecoveryValue(row, 'updatedAt')), - syncAttempts: Value(_requiredRecoveryValue(row, 'syncAttempts')), - lastError: Value(_nullableRecoveryValue(row, 'lastError')), - ), - ]; - await transaction(() async { // Recovery completion (the durable marker removal) is deliberately // separate from this transaction. Replace the snapshot-owned rows so a @@ -446,54 +305,57 @@ class AppDatabase extends _$AppDatabase { await delete(profiles).go(); await delete(connections).go(); await delete(offlineWatchProgress).go(); - for (final row in connectionCompanions) { - await into(connections).insert(row); + // `toCompanion(false)` writes every column explicitly, including the + // nulls, so a restored row is byte-identical to the captured one rather + // than picking up column defaults. + for (final row in connectionRows) { + await into(connections).insert(row.toCompanion(false)); } - for (final row in profileCompanions) { - await into(profiles).insert(row); + for (final row in profileRows) { + await into(profiles).insert(row.toCompanion(false)); } - for (final row in joinCompanions) { - await into(profileConnections).insert(row); + for (final row in joinRows) { + await into(profileConnections).insert(row.toCompanion(false)); } - for (final row in pendingCompanions) { - await into(offlineWatchProgress).insert(row); + for (final row in pendingRows) { + await into(offlineWatchProgress).insert(row.toCompanion(false)); } }); } - static List> _decodeRecoveryRows( + static List _decodeRecoveryRows( Map group, String key, - Set expectedKeys, + T Function(Map json) fromJson, ) { final value = group[key]; - if (value is! List) throw const FormatException('Invalid tvOS database recovery image'); + if (value is! List) throw _invalidRecoveryImage; return [ - for (final value in value) - if (value is Map && - value.keys.toSet().containsAll(expectedKeys) && - value.length == expectedKeys.length) - value - else - throw const FormatException('Invalid tvOS database recovery image'), + for (final row in value) + if (row is Map) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage, ]; } - static T _requiredRecoveryValue(Map row, String key) { - final value = row[key]; - if (!row.containsKey(key) || value is! T) { - throw const FormatException('Invalid tvOS database recovery image'); + /// Reads one row through drift's generated deserializer and rejects anything + /// that does not round-trip back to the exact same map. Drift already throws + /// on a missing or mistyped required column; the round-trip additionally + /// rejects unknown and missing-but-nullable columns, which the serializer + /// would otherwise accept silently. + static T _decodeRecoveryRow( + Map row, + T Function(Map json) fromJson, + ) { + final T decoded; + try { + decoded = fromJson(row); + } catch (_) { + throw _invalidRecoveryImage; } - return value; + if (!mapEquals(decoded.toJson(), row)) throw _invalidRecoveryImage; + return decoded; } - static T? _nullableRecoveryValue(Map row, String key) { - if (!row.containsKey(key)) throw const FormatException('Invalid tvOS database recovery image'); - final value = row[key]; - if (value == null) return null; - if (value is! T) throw const FormatException('Invalid tvOS database recovery image'); - return value as T; - } + static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image'); @override int get schemaVersion => 20; @@ -658,89 +520,27 @@ class AppDatabase extends _$AppDatabase { } if (from < 17) { appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)'); - await customStatement(''' - WITH download_metadata_ids AS ( - SELECT global_key, server_id, rating_key AS metadata_id - FROM downloaded_media - UNION - SELECT global_key, server_id, parent_rating_key AS metadata_id - FROM downloaded_media - WHERE parent_rating_key IS NOT NULL - AND parent_rating_key != '' - UNION - SELECT global_key, server_id, grandparent_rating_key AS metadata_id - FROM downloaded_media - WHERE grandparent_rating_key IS NOT NULL - AND grandparent_rating_key != '' - ) - INSERT INTO api_cache (cache_key, data, pinned, cached_at) - SELECT DISTINCT - metadata.server_id - || '/~plex-profile/' - || owner.profile_id - || ':' - || substr(source.cache_key, length(metadata.server_id) + 2), - source.data, - source.pinned, - source.cached_at - FROM download_metadata_ids AS metadata - JOIN download_owners AS owner - ON owner.global_key = metadata.global_key - JOIN api_cache AS source - ON source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id - OR source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' - WHERE source.pinned = 1 - ON CONFLICT(cache_key) DO UPDATE SET - data = excluded.data, - pinned = excluded.pinned, - cached_at = excluded.cached_at - '''); + await customStatement( + _rescopePinnedPlexMetadataStatement( + namespaceExpression: "'/~plex-profile/' || owner.profile_id || ':'", + ownerJoin: '''JOIN download_owners AS owner + ON owner.global_key = metadata.global_key''', + ), + ); // A direct pre-v14 upgrade has no owners yet: profiles and owner // adoption are bootstrapped only after the database opens. Preserve // those downloads in the neutral Plex transfer namespace so the // first profile can adopt them without inheriting legacy watch data. - await customStatement(''' - WITH download_metadata_ids AS ( - SELECT global_key, server_id, rating_key AS metadata_id - FROM downloaded_media - UNION - SELECT global_key, server_id, parent_rating_key AS metadata_id - FROM downloaded_media - WHERE parent_rating_key IS NOT NULL - AND parent_rating_key != '' - UNION - SELECT global_key, server_id, grandparent_rating_key AS metadata_id - FROM downloaded_media - WHERE grandparent_rating_key IS NOT NULL - AND grandparent_rating_key != '' - ) - INSERT INTO api_cache (cache_key, data, pinned, cached_at) - SELECT DISTINCT - metadata.server_id - || '/~plex-transfer:' - || substr(source.cache_key, length(metadata.server_id) + 2), - source.data, - source.pinned, - source.cached_at - FROM download_metadata_ids AS metadata - JOIN api_cache AS source - ON source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id - OR source.cache_key = - metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' - WHERE source.pinned = 1 - AND NOT EXISTS ( - SELECT 1 - FROM download_owners AS owner - WHERE owner.global_key = metadata.global_key - ) - ON CONFLICT(cache_key) DO UPDATE SET - data = excluded.data, - pinned = excluded.pinned, - cached_at = excluded.cached_at - '''); + await customStatement( + _rescopePinnedPlexMetadataStatement( + namespaceExpression: "'/~plex-transfer:'", + ownerFilter: '''AND NOT EXISTS ( + SELECT 1 + FROM download_owners AS owner + WHERE owner.global_key = metadata.global_key + )''', + ), + ); final transferRows = await customSelect(''' SELECT cache_key, data @@ -941,10 +741,6 @@ class AppDatabase extends _$AppDatabase { } } - Expression _clientScopePredicate(GeneratedColumn column, String? clientScopeId) { - return clientScopeId == null ? column.isNull() : column.equals(clientScopeId); - } - Expression _nullableTextPredicate(GeneratedColumn column, String? value) { return value == null ? column.isNull() : column.equals(value); } @@ -971,6 +767,7 @@ class AppDatabase extends _$AppDatabase { } /// Get pending watch actions for a specific server + @visibleForTesting Future> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) { return (select(offlineWatchProgress) ..where( @@ -994,12 +791,13 @@ class AppDatabase extends _$AppDatabase { (t) => matchesKey(t) & (filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) & - (filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)), + (filterClientScope ? _nullableTextPredicate(t.clientScopeId, clientScopeId) : const Constant(true)), ) ..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]); } /// Get the latest action for a specific item + @visibleForTesting Future getLatestWatchAction( String globalKey, { String? profileId, @@ -1102,7 +900,7 @@ class AppDatabase extends _$AppDatabase { (t) => t.globalKey.equals(globalKey) & _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId) & + _nullableTextPredicate(t.clientScopeId, clientScopeId) & t.actionType.equals(OfflineActionType.progress.id), ) ..orderBy([(t) => OrderingTerm.asc(t.id)])) @@ -1164,7 +962,7 @@ class AppDatabase extends _$AppDatabase { (t) => t.globalKey.equals(globalKey) & _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId), + _nullableTextPredicate(t.clientScopeId, clientScopeId), )) .go(); @@ -1384,29 +1182,21 @@ class AppDatabase extends _$AppDatabase { } } - Future updateSyncRuleCount(String globalKey, int episodeCount) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount))); + Future _writeSyncRule(String globalKey, SyncRulesCompanion values) async { + await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values); } - Future updateSyncRuleFilter(String globalKey, String downloadFilter) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter))); - } + Future updateSyncRuleCount(String globalKey, int episodeCount) => + _writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount))); - Future updateSyncRuleEnabled(String globalKey, bool enabled) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled))); - } + Future updateSyncRuleFilter(String globalKey, String downloadFilter) => + _writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter))); - Future updateSyncRuleLastExecuted(String globalKey) async { - await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( - SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)), - ); - } + Future updateSyncRuleEnabled(String globalKey, bool enabled) => + _writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled))); + + Future updateSyncRuleLastExecuted(String globalKey) => + _writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch))); Future completeSyncRuleExecution(String globalKey) { return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( @@ -1437,6 +1227,57 @@ class AppDatabase extends _$AppDatabase { } } +/// Builds the v17 statement that re-keys pinned legacy Plex metadata rows into +/// a scoped cache namespace. +/// +/// The owned and the ownerless branch run the same operation over the same +/// `download_metadata_ids` set and differ only in three spots: the expression +/// spliced into the new `cache_key` ([namespaceExpression]), an optional join +/// that exposes the owning profile ([ownerJoin]), and an optional extra +/// predicate that keeps each branch to its own rows ([ownerFilter]). +String _rescopePinnedPlexMetadataStatement({ + required String namespaceExpression, + String ownerJoin = '', + String ownerFilter = '', +}) => + ''' + WITH download_metadata_ids AS ( + SELECT global_key, server_id, rating_key AS metadata_id + FROM downloaded_media + UNION + SELECT global_key, server_id, parent_rating_key AS metadata_id + FROM downloaded_media + WHERE parent_rating_key IS NOT NULL + AND parent_rating_key != '' + UNION + SELECT global_key, server_id, grandparent_rating_key AS metadata_id + FROM downloaded_media + WHERE grandparent_rating_key IS NOT NULL + AND grandparent_rating_key != '' + ) + INSERT INTO api_cache (cache_key, data, pinned, cached_at) + SELECT DISTINCT + metadata.server_id + || $namespaceExpression + || substr(source.cache_key, length(metadata.server_id) + 2), + source.data, + source.pinned, + source.cached_at + FROM download_metadata_ids AS metadata + $ownerJoin + JOIN api_cache AS source + ON source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id + OR source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' + WHERE source.pinned = 1 + $ownerFilter + ON CONFLICT(cache_key) DO UPDATE SET + data = excluded.data, + pinned = excluded.pinned, + cached_at = excluded.cached_at +'''; + Future _resolveProductionDatabaseFile() async { final dbFolder = (Platform.isAndroid || Platform.isIOS) ? await getApplicationDocumentsDirectory() diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index d36387e2..116f2159 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -1,6 +1,7 @@ import 'dart:convert'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import '../media/ids.dart'; import 'app_database.dart'; @@ -147,6 +148,7 @@ extension DownloadDatabaseOperations on AppDatabase { return (await _validDownloadOwnerRows(globalKey)).length; } + @visibleForTesting Future hasDownloadOwner(String globalKey, {String? excludingProfileId}) async { final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId); return rows.isNotEmpty; @@ -278,64 +280,6 @@ extension DownloadDatabaseOperations on AppDatabase { } } - Future insertDownload({ - required ServerId serverId, - String? clientScopeId, - required String ratingKey, - required String globalKey, - required String type, - String? parentRatingKey, - String? grandparentRatingKey, - required int status, - int mediaIndex = 0, - String? mediaSourceId, - }) async { - await customUpdate( - ''' - INSERT INTO downloaded_media ( - server_id, - client_scope_id, - rating_key, - global_key, - type, - parent_rating_key, - grandparent_rating_key, - status, - media_index, - media_source_id - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - ON CONFLICT(global_key) DO UPDATE SET - server_id = excluded.server_id, - client_scope_id = excluded.client_scope_id, - rating_key = excluded.rating_key, - type = excluded.type, - parent_rating_key = excluded.parent_rating_key, - grandparent_rating_key = excluded.grandparent_rating_key, - status = excluded.status, - progress = 0, - total_bytes = NULL, - downloaded_bytes = 0, - error_message = NULL, - retry_count = 0, - media_index = excluded.media_index, - media_source_id = excluded.media_source_id - ''', - variables: [ - Variable(serverId), - Variable(clientScopeId), - Variable(ratingKey), - Variable(globalKey), - Variable(type), - Variable(parentRatingKey), - Variable(grandparentRatingKey), - Variable(status), - Variable(mediaIndex), - Variable(mediaSourceId), - ], - updates: {downloadedMedia}, - ); - } - Future addToQueue({ required String mediaGlobalKey, int priority = 0, @@ -582,6 +526,7 @@ extension DownloadDatabaseOperations on AppDatabase { return (await query.map((row) => row.read(count) ?? 0).getSingle()); } + @visibleForTesting Future> getReferencedDownloadSafRoots() async { final rows = await (selectOnly(downloadedMedia) diff --git a/lib/database/tables.dart b/lib/database/tables.dart index 94fd76d5..e8571f27 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -218,7 +218,7 @@ class ProfileConnections extends Table { // Profile.virtualPlexHome from PlexHomeService's live cache, never // persisted in `profiles`), so an FK here would reject every join row // they need. Profile deletion instead cleans up join rows explicitly - // (removeAllProfileConnectionsAndCleanup in profile_connection_cleanup) + // (ProfileConnectionCleanup.removeAllProfileConnections) // before calling ProfileRegistry.remove. TextColumn get profileId => text()(); TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)(); diff --git a/lib/focus/card_focus_scope.dart b/lib/focus/card_focus_scope.dart index ffb247c2..ebcbf76b 100644 --- a/lib/focus/card_focus_scope.dart +++ b/lib/focus/card_focus_scope.dart @@ -5,8 +5,8 @@ import 'focus_theme.dart'; /// Exposes the focus state of an enclosing focus wrapper to a descendant /// [CardFocusBorder] that draws the focus border itself. /// -/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this -/// instead of painting a border when `delegateFocusBorder` is set, so cards can +/// The shared focus chrome ([buildFocusChrome]) inserts this instead of painting +/// a border when `delegateFocusBorder` is set, so cards can /// put the border on the exact rect the design highlights (the poster image, /// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder] /// element registers a dependency, so a focus flip rebuilds just that border diff --git a/lib/focus/dpad_reorder_mixin.dart b/lib/focus/dpad_reorder_mixin.dart new file mode 100644 index 00000000..38e07333 --- /dev/null +++ b/lib/focus/dpad_reorder_mixin.dart @@ -0,0 +1,207 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../widgets/overlay_sheet.dart'; +import 'dpad_navigator.dart'; +import 'key_event_utils.dart'; + +/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows +/// inside a sheet or dialog. +/// +/// The host keeps its list and row widgets; this mixin owns the virtual cursor +/// ([focusedIndex] / [focusedColumn]), the move-mode state and the key handler. +/// Wire it up by passing [handleReorderKeyEvent] to the list's +/// `Focus.onKeyEvent` and by reading [focusedIndex], [focusedColumn] and +/// [movingIndex] when building rows. +/// +/// Navigation mode: UP/DOWN move between rows (resetting to column 0), +/// LEFT/RIGHT move between the row (column 0) and the trailing action columns +/// up to [lastReorderColumn], SELECT on column 0 enters move mode and on any +/// other column calls [onReorderColumnActivated]. +/// +/// Move mode: UP/DOWN swap the moving row with its neighbour, SELECT confirms +/// through [onReorderMoveConfirmed], and BACK restores the order captured when +/// move mode was entered. BACK outside move mode dismisses the hosting sheet. +/// D-pad keys are consumed at the list boundaries so focus cannot escape. +mixin DpadReorderListMixin on State { + /// Row height assumed by [ensureFocusedVisible] (Material `ListTile` with a + /// subtitle) and the list's top padding. + static const double _itemHeight = 72.0; + static const double _listTopPadding = 8.0; + + /// Row the virtual cursor sits on. + int focusedIndex = 0; + + /// Column within [focusedIndex]: 0 is the row itself, 1..[lastReorderColumn] + /// are the trailing action buttons. + int focusedColumn = 0; + + /// Row being moved, or null when not in move mode. + int? movingIndex; + + int? _originalIndex; + List? _originalOrder; + bool _backKeyDownSeen = false; + + /// The list being reordered. Mutated in place while moving and replaced + /// wholesale when a move is cancelled. + List get reorderItems; + set reorderItems(List value); + + /// Right-most focusable column index (0 when the row has no action buttons). + int get lastReorderColumn; + + /// Scrollable holding the rows, or null when the host does not scroll the + /// focused row into view. + ScrollController? get reorderScrollController; + + /// Called when SELECT confirms a move; [reorderItems] already holds the new + /// order. + void onReorderMoveConfirmed(); + + /// Called when SELECT activates a trailing action column (1 or greater). + void onReorderColumnActivated(int column, int index); + + /// Scrolls [focusedIndex] into view, parking it ~25% from the viewport top. + void ensureFocusedVisible() { + final scrollController = reorderScrollController; + if (scrollController == null || !scrollController.hasClients) return; + + final double targetTop = _listTopPadding + (focusedIndex * _itemHeight); + final double targetBottom = targetTop + _itemHeight; + + final double viewportTop = scrollController.offset; + final double viewportHeight = scrollController.position.viewportDimension; + final double viewportBottom = viewportTop + viewportHeight; + + // Already fully visible — skip + if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; + + final double destination = (targetTop - viewportHeight * 0.25).clamp( + 0.0, + scrollController.position.maxScrollExtent, + ); + + scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); + } + + KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + + // Track back key down/up pairing. If focus was elsewhere during KeyDown + // (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray + // KeyUp that would incorrectly pop the dialog. Consume it instead. + if (key.isBackKey) { + if (event is KeyDownEvent) { + _backKeyDownSeen = true; + } else if (event is KeyUpEvent && !_backKeyDownSeen) { + return KeyEventResult.handled; + } + if (event is KeyUpEvent) { + _backKeyDownSeen = false; + } + } + + final backResult = handleBackKeyAction(event, () { + if (movingIndex != null) { + // Cancel move - restore original position + setState(() { + final originalOrder = _originalOrder; + if (originalOrder != null) { + reorderItems = List.from(originalOrder); + } + focusedIndex = _originalIndex ?? 0; + movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + } else { + OverlaySheetController.popAdaptive(context); + } + }); + if (backResult != KeyEventResult.ignored) { + return backResult; + } + + if (!event.isActionable) return KeyEventResult.ignored; + + final int? moving = movingIndex; + if (moving != null) { + // Move mode - arrows reorder the item + if (key.isUpKey && moving > 0) { + _swapMovingItem(moving, moving - 1); + return KeyEventResult.handled; + } + if (key.isDownKey && moving < reorderItems.length - 1) { + _swapMovingItem(moving, moving + 1); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + // Confirm move - apply the reorder + onReorderMoveConfirmed(); + setState(() { + movingIndex = null; + _originalIndex = null; + _originalOrder = null; + }); + return KeyEventResult.handled; + } + } else { + // Navigation mode + if (key.isUpKey && focusedIndex > 0) { + setState(() { + focusedIndex--; + focusedColumn = 0; // Reset to row when changing rows + }); + ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isDownKey && focusedIndex < reorderItems.length - 1) { + setState(() { + focusedIndex++; + focusedColumn = 0; // Reset to row when changing rows + }); + ensureFocusedVisible(); + return KeyEventResult.handled; + } + if (key.isLeftKey && focusedColumn > 0) { + setState(() => focusedColumn--); + return KeyEventResult.handled; + } + if (key.isRightKey && focusedColumn < lastReorderColumn) { + setState(() => focusedColumn++); + return KeyEventResult.handled; + } + if (key.isSelectKey) { + if (focusedColumn == 0) { + // Enter move mode + setState(() { + movingIndex = focusedIndex; + _originalIndex = focusedIndex; + _originalOrder = List.from(reorderItems); + }); + } else { + onReorderColumnActivated(focusedColumn, focusedIndex); + } + return KeyEventResult.handled; + } + } + + // Block d-pad keys at boundaries so focus doesn't escape the dialog + if (key.isDpadDirection) { + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _swapMovingItem(int from, int to) { + setState(() { + final item = reorderItems.removeAt(from); + reorderItems.insert(to, item); + movingIndex = to; + focusedIndex = to; + }); + ensureFocusedVisible(); + } +} diff --git a/lib/focus/focus_chrome.dart b/lib/focus/focus_chrome.dart new file mode 100644 index 00000000..841339d4 --- /dev/null +++ b/lib/focus/focus_chrome.dart @@ -0,0 +1,59 @@ +import 'package:flutter/material.dart'; + +import 'card_focus_scope.dart'; +import 'focus_glow_overlay.dart'; +import 'focus_theme.dart'; + +/// Builds the focus border/glow chrome shared by [FocusableWrapper] and +/// [FocusBuilders.buildLockedFocusWrapper]. +/// +/// A function rather than a widget so it costs no element per card in dense TV +/// grids. Scale and input handling stay with the callers: the wrapper drives a +/// paint-only scale from its own controller and owns the [Focus] node, while +/// the locked builder scales implicitly and wraps gestures itself. +/// +/// Callers pass the [duration] they already resolved via +/// [FocusTheme.getAnimationDuration] so a build resolves it once. +Widget buildFocusChrome( + BuildContext context, { + required bool showFocus, + required Duration duration, + double borderRadius = FocusTheme.defaultBorderRadius, + BorderRadius? borderRadii, + Color? focusColor, + bool useBackgroundFocus = false, + bool useFocusGlow = false, + bool delegateFocusBorder = false, + Size? glowSize, + required Widget child, +}) { + Widget card; + if (delegateFocusBorder) { + card = CardFocusScope(showFocus: showFocus, child: child); + } else { + final decoration = useBackgroundFocus + ? FocusTheme.focusBackgroundDecoration(isFocused: showFocus, borderRadius: borderRadius, radii: borderRadii) + : FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: borderRadius, + radii: borderRadii, + color: focusColor, + ); + card = AnimatedContainer(duration: duration, curve: Curves.easeOutCubic, decoration: decoration, child: child); + } + + // Glow (full-bleed cards) renders in an overlay above siblings so it stays + // symmetric; the in-card decoration only carries the border. + if (useFocusGlow) { + card = FocusGlowOverlay( + isFocused: showFocus, + borderRadius: borderRadius, + color: focusColor ?? FocusTheme.getFocusBorderColor(context), + glowSize: glowSize, + child: card, + ); + } + + return card; +} diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index e2fe987d..3067b44d 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -32,22 +32,18 @@ class ChipKeyCallbacks { /// This mixin handles: /// - Internal/external FocusNode pattern /// - `_isFocused` state tracking -/// - Listener setup in `initState` -/// - Listener handoff in `didUpdateWidget` -/// - Cleanup in `dispose` +/// - Listener setup, handoff and cleanup across the State lifecycle /// /// To use this mixin: /// 1. Add `with FocusableChipStateMixin` to your State class /// 2. Implement [widgetFocusNode] to return the widget's optional focusNode /// 3. Implement [debugLabel] to return a debug label for the internal node -/// 4. Call [initFocusNode] in your `initState` -/// 5. Call [updateFocusNode] in your `didUpdateWidget` -/// 6. Call [disposeFocusNode] in your `dispose` -/// 7. Use [focusNode] and [isFocused] in your build method +/// 4. Use [focusNode] and [isFocused] in your build method mixin FocusableChipStateMixin on State { final _focusNodeBinding = OwnedFocusNodeBinding(); bool _isFocused = false; final _selectLongPress = DpadSelectLongPressController(); + FocusNode? _boundExternalNode; /// Override to return the widget's optional external focus node. FocusNode? get widgetFocusNode; @@ -61,22 +57,30 @@ mixin FocusableChipStateMixin on State { /// Whether this widget is currently focused. bool get isFocused => _isFocused; - /// Call this in your `initState` to set up the focus listener. - void initFocusNode() { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); + @override + void initState() { + super.initState(); + _bindFocusNode(); } - /// Call this in your `didUpdateWidget` with the old widget's focusNode. - void updateFocusNode(FocusNode? oldFocusNode) { - if (oldFocusNode != widgetFocusNode) { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); + @override + void didUpdateWidget(T oldWidget) { + super.didUpdateWidget(oldWidget); + if (_boundExternalNode != widgetFocusNode) { + _bindFocusNode(); } } - /// Call this in your `dispose` to clean up the focus listener. - void disposeFocusNode() { + @override + void dispose() { _focusNodeBinding.dispose(); _selectLongPress.dispose(); + super.dispose(); + } + + void _bindFocusNode() { + _boundExternalNode = widgetFocusNode; + _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); } void _onFocusChange() { @@ -102,6 +106,12 @@ mixin FocusableChipStateMixin on State { /// /// Returns [KeyEventResult.handled] if the event was consumed, /// [KeyEventResult.ignored] otherwise. + /// + /// Runs the same activation sequence as `_FocusableWrapperState._handleKeyEvent` + /// but is deliberately kept separate: a chip leaves the context-menu key + /// unconsumed when [ChipKeyCallbacks.onLongPress] is null and traps RIGHT/DOWN + /// so focus cannot escape the strip, where a wrapper does the opposite on both + /// counts. KeyEventResult handleChipKeyEvent(FocusNode _, KeyEvent event, ChipKeyCallbacks callbacks) { final key = event.logicalKey; diff --git a/lib/focus/focusable_tile_mixin.dart b/lib/focus/focusable_tile_mixin.dart index 6ed39821..9b11c40d 100644 --- a/lib/focus/focusable_tile_mixin.dart +++ b/lib/focus/focusable_tile_mixin.dart @@ -7,23 +7,35 @@ import 'owned_focus_node_binding.dart'; /// auto-scrolls the tile into view when it gains focus. mixin FocusableTileStateMixin on State { final _focusNodeBinding = OwnedFocusNodeBinding(); + FocusNode? _boundExternalNode; FocusNode? get widgetFocusNode; FocusNode get effectiveFocusNode => _focusNodeBinding.node; - void initFocusNode() { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); + @override + void initState() { + super.initState(); + _bindFocusNode(); } - void updateFocusNode(FocusNode? oldFocusNode) { - if (oldFocusNode != widgetFocusNode) { - _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); + @override + void didUpdateWidget(T oldWidget) { + super.didUpdateWidget(oldWidget); + if (_boundExternalNode != widgetFocusNode) { + _bindFocusNode(); } } - void disposeFocusNode() { + @override + void dispose() { _focusNodeBinding.dispose(); + super.dispose(); + } + + void _bindFocusNode() { + _boundExternalNode = widgetFocusNode; + _focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); } void _onFocusChange() { diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 606f7c41..1179f3e1 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -4,10 +4,9 @@ import 'package:flutter/rendering.dart'; import '../widgets/clickable_cursor.dart'; import '../utils/text_input_diagnostics.dart'; -import 'card_focus_scope.dart'; import 'dpad_navigator.dart'; import 'dpad_select_long_press_controller.dart'; -import 'focus_glow_overlay.dart'; +import 'focus_chrome.dart'; import 'focus_theme.dart'; import 'input_mode_tracker.dart'; import 'owned_focus_node_binding.dart'; @@ -426,6 +425,11 @@ class _FocusableWrapperState extends State with SingleTickerPr }); } + // Runs the same activation sequence as FocusableChipStateMixin.handleChipKeyEvent + // but is deliberately kept separate: a wrapper always consumes the context-menu + // key (even with no onLongPress, so a card never leaks it upward) and passes + // every unmapped arrow through to framework traversal, where a chip does the + // opposite on both counts. KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { final key = event.logicalKey; final diagnosticsEnabled = TextInputDiagnostics.enabled; @@ -552,41 +556,20 @@ class _FocusableWrapperState extends State with SingleTickerPr // Keep the card subtree outside the scale builder. Rebuilding media-card // semantics on every animation tick is substantially more expensive than // changing the paint transform alone on dense TV grids. - Widget card; - if (widget.delegateFocusBorder) { - card = CardFocusScope(showFocus: showFocus, child: widget.child); - } else { - final focusDecoration = widget.useBackgroundFocus - ? FocusTheme.focusBackgroundDecoration( - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - ) - : FocusTheme.focusDecoration( - context, - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - color: widget.focusColor, - ); - card = AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: focusDecoration, - child: widget.child, - ); - } - if (widget.useFocusGlow) { - card = FocusGlowOverlay( - isFocused: showFocus, - borderRadius: widget.borderRadius, - color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context), - child: card, - ); - } inner = AnimatedBuilder( animation: _scaleAnimation!, - child: card, + child: buildFocusChrome( + context, + showFocus: showFocus, + duration: duration, + borderRadius: widget.borderRadius, + borderRadii: widget.borderRadii, + focusColor: widget.focusColor, + useBackgroundFocus: widget.useBackgroundFocus, + useFocusGlow: widget.useFocusGlow, + delegateFocusBorder: widget.delegateFocusBorder, + child: widget.child, + ), builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!), ); } diff --git a/lib/main.dart b/lib/main.dart index 9891c797..73c2c501 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -24,6 +24,7 @@ import 'profiles/profile.dart'; import 'profiles/profile_connection_cleanup.dart'; import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_registry.dart'; +import 'profiles/profile_selection_policy.dart'; import 'mixins/mounted_set_state_mixin.dart'; import 'theme/mono_theme.dart'; import 'profiles/plex_home_service.dart'; @@ -1043,7 +1044,7 @@ class _MainAppState extends State with WidgetsBindingObserver { return provider; }, update: (_, multiServerProvider, previous) { - final provider = previous ?? OfflineModeProvider(_serverManager, multiServerProvider: multiServerProvider); + final provider = previous!; provider.updateMultiServerProvider(multiServerProvider); provider.initialize(); // Idempotent - safe to call again return provider; @@ -1069,8 +1070,7 @@ class _MainAppState extends State with WidgetsBindingObserver { pinPrompt: _rootPinPrompt, shouldDeferInitialBind: (_) async { final settings = await SettingsService.getInstance(); - return settings.read(SettingsService.requireProfileSelectionOnOpen) && - activeProfile.hasMultipleProfiles; + return activeProfile.requiresSelectionOnOpen(settings); }, ); }, @@ -1081,7 +1081,7 @@ class _MainAppState extends State with WidgetsBindingObserver { ChangeNotifierProxyProvider( create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase), update: (context, activeProfile, previous) { - final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase); + final provider = previous!; provider.setActiveProfileId(activeProfile.activeId); return provider; }, @@ -1134,7 +1134,7 @@ class _MainAppState extends State with WidgetsBindingObserver { return _offlineWatchSyncService; }, update: (_, activeProfile, previous) { - final provider = previous ?? _offlineWatchSyncService; + final provider = previous!; provider.setActiveProfileId( activeProfile.activeId, availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null, @@ -1147,14 +1147,12 @@ class _MainAppState extends State with WidgetsBindingObserver { syncService: context.read(), downloadProvider: context.read(), ), - update: (_, syncService, downloadProvider, previous) { - return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider); - }, + update: (_, syncService, downloadProvider, previous) => previous!, ), ChangeNotifierProxyProvider2( create: (context) => UserProfileProvider(storageService: context.read()), update: (context, activeProfile, connections, previous) { - final provider = previous ?? UserProfileProvider(storageService: context.read()); + final provider = previous!; provider.attach( connections: connections, activeProfile: activeProfile, @@ -1213,7 +1211,7 @@ class _AppShell extends StatelessWidget { themeMode: themeProvider.materialThemeMode, navigatorKey: rootNavigatorKey, navigatorObservers: [BackKeySuppressorObserver()], - home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome), + home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome), // Siri Remote select + gamepad A report as // LogicalKeyboardKey.{select,gameButtonA} which aren't // in Flutter's default shortcut set — Material-level @@ -1300,32 +1298,6 @@ bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) { return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired; } -class OrientationAwareSetup extends StatefulWidget { - const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome}); - - final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome; - - @override - State createState() => _OrientationAwareSetupState(); -} - -class _OrientationAwareSetupState extends State { - @override - void didChangeDependencies() { - super.didChangeDependencies(); - _setOrientationPreferences(); - } - - void _setOrientationPreferences() { - OrientationHelper.restoreDefaultOrientations(context); - } - - @override - Widget build(BuildContext context) { - return SetupScreen(databaseRecoveryOutcome: widget.databaseRecoveryOutcome); - } -} - class SetupScreen extends StatefulWidget { const SetupScreen({ super.key, @@ -1356,6 +1328,15 @@ class _SetupScreenState extends State with MountedSetStateMixin { _loadSavedCredentials(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // The app's first screen: undo any orientation lock a previous run's + // full-screen player left behind, and re-apply it whenever the form + // factor signals (Theme.platform / MediaQuery size) change. + OrientationHelper.restoreDefaultOrientations(context); + } + void _setStatus(String message) { setStateIfMounted(() => _statusMessage = message); } @@ -1418,12 +1399,12 @@ class _SetupScreenState extends State with MountedSetStateMixin { profileRegistry: profileRegistry, ); await bootstrap.run(); - final pruned = await pruneUnreferencedJellyfinConnections( + final pruned = await ProfileConnectionCleanup( profileConnections: profileConnections, connections: connRegistry, storage: storage, serverManager: serverManager, - ); + ).pruneUnreferencedJellyfinConnections(); if (pruned > 0) { appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}'); } @@ -1561,9 +1542,7 @@ class _SetupScreenState extends State with MountedSetStateMixin { final settings = await SettingsService.getInstance(); if (!mounted) return; final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; - final requireOnOpen = - settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; - final shouldPrompt = hasNoActive || requireOnOpen; + final shouldPrompt = hasNoActive || activeProfile.requiresSelectionOnOpen(settings); var bindingSucceeded = activeProfile.lastBindingSucceeded; if (shouldPrompt) { diff --git a/lib/media/episode_collection.dart b/lib/media/episode_collection.dart index e5198ddc..357a21b3 100644 --- a/lib/media/episode_collection.dart +++ b/lib/media/episode_collection.dart @@ -29,6 +29,46 @@ Future collectEpisodes( ); } +/// Walks [items] and collects playable movie/episode/track entries into [out]. +/// Shows and seasons are expanded into their episodes; albums and artists are +/// expanded into their tracks (audio playlists/collections). Clips, nested +/// collections/playlists, and unknown types are skipped. [unwatchedOnly] applies +/// the same played-state filter to every kind — for tracks that means +/// Plex/Jellyfin play counts. +/// +/// Shared by the one-shot "download this list" queue and the sync rule that +/// keeps the same list downloaded, so both expand a list to the same items. +Future collectListLeaves( + MediaServerClient client, + List items, { + required bool unwatchedOnly, + required List out, +}) async { + for (final item in items) { + switch (item.kind) { + case MediaKind.movie: + case MediaKind.episode: + case MediaKind.track: + if (unwatchedOnly && !item.isUnwatchedOrInProgress) break; + out.add(item); + case MediaKind.show: + case MediaKind.season: + await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item); + case MediaKind.album: + case MediaKind.artist: + // One recursive-leaves call per container on both backends + // (Jellyfin retries tag-only artists by album-artist credit). + for (final track in await client.fetchPlayableDescendants(item.id)) { + if (unwatchedOnly && !track.isUnwatchedOrInProgress) continue; + out.add(track); + } + default: + // Skip clips, nested collections/playlists, unknown types. + break; + } + } +} + /// Fetch just the first episode of a season without walking the entire season. /// Use this for representative lookups and immediate "play first" actions. Future fetchFirstEpisodeForSeason( diff --git a/lib/media/library_query.dart b/lib/media/library_query.dart index e3c472e2..b5cbd022 100644 --- a/lib/media/library_query.dart +++ b/lib/media/library_query.dart @@ -1,6 +1,7 @@ // ignore_for_file: invalid_annotation_target import 'package:freezed_annotation/freezed_annotation.dart'; +import '../utils/media_server_http_client.dart' show AbortController; import 'media_kind.dart'; part 'library_query.freezed.dart'; @@ -88,3 +89,33 @@ int fallbackPageTotal({required int offset, required int itemCount, int? request final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize; return offset + itemCount + (fullPage ? 1 : 0); } + +/// Walk every page of a paginated endpoint and concatenate the results. +/// +/// [fetchPage] receives a zero-based offset and [pageSize] and is called until +/// a page comes back empty, the accumulated count reaches the page's +/// [LibraryPage.totalCount], or — when [stopOnShortPage] is set — a page comes +/// back shorter than [pageSize]. The short-page break is for backends whose +/// total is unreliable; leave it off when the total is authoritative. +/// +/// [abort] is checked before and after every request. Errors propagate. +Future> drainPages( + Future> Function(int start, int size) fetchPage, { + required int pageSize, + AbortController? abort, + bool stopOnShortPage = false, +}) async { + final all = []; + var start = 0; + while (true) { + abort?.throwIfAborted(); + final page = await fetchPage(start, pageSize); + abort?.throwIfAborted(); + if (page.items.isEmpty) break; + all.addAll(page.items); + start += page.items.length; + if (start >= page.totalCount) break; + if (stopOnShortPage && page.items.length < pageSize) break; + } + return all; +} diff --git a/lib/media/live_tv_support.dart b/lib/media/live_tv_support.dart index 050ef239..392bfe81 100644 --- a/lib/media/live_tv_support.dart +++ b/lib/media/live_tv_support.dart @@ -1,13 +1,8 @@ import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; -import '../models/livetv_lineup.dart'; import '../models/livetv_program.dart'; -import '../models/livetv_server_status.dart'; -import '../models/livetv_session.dart'; import '../models/media_grab_operation.dart'; -import '../models/media_grabber_device.dart'; -import '../models/media_provider_info.dart'; import '../models/media_subscription.dart'; class LiveTvActivityResult { @@ -202,67 +197,13 @@ abstract class LiveTvSupport { /// recording APIs. abstract class LiveTvDvrSupport { Future> fetchDvrs(); - Future fetchLiveTvServerStatus(); - Future fetchDvr(String dvrId); - Future> createDvr({ - required List devices, - required List lineups, - String? language, - String? country, - String? postalCode, - }); - Future deleteDvr(String dvrId); - Future updateDvrPrefs(String dvrId, Map prefs); - Future attachDeviceToDvr(String dvrId, String deviceId); - Future detachDeviceFromDvr(String dvrId, String deviceId); - Future addLineupToDvr(String dvrId, String lineupUri); - Future removeLineupFromDvr(String dvrId, String lineupUri); Future> reloadGuide(String dvrId); - Future cancelGuideReload(String dvrId); - - Future> fetchGrabbers({String? protocol}); - Future> fetchGrabberDevices(); - Future>> discoverGrabberDevices(); - Future fetchGrabberDevice(String deviceId); - Future addGrabberDevice(String uri, {String? grabberId}); - Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}); - Future deleteGrabberDevice(String deviceId); - Future> fetchGrabberDeviceChannels(String deviceId); - Future> scanGrabberDevice( - String deviceId, { - String? source, - Map prefs = const {}, - String? network, - String? country, - }); - Future cancelGrabberDeviceScan(String deviceId); - Future saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request); - Future updateGrabberDevicePrefs(String deviceId, Map prefs); - String buildGrabberDeviceThumbUrl(String deviceId, int version); - - Future> fetchEpgCountries(); - Future> fetchEpgLanguages(); - Future> fetchEpgRegions(String country, String epgId); - Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}); - Future> fetchEpgChannelsForLineup(String lineupUri); - Future> fetchEpgChannelsForLineups(List lineupUris); - Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}); - Future?>> findBestLineup({ - required String deviceUri, - required String lineupGroupUri, - }); Future> getSubscriptionTemplate(String guid); Future> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}); - Future fetchRecordingRule( - String subscriptionId, { - bool includeGrabs = true, - bool includeStorage = true, - }); Future createRecordingRule(MediaSubscriptionCreateRequest request); Future updateRecordingRule(String subscriptionId, Map prefs); Future deleteRecordingRule(String subscriptionId); - Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}); Future processRecordingRules(); Future> fetchScheduledRecordings(); Future cancelGrab(String operationId); @@ -271,13 +212,4 @@ abstract class LiveTvDvrSupport { required List ratingKeys, bool includeStorage = true, }); - - Future> fetchMediaProviders(); - Future registerMediaProvider(String url); - Future refreshMediaProviders(); - Future unregisterMediaProvider(String providerId); - Future> fetchLiveTvSessionsDetailed(); - Future fetchLiveTvSession(String sessionId); - Uri buildNotificationWebSocketUri({List? filters}); - Uri buildNotificationEventSourceUri({List? filters}); } diff --git a/lib/media/media_item.dart b/lib/media/media_item.dart index 5a3f934c..5a53f86a 100644 --- a/lib/media/media_item.dart +++ b/lib/media/media_item.dart @@ -676,13 +676,6 @@ sealed class MediaItem with _$MediaItem { return resolvedBackdropPaths; } - /// Returns the best hero art path based on the container's aspect ratio. - String? heroArt({required double containerAspectRatio}) { - final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio); - if (candidates.isEmpty) return null; - return candidates.first; - } - /// Returns hero art candidates in display-preference order. List heroArtCandidates({required double containerAspectRatio}) { final own = resolvedBackdropPaths; diff --git a/lib/media/media_kind.dart b/lib/media/media_kind.dart index eb92bb18..8015e816 100644 --- a/lib/media/media_kind.dart +++ b/lib/media/media_kind.dart @@ -26,8 +26,6 @@ enum MediaKind { bool get isVideo => this == movie || this == episode || this == clip; - bool get isShowRelated => this == show || this == season || this == episode; - bool get isMusic => this == artist || this == album || this == track; bool get isPlayable => isVideo || this == track; diff --git a/lib/media/media_playlist.dart b/lib/media/media_playlist.dart index 4b8b06fa..e142db04 100644 --- a/lib/media/media_playlist.dart +++ b/lib/media/media_playlist.dart @@ -61,11 +61,6 @@ class MediaPlaylist { /// Display-friendly title (alias of [title] for parity with [MediaItem]). String get displayTitle => title; - /// Whether this playlist's contents can be reordered/edited by the client. - /// Plex smart playlists are read-only; manual playlists and Jellyfin - /// playlists are editable. - bool get isEditable => !smart; - String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id; MediaPlaylist copyWith({ diff --git a/lib/media/media_server_client.dart b/lib/media/media_server_client.dart index edf6bb5f..1cacbdc4 100644 --- a/lib/media/media_server_client.dart +++ b/lib/media/media_server_client.dart @@ -265,7 +265,7 @@ abstract class MediaServerClient { /// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a /// sidecar-lyrics track stream (`streamType 4`) fetched from /// `/library/streams/{id}` and parsed from LRC. Synced-ness is per - /// [Lyrics.synced]; gated by [ServerCapabilities.lyrics]. + /// [Lyrics.synced]; per-track absence is the runtime gate. Future fetchLyrics(MediaItem track); /// Free-text search across the user's libraries. [limit] is a per-request @@ -274,9 +274,6 @@ abstract class MediaServerClient { /// backend request owned by this search pass. Future> searchItems(String query, {int limit = 100, AbortController? abort}); - /// Recently-added items across all libraries. - Future> fetchRecentlyAdded({int limit = 50}); - /// Items the user has started but not finished. Plex calls this "On Deck" /// internally; the neutral name matches the Continue Watching UI surface. Future> fetchContinueWatching({int? count = 20}); diff --git a/lib/media/media_source_info.dart b/lib/media/media_source_info.dart index 1738f35f..79f3f88a 100644 --- a/lib/media/media_source_info.dart +++ b/lib/media/media_source_info.dart @@ -275,7 +275,6 @@ class MediaMarker { Duration get startTime => Duration(milliseconds: startTimeOffset); Duration get endTime => Duration(milliseconds: endTimeOffset); - bool get isIntro => type == 'intro'; bool get isCredits => type == 'credits'; bool containsPosition(Duration position) { diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 485de4ef..8dcafb82 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -1,17 +1,3 @@ -/// How the alpha-jump bar behaves for libraries on this backend. -enum AlphaBarMode { - /// No alpha bar — hide entirely. - none, - - /// Plex: server reports per-letter cumulative offsets via `/firstCharacter`, - /// taps scroll the grid to the offset. - scrollSnap, - - /// Jellyfin: bar acts as a filter button — taps set `NameStartsWith` query - /// param, results re-fetch. - nameStartsWithFilter, -} - /// Static capability flags advertised by a [MediaServerClient]. UI consults /// these to gate feature affordances per server (e.g. hide Live TV when no /// connected server supports it). @@ -21,14 +7,6 @@ enum AlphaBarMode { /// Jellyfin features are wired in over time, the corresponding flags flip /// without changing call sites. class ServerCapabilities { - /// Server-side `PlayQueue` resource (Plex `/playQueues`) — enables shared - /// queue state across devices and Watch Together coordination. - final bool serverSidePlayQueue; - - /// Server-side editable playlists (Plex `/playlists`, Jellyfin - /// `/Playlists`). - final bool serverSidePlaylists; - /// This backend kind has a Live TV / DVR API the app can talk to. Whether /// a *specific* server has Live TV configured is a runtime concern — /// [MultiServerProvider.checkLiveTvAvailability] probes each server and @@ -41,17 +19,9 @@ class ServerCapabilities { /// when [liveTv] is true. final bool liveTvDvr; - /// Server proxies subtitle search (e.g. OpenSubtitles). - final bool subtitleSearch; - /// Server can transcode video. final bool videoTranscoding; - /// Server supports server-side downloads / "sync" (the queued-from-server - /// model). Both Plex and Jellyfin support client-driven downloads, which - /// is a separate concept. - final bool serverSideSync; - /// Server provides curated recommendation hubs (Plex Discover). Jellyfin /// returns synthesized hubs but with sparser categorisation. final bool richHubs; @@ -72,32 +42,9 @@ class ServerCapabilities { /// Hides the "Search subtitles" affordance when false. final bool externalSubtitleSearch; - /// Persisting per-track audio/subtitle preferences server-side. Plex uses - /// `/library/metadata/{id}/prefs` + `selectStream`; Jellyfin saves selected - /// stream indexes from `/Sessions/Playing/Progress` when the user's Jellyfin - /// remember-selection settings are enabled. When false, in-player switching - /// still works but choices don't follow the user across devices. - final bool trackPreferencePersistence; - - /// Multi-endpoint connection model with endpoint racing/failover. Plex gets - /// local/remote/relay candidates from plex.tv; Jellyfin uses user-entered - /// URLs for the same server. - final bool endpointFailover; - - /// Watch progress can be queued offline and replayed when reconnected - /// ([OfflineWatchSyncService]). Jellyfin reports inline only today. - final bool offlineWatchQueue; - - /// Discord rich-presence integration. Plex-only because the RPC payload - /// uses Plex-shaped session/metadata. - final bool discordRpc; - /// Server exposes metadata edit endpoints. Hides edit affordances when false. final bool richMetadataEdit; - /// How the alpha-jump bar should behave for this backend's libraries. - final AlphaBarMode alphaBar; - /// Server can supply thumbnails for the player's seek-bar scrub preview. /// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite /// sheets. Both backends are wired through [ScrubPreviewSource]; the flag @@ -109,73 +56,40 @@ class ServerCapabilities { /// `/Items?ParentId=...&Recursive=false` queries. final bool folderGrouping; - /// Server can supply track lyrics. Jellyfin exposes `/Audio/{id}/Lyrics`; - /// Plex surfaces sidecar `.lrc`/`.txt` files as track streams - /// (`streamType 4`) fetched via `/library/streams/{id}`. Gates the lyrics - /// affordance in the music player; per-track absence is the runtime gate. - final bool lyrics; - /// Server can build an "instant mix" / radio track list from a seed item. /// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues /// (`POST /playQueues?type=audio&uri=...station...`). final bool instantMix; - /// Server can transcode audio to a capped bitrate. Plex: - /// `/music/:/transcode/universal`; Jellyfin: `PlaybackInfo` with an audio - /// `TranscodingProfile`. Gates the music quality picker (vs original-only). - final bool audioTranscoding; - const ServerCapabilities({ - this.serverSidePlayQueue = false, - this.serverSidePlaylists = false, this.liveTv = false, this.liveTvDvr = false, - this.subtitleSearch = false, this.videoTranscoding = true, - this.serverSideSync = false, this.richHubs = false, this.numericUserRating = false, this.userFavorites = false, this.continueWatchingRemoval = false, this.externalSubtitleSearch = false, - this.trackPreferencePersistence = false, - this.endpointFailover = false, - this.offlineWatchQueue = false, - this.discordRpc = false, this.richMetadataEdit = false, - this.alphaBar = AlphaBarMode.none, this.scrubThumbnails = false, this.folderGrouping = false, - this.lyrics = false, this.instantMix = false, - this.audioTranscoding = false, }); /// Defaults for a fully-featured Plex server. static const ServerCapabilities plex = ServerCapabilities( - serverSidePlayQueue: true, - serverSidePlaylists: true, liveTv: true, liveTvDvr: true, - subtitleSearch: true, videoTranscoding: true, - serverSideSync: true, richHubs: true, numericUserRating: true, userFavorites: false, continueWatchingRemoval: true, externalSubtitleSearch: true, - trackPreferencePersistence: true, - endpointFailover: true, - offlineWatchQueue: true, - discordRpc: true, richMetadataEdit: true, - alphaBar: AlphaBarMode.scrollSnap, scrubThumbnails: true, folderGrouping: true, - lyrics: true, instantMix: true, - audioTranscoding: true, ); /// Defaults for a Jellyfin server. @@ -188,79 +102,37 @@ class ServerCapabilities { /// `/LiveTv/Programs`. Detection + channel listing are wired today; /// EPG and tuning are follow-ups. static const ServerCapabilities jellyfin = ServerCapabilities( - serverSidePlayQueue: false, - serverSidePlaylists: true, liveTv: true, liveTvDvr: false, - subtitleSearch: false, videoTranscoding: true, - serverSideSync: false, richHubs: false, numericUserRating: false, userFavorites: true, externalSubtitleSearch: false, - trackPreferencePersistence: true, - endpointFailover: true, - offlineWatchQueue: false, - discordRpc: false, richMetadataEdit: true, - alphaBar: AlphaBarMode.nameStartsWithFilter, scrubThumbnails: true, folderGrouping: true, - lyrics: true, instantMix: true, - audioTranscoding: true, ); - ServerCapabilities copyWith({ - bool? serverSidePlayQueue, - bool? serverSidePlaylists, - bool? liveTv, - bool? liveTvDvr, - bool? subtitleSearch, - bool? videoTranscoding, - bool? serverSideSync, - bool? richHubs, - bool? numericUserRating, - bool? userFavorites, - bool? continueWatchingRemoval, - bool? externalSubtitleSearch, - bool? trackPreferencePersistence, - bool? endpointFailover, - bool? offlineWatchQueue, - bool? discordRpc, - bool? richMetadataEdit, - AlphaBarMode? alphaBar, - bool? scrubThumbnails, - bool? folderGrouping, - bool? lyrics, - bool? instantMix, - bool? audioTranscoding, - }) { + /// Every flag here is fixed per backend *kind* except [videoTranscoding], + /// which Plex probes per server (`PlexClient.capabilities`) — so that is the + /// only override this type needs. Widen the parameter list if another flag + /// ever becomes a runtime probe. + ServerCapabilities copyWith({bool? videoTranscoding}) { return ServerCapabilities( - serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue, - serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists, - liveTv: liveTv ?? this.liveTv, - liveTvDvr: liveTvDvr ?? this.liveTvDvr, - subtitleSearch: subtitleSearch ?? this.subtitleSearch, + liveTv: liveTv, + liveTvDvr: liveTvDvr, videoTranscoding: videoTranscoding ?? this.videoTranscoding, - serverSideSync: serverSideSync ?? this.serverSideSync, - richHubs: richHubs ?? this.richHubs, - numericUserRating: numericUserRating ?? this.numericUserRating, - userFavorites: userFavorites ?? this.userFavorites, - continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval, - externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch, - trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence, - endpointFailover: endpointFailover ?? this.endpointFailover, - offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue, - discordRpc: discordRpc ?? this.discordRpc, - richMetadataEdit: richMetadataEdit ?? this.richMetadataEdit, - alphaBar: alphaBar ?? this.alphaBar, - scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails, - folderGrouping: folderGrouping ?? this.folderGrouping, - lyrics: lyrics ?? this.lyrics, - instantMix: instantMix ?? this.instantMix, - audioTranscoding: audioTranscoding ?? this.audioTranscoding, + richHubs: richHubs, + numericUserRating: numericUserRating, + userFavorites: userFavorites, + continueWatchingRemoval: continueWatchingRemoval, + externalSubtitleSearch: externalSubtitleSearch, + richMetadataEdit: richMetadataEdit, + scrubThumbnails: scrubThumbnails, + folderGrouping: folderGrouping, + instantMix: instantMix, ); } } diff --git a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart index 55e40b0f..ea40cbad 100644 --- a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart +++ b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart @@ -5,7 +5,6 @@ import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../services/jellyfin_client.dart'; import '../utils/jellyfin_time.dart'; -import '../utils/media_image_helper.dart'; import 'metadata_edit_models.dart'; class JellyfinMetadataEditAdapter extends MetadataEditAdapter { @@ -39,7 +38,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { List buildSchema(MetadataEditDraft draft) { final kind = draft.sourceItem.kind; return [ - MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)), + MetadataEditSection( + id: 'basic', + title: t.metadataEdit.basicInfo, + fields: metadataBasicFields(kind, studioType: MetadataEditFieldType.stringList), + ), if (_tagFields(kind).isNotEmpty) MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), @@ -53,11 +56,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { final dto = Map.from(raw); dto['ProviderIds'] = _stringMap(dto['ProviderIds']); - dto['Tags'] = _stringList(dto['Tags']); - dto['Genres'] = _stringList(dto['Genres']); + dto['Tags'] = metadataStringList(dto['Tags']); + dto['Genres'] = metadataStringList(dto['Genres']); dto['People'] = _mapList(dto['People']); dto['Studios'] = _mapList(dto['Studios']); - dto['LockedFields'] = _stringList(dto['LockedFields']); + dto['LockedFields'] = metadataStringList(dto['LockedFields']); dto['LockData'] = dto['LockData'] == true; dto.remove('Trickplay'); @@ -71,29 +74,29 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { final value = draft.value('originallyAvailableAt') ?? ''; dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']); } - if (_fieldChanged(draft, 'studio')) { + if (_listFieldChanged(draft, 'studio')) { dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio'])); } if (draft.fieldChanged('tagline')) { final tagline = metadataEmptyToNull(draft.value('tagline')); - final existing = _stringList(dto['Taglines']); + final existing = metadataStringList(dto['Taglines']); dto['Taglines'] = tagline == null ? [] : [tagline, ...existing.skip(1)]; } - if (_fieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']); - if (_fieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']); - if (_fieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']); + if (_listFieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']); + if (_listFieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']); + if (_listFieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']); var peopleChanged = false; var people = _mapList(dto['People']); - if (_fieldChanged(draft, 'director')) { + if (_listFieldChanged(draft, 'director')) { people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director'])); peopleChanged = true; } - if (_fieldChanged(draft, 'writer')) { + if (_listFieldChanged(draft, 'writer')) { people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer'])); peopleChanged = true; } - if (_fieldChanged(draft, 'producer')) { + if (_listFieldChanged(draft, 'producer')) { people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer'])); peopleChanged = true; } @@ -132,11 +135,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { .toList(); } - @override - Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { - return applyArtworkFromUrl(draft, field, option.sourceUrl); - } - @override Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { final imageType = field.artwork?.key; @@ -180,12 +178,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { ? metadataFirstString(raw['Taglines']) : item.tagline ?? ''; values['summary'] = raw['Overview'] as String? ?? item.summary ?? ''; - values['genre'] = _stringList(raw['Genres']); + values['genre'] = metadataStringList(raw['Genres']); values['director'] = _peopleByType(raw['People'], 'Director'); values['writer'] = _peopleByType(raw['People'], 'Writer'); values['producer'] = _peopleByType(raw['People'], 'Producer'); - values['country'] = _stringList(raw['ProductionLocations']); - values['label'] = _stringList(raw['Tags']); + values['country'] = metadataStringList(raw['ProductionLocations']); + values['label'] = metadataStringList(raw['Tags']); } void _writeArtworkValues(Map values, MediaItem item) { @@ -194,29 +192,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { values['artwork:Logo'] = item.clearLogoPath; } - List _basicFields(MediaKind kind) { - return [ - MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField( - id: 'originallyAvailableAt', - label: t.metadataEdit.releaseDate, - type: MetadataEditFieldType.date, - ), - if (kind != MediaKind.season) - MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.stringList), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), - MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), - ]; - } - List _tagFields(MediaKind kind) { MetadataEditField tag(String id, String label) => MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); @@ -234,100 +209,20 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { }; } - List _artworkFields(MediaKind kind) { - final fields = [ - // Episode "posters" are 16:9 thumbnails, not 2:3 poster art. - kind == MediaKind.episode - ? _artworkField( - 'Primary', - t.metadataEdit.poster, - t.metadataEdit.selectPoster, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.thumb, - ) - : _artworkField('Primary', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), - ]; - if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { - fields.add( - _artworkField( - 'Backdrop', - t.metadataEdit.background, - t.metadataEdit.selectBackground, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.art, - ), - ); - } - if (kind == MediaKind.movie || kind == MediaKind.show) { - fields.add( - _artworkField( - 'Logo', - t.metadataEdit.logo, - t.metadataEdit.selectLogo, - 80, - 32, - 2, - 2.5, - fit: MetadataArtworkFit.contain, - imageType: ImageType.logo, - ), - ); - } - return fields; - } - - MetadataEditField _artworkField( - String key, - String label, - String title, - double width, - double height, - int columns, - double aspectRatio, { - MetadataArtworkFit fit = MetadataArtworkFit.cover, - ImageType imageType = ImageType.poster, - }) { - return MetadataEditField( - id: 'artwork:$key', - label: label, - type: MetadataEditFieldType.artwork, - saveMode: MetadataEditSaveMode.immediate, - artwork: MetadataArtworkConfig( - key: key, - selectTitle: title, - previewWidth: width, - previewHeight: height, - gridColumns: columns, - gridAspectRatio: aspectRatio, - fit: fit, - imageType: imageType, - ), - ); - } + List _artworkFields(MediaKind kind) => + metadataArtworkFields(kind, posterKey: 'Primary', backdropKey: 'Backdrop', logoKey: 'Logo'); void _setChangedString(Map dto, MetadataEditDraft draft, String fieldId, String dtoKey) { if (!draft.fieldChanged(fieldId)) return; dto[dtoKey] = metadataEmptyToNull(draft.value(fieldId)); } - bool _fieldChanged(MetadataEditDraft draft, String fieldId) { - for (final section in schemaFor(draft)) { - for (final field in section.fields) { - if (field.id == fieldId) return metadataEditFieldChanged(draft, field); - } - } - return draft.fieldChanged(fieldId); - } + /// Every id passed here names a `stringList` field, so the comparison is + /// order-insensitive regardless of which kind's schema is in play. + bool _listFieldChanged(MetadataEditDraft draft, String fieldId) => + !metadataEditStringListEquals(draft.values[fieldId], draft.originalValues[fieldId]); } -List _stringList(Object? value) => metadataStringList(value); - Map _stringMap(Object? value) { if (value is! Map) return {}; return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? '')); diff --git a/lib/metadata_edit/metadata_edit_models.dart b/lib/metadata_edit/metadata_edit_models.dart index bc817887..9b647bc1 100644 --- a/lib/metadata_edit/metadata_edit_models.dart +++ b/lib/metadata_edit/metadata_edit_models.dart @@ -1,3 +1,4 @@ +import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -147,7 +148,9 @@ abstract class MetadataEditAdapter { Future> fetchArtwork(MetadataEditDraft draft, MetadataEditField field); - Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option); + Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { + return applyArtworkFromUrl(draft, field, option.sourceUrl); + } Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url); @@ -160,6 +163,133 @@ abstract class MetadataEditAdapter { } } +/// Basic-info fields shared by every backend; only the studio field type differs. +List metadataBasicFields( + MediaKind kind, { + MetadataEditFieldType studioType = MetadataEditFieldType.text, +}) { + return [ + MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), + if (kind != MediaKind.season) + MetadataEditField( + id: 'originallyAvailableAt', + label: t.metadataEdit.releaseDate, + type: MetadataEditFieldType.date, + ), + if (kind != MediaKind.season) + MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: studioType), + if (kind == MediaKind.movie || kind == MediaKind.show) + MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), + MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), + ]; +} + +/// Artwork fields shared by every backend; each backend supplies its own artwork +/// key names, which kinds carry a logo, and whether square art exists. +List metadataArtworkFields( + MediaKind kind, { + required String posterKey, + required String backdropKey, + required String logoKey, + String? squareKey, + Set logoKinds = const {MediaKind.movie, MediaKind.show}, +}) { + final fields = [ + // Episode "posters" are 16:9 thumbnails, not 2:3 poster art. + kind == MediaKind.episode + ? metadataArtworkField( + posterKey, + t.metadataEdit.poster, + t.metadataEdit.selectPoster, + 80, + 45, + 2, + 16 / 9, + imageType: ImageType.thumb, + ) + : metadataArtworkField(posterKey, t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), + ]; + if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { + fields.add( + metadataArtworkField( + backdropKey, + t.metadataEdit.background, + t.metadataEdit.selectBackground, + 80, + 45, + 2, + 16 / 9, + imageType: ImageType.art, + ), + ); + } + if (logoKinds.contains(kind)) { + fields.add( + metadataArtworkField( + logoKey, + t.metadataEdit.logo, + t.metadataEdit.selectLogo, + 80, + 32, + 2, + 2.5, + fit: MetadataArtworkFit.contain, + imageType: ImageType.logo, + ), + ); + if (squareKey != null) { + fields.add( + metadataArtworkField( + squareKey, + t.metadataEdit.squareArt, + t.metadataEdit.selectSquareArt, + 50, + 50, + 3, + 1, + imageType: ImageType.avatar, + ), + ); + } + } + return fields; +} + +MetadataEditField metadataArtworkField( + String key, + String label, + String title, + double width, + double height, + int columns, + double aspectRatio, { + MetadataArtworkFit fit = MetadataArtworkFit.cover, + ImageType imageType = ImageType.poster, +}) { + return MetadataEditField( + id: 'artwork:$key', + label: label, + type: MetadataEditFieldType.artwork, + saveMode: MetadataEditSaveMode.immediate, + artwork: MetadataArtworkConfig( + key: key, + selectTitle: title, + previewWidth: width, + previewHeight: height, + gridColumns: columns, + gridAspectRatio: aspectRatio, + fit: fit, + imageType: imageType, + ), + ); +} + bool metadataEditValueEquals(Object? a, Object? b) { if (identical(a, b)) return true; if (a is List && b is List) { diff --git a/lib/metadata_edit/plex_metadata_edit_adapter.dart b/lib/metadata_edit/plex_metadata_edit_adapter.dart index 5557f33a..47ca56cf 100644 --- a/lib/metadata_edit/plex_metadata_edit_adapter.dart +++ b/lib/metadata_edit/plex_metadata_edit_adapter.dart @@ -7,7 +7,6 @@ import '../media/media_server_client.dart'; import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/language_codes.dart'; -import '../utils/media_image_helper.dart'; import 'metadata_edit_models.dart'; class PlexMetadataEditAdapter extends MetadataEditAdapter { @@ -60,7 +59,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { List buildSchema(MetadataEditDraft draft) { final kind = draft.sourceItem.kind; return [ - MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)), + MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: metadataBasicFields(kind)), if (_tagFields(kind).isNotEmpty) MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), @@ -133,11 +132,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { .toList(); } - @override - Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { - return applyArtworkFromUrl(draft, field, option.sourceUrl); - } - @override Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { final element = field.artwork?.key; @@ -213,29 +207,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { } } - List _basicFields(MediaKind kind) { - return [ - MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text), - if (kind != MediaKind.season) - MetadataEditField( - id: 'originallyAvailableAt', - label: t.metadataEdit.releaseDate, - type: MetadataEditFieldType.date, - ), - if (kind != MediaKind.season) - MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.text), - if (kind == MediaKind.movie || kind == MediaKind.show) - MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text), - MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText), - ]; - } - List _tagFields(MediaKind kind) { MetadataEditField tag(String id, String label) => MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); @@ -267,94 +238,14 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { }; } - List _artworkFields(MediaKind kind) { - final fields = [ - // Episode "posters" are 16:9 thumbnails, not 2:3 poster art. - kind == MediaKind.episode - ? _artworkField( - 'posters', - t.metadataEdit.poster, - t.metadataEdit.selectPoster, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.thumb, - ) - : _artworkField('posters', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3), - ]; - if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) { - fields.add( - _artworkField( - 'arts', - t.metadataEdit.background, - t.metadataEdit.selectBackground, - 80, - 45, - 2, - 16 / 9, - imageType: ImageType.art, - ), - ); - } - if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.collection) { - fields.add( - _artworkField( - 'clearLogos', - t.metadataEdit.logo, - t.metadataEdit.selectLogo, - 80, - 32, - 2, - 2.5, - fit: MetadataArtworkFit.contain, - imageType: ImageType.logo, - ), - ); - fields.add( - _artworkField( - 'squareArts', - t.metadataEdit.squareArt, - t.metadataEdit.selectSquareArt, - 50, - 50, - 3, - 1, - imageType: ImageType.avatar, - ), - ); - } - return fields; - } - - MetadataEditField _artworkField( - String key, - String label, - String title, - double width, - double height, - int columns, - double aspectRatio, { - MetadataArtworkFit fit = MetadataArtworkFit.cover, - ImageType imageType = ImageType.poster, - }) { - return MetadataEditField( - id: 'artwork:$key', - label: label, - type: MetadataEditFieldType.artwork, - saveMode: MetadataEditSaveMode.immediate, - artwork: MetadataArtworkConfig( - key: key, - selectTitle: title, - previewWidth: width, - previewHeight: height, - gridColumns: columns, - gridAspectRatio: aspectRatio, - fit: fit, - imageType: imageType, - ), - ); - } + List _artworkFields(MediaKind kind) => metadataArtworkFields( + kind, + posterKey: 'posters', + backdropKey: 'arts', + logoKey: 'clearLogos', + squareKey: 'squareArts', + logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection}, + ); List _advancedFields(MediaKind kind) { final fields = []; diff --git a/lib/mixins/debounced_media_search.dart b/lib/mixins/debounced_media_search.dart index f09ed606..ba6590b5 100644 --- a/lib/mixins/debounced_media_search.dart +++ b/lib/mixins/debounced_media_search.dart @@ -171,6 +171,22 @@ mixin DebouncedMediaSearch on State { } } + /// The results list both screens render: padded, without keep-alives or + /// semantic indexes, one child per entry of [searchResults]. + Widget buildResultsSliver(NullableIndexedWidgetBuilder itemBuilder) { + return SliverPadding( + padding: const EdgeInsets.all(16), + sliver: SliverList( + delegate: SliverChildBuilderDelegate( + itemBuilder, + childCount: searchResults.length, + addAutomaticKeepAlives: false, + addSemanticIndexes: false, + ), + ), + ); + } + /// OSK "Search" / hardware Enter on TV: jump to results, or force the /// pending search to run now. void handleSearchSubmit() { diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart index 81f820cf..cb15c6b7 100644 --- a/lib/mixins/deletion_aware.dart +++ b/lib/mixins/deletion_aware.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import '../utils/deletion_notifier.dart'; import 'event_aware.dart'; +import 'watch_state_aware.dart'; /// Mixin for screens that need to react to deletion events. /// @@ -74,3 +75,21 @@ mixin DeletionAware on State { super.dispose(); } } + +/// Points [DeletionAware]'s filters at the [WatchStateAware] ones. +/// +/// The usual case: a screen shows the same rows for both event families, so a +/// deleted show and a watched show affect exactly the same items. Mix this in +/// after both aware mixins instead of re-typing the three getters. A screen +/// that genuinely needs a different scope overrides the getter it cares about +/// (or skips this mixin entirely). +mixin DeletionMirrorsWatchState on WatchStateAware, DeletionAware { + @override + String? get deletionServerId => watchStateServerId; + + @override + Set? get deletionGlobalKeys => watchedGlobalKeys; + + @override + Set? get deletionIds => watchedIds; +} diff --git a/lib/mixins/paginated_item_loader.dart b/lib/mixins/paginated_item_loader.dart index f13f4272..1d729df6 100644 --- a/lib/mixins/paginated_item_loader.dart +++ b/lib/mixins/paginated_item_loader.dart @@ -104,43 +104,6 @@ mixin PaginatedItemLoader on State { return (page: result, applied: true); } - /// Shared initial-load transaction for paginated consumers. - /// - /// Owns reset, stale-result rejection, mounted checks, and error-state - /// application. Callers supply only their view fields, logging, and - /// post-success behavior. - Future loadInitialPaginatedItems({ - required int pageSize, - required VoidCallback resetViewState, - required void Function(List items) applyLoadedItems, - required void Function(Object error, StackTrace stackTrace) applyError, - void Function(int loadedCount, int totalCount)? onLoaded, - void Function(Object error, StackTrace stackTrace)? onError, - }) async { - setState(() { - resetViewState(); - resetPaginationState(); - }); - - try { - final initialPage = await loadInitialPageWithStatus(pageSize); - if (!initialPage.applied || !mounted) return false; - - setState(() { - applyLoadedItems(loadedItems.values.toList()); - }); - onLoaded?.call(loadedItems.length, totalSize); - return true; - } catch (error, stackTrace) { - onError?.call(error, stackTrace); - if (!mounted) return false; - setState(() { - applyError(error, stackTrace); - }); - return false; - } - } - /// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount) /// with [buffer] extra indices on each side. Serialized — only one /// range-fetch runs at a time — and re-checks after each success so a diff --git a/lib/mixins/standard_paginated_view.dart b/lib/mixins/standard_paginated_view.dart new file mode 100644 index 00000000..cbae10a4 --- /dev/null +++ b/lib/mixins/standard_paginated_view.dart @@ -0,0 +1,74 @@ +import 'package:flutter/widgets.dart'; + +import '../media/media_item.dart'; +import 'item_updatable.dart'; +import 'paginated_item_loader.dart'; + +/// Standard view-state wiring for screens whose body is a single paginated +/// list. +/// +/// [PaginatedItemLoader] owns the sparse `loadedItems` map; the hosts +/// (`BaseMediaListDetailScreen`, `BaseLibraryTabState`) additionally expose +/// `items` / `isLoading` / `errorMessage` to drive the loading, empty and +/// error chrome. This mixin owns the transitions between the two, so a +/// screen's `loadItems` supplies only the page size, the error text, and an +/// optional post-load hook. +mixin StandardPaginatedView on PaginatedItemLoader { + set items(List value); + set isLoading(bool value); + set errorMessage(String? value); + + /// Initial-load transaction: clears the view state, fetches the first page, + /// then publishes either the loaded items or [errorMessageFor]'s text. + /// + /// Stale results — a newer load started, or the screen was disposed — are + /// dropped without touching state. [errorMessageFor] runs even when + /// unmounted, so screens can log from it; [onLoaded] runs only after a + /// successful publish. + Future loadStandardPaginatedItems({ + required int pageSize, + required String Function(Object error, StackTrace stackTrace) errorMessageFor, + void Function(int loadedCount, int totalCount)? onLoaded, + }) async { + setState(() { + isLoading = true; + errorMessage = null; + items = []; + resetPaginationState(); + }); + + try { + final initialPage = await loadInitialPageWithStatus(pageSize); + if (!initialPage.applied || !mounted) return; + + setState(() { + items = loadedItems.values.toList(); + isLoading = false; + }); + onLoaded?.call(loadedItems.length, totalSize); + } catch (error, stackTrace) { + final message = errorMessageFor(error, stackTrace); + if (!mounted) return; + setState(() { + errorMessage = message; + isLoading = false; + }); + } + } +} + +/// [ItemUpdatable.updateItemInLists] for screens whose visible list is the +/// sparse `loadedItems` map rather than a flat `items` list — searching the +/// map is what keeps an item refreshed at a scrolled-in position, past the +/// first page. +mixin PaginatedItemUpdatable on PaginatedItemLoader, ItemUpdatable { + @override + void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { + for (final entry in loadedItems.entries) { + if (entry.value.globalKey == sourceGlobalKey) { + loadedItems[entry.key] = updatedItem; + return; + } + } + } +} diff --git a/lib/models/download_models.dart b/lib/models/download_models.dart index 50d8ce6c..ee591442 100644 --- a/lib/models/download_models.dart +++ b/lib/models/download_models.dart @@ -34,8 +34,6 @@ sealed class DownloadProgress with _$DownloadProgress { double get progressPercent => progress / 100.0; String get speedFormatted => ByteFormatter.formatSpeed(speed); - String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes); - String get totalFormatted => ByteFormatter.formatBytes(totalBytes); bool get hasArtworkPaths => thumbPath != null; } diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index 3ce2cb36..80cdaa73 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart'; import '../i18n/strings.g.dart'; import '../utils/json_utils.dart'; -import 'mixins/multi_server_fields.dart'; part 'livetv_channel.g.dart'; @@ -49,7 +48,7 @@ List filterLiveTvChannelsForFavorites({ } @JsonSerializable(createToJson: false) -class LiveTvChannel with MultiServerFields { +class LiveTvChannel { @JsonKey(readValue: _readChannelKey) final String key; @JsonKey(readValue: _readChannelIdentifier) @@ -68,10 +67,8 @@ class LiveTvChannel with MultiServerFields { @JsonKey(fromJson: flexibleBool) final bool? drm; - @override @JsonKey(includeFromJson: false, includeToJson: false) final String? serverId; - @override @JsonKey(includeFromJson: false, includeToJson: false) final String? serverName; @JsonKey(includeFromJson: false, includeToJson: false) diff --git a/lib/models/livetv_lineup.dart b/lib/models/livetv_lineup.dart deleted file mode 100644 index 524bf694..00000000 --- a/lib/models/livetv_lineup.dart +++ /dev/null @@ -1,92 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; -import 'livetv_channel.dart'; - -part 'livetv_lineup.g.dart'; - -List _parseChannels(Object? raw) => parseFlexibleJsonList(raw, LiveTvChannel.fromJson); - -@JsonSerializable(createToJson: false) -class LiveTvCountry { - final String? key; - final String? type; - @JsonKey(defaultValue: '') - final String title; - @JsonKey(defaultValue: '') - final String code; - final String? language; - final String? languageTitle; - final String? example; - @JsonKey(fromJson: flexibleInt) - final int? flavor; - - const LiveTvCountry({ - this.key, - this.type, - required this.title, - required this.code, - this.language, - this.languageTitle, - this.example, - this.flavor, - }); - - factory LiveTvCountry.fromJson(Map json) => _$LiveTvCountryFromJson(json); -} - -@JsonSerializable(createToJson: false) -class LiveTvLanguage { - @JsonKey(defaultValue: '') - final String code; - @JsonKey(defaultValue: '') - final String title; - - const LiveTvLanguage({required this.code, required this.title}); - - factory LiveTvLanguage.fromJson(Map json) => _$LiveTvLanguageFromJson(json); -} - -@JsonSerializable(createToJson: false) -class LiveTvRegion { - @JsonKey(defaultValue: '') - final String key; - final String? type; - @JsonKey(defaultValue: '') - final String title; - - const LiveTvRegion({required this.key, this.type, required this.title}); - - factory LiveTvRegion.fromJson(Map json) => _$LiveTvRegionFromJson(json); -} - -@JsonSerializable(createToJson: false) -class LiveTvLineup { - @JsonKey(defaultValue: '') - final String uuid; - final String? type; - final String? title; - @JsonKey(fromJson: flexibleInt) - final int? lineupType; - final String? location; - @JsonKey(name: 'Channel', fromJson: _parseChannels) - final List channels; - - const LiveTvLineup({ - required this.uuid, - this.type, - this.title, - this.lineupType, - this.location, - this.channels = const [], - }); - - factory LiveTvLineup.fromJson(Map json) => _$LiveTvLineupFromJson(json); -} - -class LiveTvLineupResult { - final String? lineupGroupUuid; - final List lineups; - - const LiveTvLineupResult({this.lineupGroupUuid, required this.lineups}); -} diff --git a/lib/models/livetv_lineup.g.dart b/lib/models/livetv_lineup.g.dart deleted file mode 100644 index f2c3920b..00000000 --- a/lib/models/livetv_lineup.g.dart +++ /dev/null @@ -1,42 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'livetv_lineup.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LiveTvCountry _$LiveTvCountryFromJson(Map json) => - LiveTvCountry( - key: json['key'] as String?, - type: json['type'] as String?, - title: json['title'] as String? ?? '', - code: json['code'] as String? ?? '', - language: json['language'] as String?, - languageTitle: json['languageTitle'] as String?, - example: json['example'] as String?, - flavor: flexibleInt(json['flavor']), - ); - -LiveTvLanguage _$LiveTvLanguageFromJson(Map json) => - LiveTvLanguage( - code: json['code'] as String? ?? '', - title: json['title'] as String? ?? '', - ); - -LiveTvRegion _$LiveTvRegionFromJson(Map json) => LiveTvRegion( - key: json['key'] as String? ?? '', - type: json['type'] as String?, - title: json['title'] as String? ?? '', -); - -LiveTvLineup _$LiveTvLineupFromJson(Map json) => LiveTvLineup( - uuid: json['uuid'] as String? ?? '', - type: json['type'] as String?, - title: json['title'] as String?, - lineupType: flexibleInt(json['lineupType']), - location: json['location'] as String?, - channels: json['Channel'] == null - ? const [] - : _parseChannels(json['Channel']), -); diff --git a/lib/models/livetv_server_status.dart b/lib/models/livetv_server_status.dart deleted file mode 100644 index eea63382..00000000 --- a/lib/models/livetv_server_status.dart +++ /dev/null @@ -1,26 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; - -part 'livetv_server_status.g.dart'; - -@JsonSerializable(createToJson: false) -class LiveTvServerStatus { - @JsonKey(name: 'livetv', fromJson: flexibleInt) - final int? liveTvCount; - @JsonKey(fromJson: flexibleBoolNullable) - final bool? allowTuners; - final String? ownerFeatures; - - const LiveTvServerStatus({this.liveTvCount, this.allowTuners, this.ownerFeatures}); - - factory LiveTvServerStatus.fromJson(Map json) => _$LiveTvServerStatusFromJson(json); - - Set get ownerFeatureSet => - (ownerFeatures ?? '').split(',').map((feature) => feature.trim()).where((feature) => feature.isNotEmpty).toSet(); - - bool get hasConfiguredDvr => (liveTvCount ?? 0) > 0; - bool get supportsTuners => allowTuners != false; - bool get hasDvrFeature => ownerFeatureSet.contains('dvr'); - bool get hasLiveTvFeature => ownerFeatureSet.contains('livetv'); -} diff --git a/lib/models/livetv_server_status.g.dart b/lib/models/livetv_server_status.g.dart deleted file mode 100644 index bcdd4431..00000000 --- a/lib/models/livetv_server_status.g.dart +++ /dev/null @@ -1,14 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'livetv_server_status.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LiveTvServerStatus _$LiveTvServerStatusFromJson(Map json) => - LiveTvServerStatus( - liveTvCount: flexibleInt(json['livetv']), - allowTuners: flexibleBoolNullable(json['allowTuners']), - ownerFeatures: json['ownerFeatures'] as String?, - ); diff --git a/lib/models/livetv_session.dart b/lib/models/livetv_session.dart deleted file mode 100644 index 05fe69d2..00000000 --- a/lib/models/livetv_session.dart +++ /dev/null @@ -1,66 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; -import 'livetv_capture_buffer.dart'; -import 'livetv_program.dart'; -import 'media_grab_operation.dart'; - -part 'livetv_session.g.dart'; - -LiveTvProgram? _programFromRaw(Object? raw) => parseFlexibleJsonObject(raw, LiveTvProgram.fromJson); - -MediaGrabOperation? _grabOperationFromRaw(Object? raw) => parseFlexibleJsonObject(raw, MediaGrabOperation.fromJson); - -CaptureBuffer? _captureBufferFromRaw(Object? raw) { - final map = firstFlexibleMap(raw); - if (map == null) return null; - final session = firstFlexibleMap(map['TranscodeSession']) ?? map; - return CaptureBuffer.fromTranscodeSession(session); -} - -@JsonSerializable(createToJson: false) -class LiveTvSession { - @JsonKey(readValue: readStringField, defaultValue: '') - final String sessionID; - @JsonKey(readValue: readStringField) - final String? dvrID; - final String? channelIdentifier; - final String? channelCallSign; - final String? channelTitle; - final String? activityUUID; - @JsonKey(fromJson: flexibleInt) - final int? currentPosition; - @JsonKey(fromJson: flexibleInt) - final int? nextPosition; - @JsonKey(fromJson: flexibleInt) - final int? startedAt; - @JsonKey(name: 'CaptureBuffer', fromJson: _captureBufferFromRaw) - final CaptureBuffer? captureBuffer; - @JsonKey(name: 'MediaGrabOperation', fromJson: _grabOperationFromRaw) - final MediaGrabOperation? grabOperation; - @JsonKey(name: 'Timeline', fromJson: firstFlexibleMap) - final Map? timeline; - @JsonKey(name: 'AiringMetadataItem', fromJson: _programFromRaw) - final LiveTvProgram? airingMetadataItem; - @JsonKey(name: 'UpNextMetadataItem', fromJson: _programFromRaw) - final LiveTvProgram? upNextMetadataItem; - - const LiveTvSession({ - required this.sessionID, - this.dvrID, - this.channelIdentifier, - this.channelCallSign, - this.channelTitle, - this.activityUUID, - this.currentPosition, - this.nextPosition, - this.startedAt, - this.captureBuffer, - this.grabOperation, - this.timeline, - this.airingMetadataItem, - this.upNextMetadataItem, - }); - - factory LiveTvSession.fromJson(Map json) => _$LiveTvSessionFromJson(json); -} diff --git a/lib/models/livetv_session.g.dart b/lib/models/livetv_session.g.dart deleted file mode 100644 index 0e1f1571..00000000 --- a/lib/models/livetv_session.g.dart +++ /dev/null @@ -1,25 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'livetv_session.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -LiveTvSession _$LiveTvSessionFromJson(Map json) => - LiveTvSession( - sessionID: readStringField(json, 'sessionID') as String? ?? '', - dvrID: readStringField(json, 'dvrID') as String?, - channelIdentifier: json['channelIdentifier'] as String?, - channelCallSign: json['channelCallSign'] as String?, - channelTitle: json['channelTitle'] as String?, - activityUUID: json['activityUUID'] as String?, - currentPosition: flexibleInt(json['currentPosition']), - nextPosition: flexibleInt(json['nextPosition']), - startedAt: flexibleInt(json['startedAt']), - captureBuffer: _captureBufferFromRaw(json['CaptureBuffer']), - grabOperation: _grabOperationFromRaw(json['MediaGrabOperation']), - timeline: firstFlexibleMap(json['Timeline']), - airingMetadataItem: _programFromRaw(json['AiringMetadataItem']), - upNextMetadataItem: _programFromRaw(json['UpNextMetadataItem']), - ); diff --git a/lib/models/media_grabber_device.dart b/lib/models/media_grabber_device.dart deleted file mode 100644 index 5ab834e6..00000000 --- a/lib/models/media_grabber_device.dart +++ /dev/null @@ -1,103 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; -import 'livetv_dvr.dart'; -import 'media_subscription.dart'; - -part 'media_grabber_device.g.dart'; - -List _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson); - -List _parseSettings(Object? raw) => parseFlexibleJsonList(raw, SubscriptionSetting.fromJson); - -@JsonSerializable(createToJson: false) -class MediaGrabber { - @JsonKey(defaultValue: '') - final String identifier; - final String? protocol; - final String? title; - - const MediaGrabber({required this.identifier, this.protocol, this.title}); - - factory MediaGrabber.fromJson(Map json) => _$MediaGrabberFromJson(json); -} - -/// Tuner/grabber device known to Plex Media Server. -@JsonSerializable(createToJson: false) -class MediaGrabberDevice { - @JsonKey(defaultValue: '') - final String key; - @JsonKey(defaultValue: '') - final String uuid; - final String? uri; - final String? protocol; - final String? title; - final String? make; - final String? model; - final String? modelNumber; - final String? firmware; - @JsonKey(fromJson: flexibleInt) - final int? tuners; - final String? sources; - @JsonKey(fromJson: flexibleInt) - final int? status; - @JsonKey(fromJson: flexibleInt) - final int? state; - @JsonKey(fromJson: flexibleInt) - final int? lastSeenAt; - @JsonKey(name: 'ChannelMapping', fromJson: _parseChannelMappings) - final List channelMappings; - @JsonKey(name: 'Setting', fromJson: _parseSettings) - final List settings; - - const MediaGrabberDevice({ - required this.key, - required this.uuid, - this.uri, - this.protocol, - this.title, - this.make, - this.model, - this.modelNumber, - this.firmware, - this.tuners, - this.sources, - this.status, - this.state, - this.lastSeenAt, - this.channelMappings = const [], - this.settings = const [], - }); - - factory MediaGrabberDevice.fromJson(Map json) => _$MediaGrabberDeviceFromJson(json); -} - -@JsonSerializable(createToJson: false) -class MediaGrabberDeviceChannel { - @JsonKey(readValue: readStringField, defaultValue: '') - final String identifier; - @JsonKey(readValue: readStringField) - final String? key; - @JsonKey(readValue: readStringField) - final String? name; - @JsonKey(fromJson: flexibleBool) - final bool drm; - @JsonKey(fromJson: flexibleBool) - final bool hd; - - const MediaGrabberDeviceChannel({required this.identifier, this.key, this.name, this.drm = false, this.hd = false}); - - factory MediaGrabberDeviceChannel.fromJson(Map json) => _$MediaGrabberDeviceChannelFromJson(json); -} - -class MediaGrabberChannelMapRequest { - final List channelsEnabled; - final Map channelMapping; - final Map channelMappingByKey; - - const MediaGrabberChannelMapRequest({ - this.channelsEnabled = const [], - this.channelMapping = const {}, - this.channelMappingByKey = const {}, - }); -} diff --git a/lib/models/media_grabber_device.g.dart b/lib/models/media_grabber_device.g.dart deleted file mode 100644 index fff4efb0..00000000 --- a/lib/models/media_grabber_device.g.dart +++ /dev/null @@ -1,47 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'media_grabber_device.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MediaGrabber _$MediaGrabberFromJson(Map json) => MediaGrabber( - identifier: json['identifier'] as String? ?? '', - protocol: json['protocol'] as String?, - title: json['title'] as String?, -); - -MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map json) => - MediaGrabberDevice( - key: json['key'] as String? ?? '', - uuid: json['uuid'] as String? ?? '', - uri: json['uri'] as String?, - protocol: json['protocol'] as String?, - title: json['title'] as String?, - make: json['make'] as String?, - model: json['model'] as String?, - modelNumber: json['modelNumber'] as String?, - firmware: json['firmware'] as String?, - tuners: flexibleInt(json['tuners']), - sources: json['sources'] as String?, - status: flexibleInt(json['status']), - state: flexibleInt(json['state']), - lastSeenAt: flexibleInt(json['lastSeenAt']), - channelMappings: json['ChannelMapping'] == null - ? const [] - : _parseChannelMappings(json['ChannelMapping']), - settings: json['Setting'] == null - ? const [] - : _parseSettings(json['Setting']), - ); - -MediaGrabberDeviceChannel _$MediaGrabberDeviceChannelFromJson( - Map json, -) => MediaGrabberDeviceChannel( - identifier: readStringField(json, 'identifier') as String? ?? '', - key: readStringField(json, 'key') as String?, - name: readStringField(json, 'name') as String?, - drm: json['drm'] == null ? false : flexibleBool(json['drm']), - hd: json['hd'] == null ? false : flexibleBool(json['hd']), -); diff --git a/lib/models/media_provider_info.dart b/lib/models/media_provider_info.dart deleted file mode 100644 index 5e68e4db..00000000 --- a/lib/models/media_provider_info.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; - -part 'media_provider_info.g.dart'; - -List _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson); - -List> _parseRawMaps(Object? raw) => flexibleMapList(raw); - -@JsonSerializable(createToJson: false) -class MediaProviderInfo { - @JsonKey(fromJson: flexibleInt) - final int? id; - @JsonKey(fromJson: flexibleInt) - final int? parentID; - @JsonKey(defaultValue: '') - final String identifier; - final String? providerIdentifier; - final String? title; - final String? types; - final String? protocols; - final String? epgSource; - final String? friendlyName; - @JsonKey(name: 'Feature', fromJson: _parseFeatures) - final List features; - - const MediaProviderInfo({ - this.id, - this.parentID, - required this.identifier, - this.providerIdentifier, - this.title, - this.types, - this.protocols, - this.epgSource, - this.friendlyName, - this.features = const [], - }); - - factory MediaProviderInfo.fromJson(Map json) => _$MediaProviderInfoFromJson(json); -} - -@JsonSerializable(createToJson: false) -class MediaProviderFeature { - final String? key; - @JsonKey(defaultValue: '') - final String type; - final String? flavor; - final String? scrobbleKey; - final String? unscrobbleKey; - @JsonKey(name: 'Directory', fromJson: _parseRawMaps) - final List> directories; - @JsonKey(name: 'Action', fromJson: _parseRawMaps) - final List> actions; - @JsonKey(name: 'Pivot', fromJson: _parseRawMaps) - final List> pivots; - - const MediaProviderFeature({ - this.key, - required this.type, - this.flavor, - this.scrobbleKey, - this.unscrobbleKey, - this.directories = const [], - this.actions = const [], - this.pivots = const [], - }); - - factory MediaProviderFeature.fromJson(Map json) => _$MediaProviderFeatureFromJson(json); -} diff --git a/lib/models/media_provider_info.g.dart b/lib/models/media_provider_info.g.dart deleted file mode 100644 index de23170f..00000000 --- a/lib/models/media_provider_info.g.dart +++ /dev/null @@ -1,38 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'media_provider_info.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MediaProviderInfo _$MediaProviderInfoFromJson(Map json) => - MediaProviderInfo( - id: flexibleInt(json['id']), - parentID: flexibleInt(json['parentID']), - identifier: json['identifier'] as String? ?? '', - providerIdentifier: json['providerIdentifier'] as String?, - title: json['title'] as String?, - types: json['types'] as String?, - protocols: json['protocols'] as String?, - epgSource: json['epgSource'] as String?, - friendlyName: json['friendlyName'] as String?, - features: json['Feature'] == null - ? const [] - : _parseFeatures(json['Feature']), - ); - -MediaProviderFeature _$MediaProviderFeatureFromJson( - Map json, -) => MediaProviderFeature( - key: json['key'] as String?, - type: json['type'] as String? ?? '', - flavor: json['flavor'] as String?, - scrobbleKey: json['scrobbleKey'] as String?, - unscrobbleKey: json['unscrobbleKey'] as String?, - directories: json['Directory'] == null - ? const [] - : _parseRawMaps(json['Directory']), - actions: json['Action'] == null ? const [] : _parseRawMaps(json['Action']), - pivots: json['Pivot'] == null ? const [] : _parseRawMaps(json['Pivot']), -); diff --git a/lib/models/mixins/multi_server_fields.dart b/lib/models/mixins/multi_server_fields.dart deleted file mode 100644 index 1ba3e4a8..00000000 --- a/lib/models/mixins/multi_server_fields.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -/// Mixin that provides multi-server support fields for models. -/// -/// This mixin adds serverId and serverName fields that are excluded from -/// JSON serialization but can be used to track which server an item belongs to. -mixin MultiServerFields { - /// Server machine identifier (not from API) - @JsonKey(includeFromJson: false, includeToJson: false) - String? get serverId; - - /// Server display name (not from API) - @JsonKey(includeFromJson: false, includeToJson: false) - String? get serverName; -} diff --git a/lib/models/plex/plex_home.dart b/lib/models/plex/plex_home.dart index 69924b7b..21f54241 100644 --- a/lib/models/plex/plex_home.dart +++ b/lib/models/plex/plex_home.dart @@ -37,18 +37,4 @@ class PlexHome { Map toJson() => _$PlexHomeToJson(this); PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull; - - List get managedUsers => users.where((user) => !user.admin).toList(); - - List get restrictedUsers => users.where((user) => user.restricted).toList(); - - PlexHomeUser? getUserByUUID(String uuid) { - try { - return users.firstWhere((user) => user.uuid == uuid); - } catch (e) { - return null; - } - } - - bool get hasMultipleUsers => users.length > 1; } diff --git a/lib/models/plex/plex_switch_response.dart b/lib/models/plex/plex_switch_response.dart new file mode 100644 index 00000000..05c4e263 --- /dev/null +++ b/lib/models/plex/plex_switch_response.dart @@ -0,0 +1,13 @@ +/// INVARIANT (#1488): a successful token mint must never be lost to parsing +/// of decorative fields. `authToken` is the only field any caller consumes +/// (see plex_home_switch.dart), so nothing else on the `/switch` body is +/// read. Plex has changed field shapes on this endpoint before (July 2026: +/// profile language lists became CSV strings), and each drift used to brick +/// token minting outright. +String parsePlexSwitchAuthToken(Map json) { + final authToken = json['authToken']; + if (authToken is! String || authToken.isEmpty) { + throw const FormatException('Plex /switch response has no usable authToken'); + } + return authToken; +} diff --git a/lib/models/plex/plex_user_profile.dart b/lib/models/plex/plex_user_profile.dart index 9e19b3da..88773fa7 100644 --- a/lib/models/plex/plex_user_profile.dart +++ b/lib/models/plex/plex_user_profile.dart @@ -10,8 +10,7 @@ part 'plex_user_profile.g.dart'; /// /// Every field parses tolerantly: the account API drifts (~July 2026 the /// language-list fields switched from arrays to CSV strings, #1488), and a -/// profile blob must never fail to parse — token minting embeds it (see -/// UserSwitchResponse.fromJson). +/// single drifted field must never sink the whole profile. @JsonSerializable() class PlexUserProfile implements MediaServerUserProfile { @JsonKey(fromJson: _boolOrTrue) @@ -62,19 +61,6 @@ class PlexUserProfile implements MediaServerUserProfile { this.mediaReviewsLanguages, }); - /// Neutral fallback matching the generated defaults — used when the account - /// API returns a profile blob that cannot be parsed at all (schema drift - /// must never break token minting, see UserSwitchResponse.fromJson). - factory PlexUserProfile.defaults() => PlexUserProfile( - autoSelectAudio: true, - defaultAudioAccessibility: 0, - autoSelectSubtitle: 0, - defaultSubtitleAccessibility: 0, - defaultSubtitleForced: 1, - watchedIndicator: 1, - mediaReviewsVisibility: 0, - ); - factory PlexUserProfile.fromJson(Map json) { final envelope = json['profile']; final profile = envelope is Map ? envelope : json; diff --git a/lib/models/plex/plex_video_playback_data.dart b/lib/models/plex/plex_video_playback_data.dart index 23febc2d..587fb1f3 100644 --- a/lib/models/plex/plex_video_playback_data.dart +++ b/lib/models/plex/plex_video_playback_data.dart @@ -26,6 +26,4 @@ class PlexVideoPlaybackData { }); bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty; - - bool get hasMediaInfo => mediaInfo != null; } diff --git a/lib/models/seerr/seerr_details.dart b/lib/models/seerr/seerr_details.dart index 19a0c504..aa25ef1c 100644 --- a/lib/models/seerr/seerr_details.dart +++ b/lib/models/seerr/seerr_details.dart @@ -4,111 +4,18 @@ import 'seerr_media.dart'; part 'seerr_details.g.dart'; -/// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog -/// surfaces need (credits, external ids, availability, air status). +/// Full detail from `GET /movie/{tmdbId}` and `GET /tv/{tmdbId}` — the subset +/// the catalog surfaces need (credits, availability, seasons). `seasons` is +/// absent on movies. @JsonSerializable(createToJson: false) -class SeerrMovieDetails { - final int id; - final String? title; - final String? overview; - final String? posterPath; - final String? backdropPath; - final String? releaseDate; - - /// Minutes. - final int? runtime; - - /// `Released` / `In Production` / `Post Production` / `Planned` / - /// `Canceled` / `Rumored`. - final String? status; - final double? voteAverage; - final int? voteCount; - final List? genres; - final SeerrCredits? credits; - final SeerrExternalIds? externalIds; - final SeerrMediaInfo? mediaInfo; - - const SeerrMovieDetails({ - required this.id, - this.title, - this.overview, - this.posterPath, - this.backdropPath, - this.releaseDate, - this.runtime, - this.status, - this.voteAverage, - this.voteCount, - this.genres, - this.credits, - this.externalIds, - this.mediaInfo, - }); - - factory SeerrMovieDetails.fromJson(Map json) => _$SeerrMovieDetailsFromJson(json); -} - -/// Full TV detail from `GET /tv/{tmdbId}`. -@JsonSerializable(createToJson: false) -class SeerrTvDetails { - final int id; - final String? name; - final String? overview; - final String? posterPath; - final String? backdropPath; - final String? firstAirDate; - final List? episodeRunTime; - - /// `Returning Series` / `Ended` / `Canceled` / `In Production` / - /// `Planned` / `Pilot`. - final String? status; - final double? voteAverage; - final int? voteCount; - final List? genres; - final List? networks; - final int? numberOfEpisodes; - final int? numberOfSeasons; +class SeerrDetails { final List? seasons; final SeerrCredits? credits; - final SeerrExternalIds? externalIds; final SeerrMediaInfo? mediaInfo; - const SeerrTvDetails({ - required this.id, - this.name, - this.overview, - this.posterPath, - this.backdropPath, - this.firstAirDate, - this.episodeRunTime, - this.status, - this.voteAverage, - this.voteCount, - this.genres, - this.networks, - this.numberOfEpisodes, - this.numberOfSeasons, - this.seasons, - this.credits, - this.externalIds, - this.mediaInfo, - }); + const SeerrDetails({this.seasons, this.credits, this.mediaInfo}); - factory SeerrTvDetails.fromJson(Map json) => _$SeerrTvDetailsFromJson(json); -} - -@JsonSerializable(createToJson: false) -class SeerrGenre { - final String? name; - const SeerrGenre({this.name}); - factory SeerrGenre.fromJson(Map json) => _$SeerrGenreFromJson(json); -} - -@JsonSerializable(createToJson: false) -class SeerrNetwork { - final String? name; - const SeerrNetwork({this.name}); - factory SeerrNetwork.fromJson(Map json) => _$SeerrNetworkFromJson(json); + factory SeerrDetails.fromJson(Map json) => _$SeerrDetailsFromJson(json); } /// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials. @@ -141,13 +48,3 @@ class SeerrCastMember { factory SeerrCastMember.fromJson(Map json) => _$SeerrCastMemberFromJson(json); } - -@JsonSerializable(createToJson: false) -class SeerrExternalIds { - final String? imdbId; - final int? tvdbId; - - const SeerrExternalIds({this.imdbId, this.tvdbId}); - - factory SeerrExternalIds.fromJson(Map json) => _$SeerrExternalIdsFromJson(json); -} diff --git a/lib/models/seerr/seerr_details.g.dart b/lib/models/seerr/seerr_details.g.dart index 133bb02b..487de020 100644 --- a/lib/models/seerr/seerr_details.g.dart +++ b/lib/models/seerr/seerr_details.g.dart @@ -6,77 +6,17 @@ part of 'seerr_details.dart'; // JsonSerializableGenerator // ************************************************************************** -SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map json) => - SeerrMovieDetails( - id: (json['id'] as num).toInt(), - title: json['title'] as String?, - overview: json['overview'] as String?, - posterPath: json['posterPath'] as String?, - backdropPath: json['backdropPath'] as String?, - releaseDate: json['releaseDate'] as String?, - runtime: (json['runtime'] as num?)?.toInt(), - status: json['status'] as String?, - voteAverage: (json['voteAverage'] as num?)?.toDouble(), - voteCount: (json['voteCount'] as num?)?.toInt(), - genres: (json['genres'] as List?) - ?.map((e) => SeerrGenre.fromJson(e as Map)) - .toList(), - credits: json['credits'] == null - ? null - : SeerrCredits.fromJson(json['credits'] as Map), - externalIds: json['externalIds'] == null - ? null - : SeerrExternalIds.fromJson( - json['externalIds'] as Map, - ), - mediaInfo: json['mediaInfo'] == null - ? null - : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), - ); - -SeerrTvDetails _$SeerrTvDetailsFromJson(Map json) => - SeerrTvDetails( - id: (json['id'] as num).toInt(), - name: json['name'] as String?, - overview: json['overview'] as String?, - posterPath: json['posterPath'] as String?, - backdropPath: json['backdropPath'] as String?, - firstAirDate: json['firstAirDate'] as String?, - episodeRunTime: (json['episodeRunTime'] as List?) - ?.map((e) => (e as num).toInt()) - .toList(), - status: json['status'] as String?, - voteAverage: (json['voteAverage'] as num?)?.toDouble(), - voteCount: (json['voteCount'] as num?)?.toInt(), - genres: (json['genres'] as List?) - ?.map((e) => SeerrGenre.fromJson(e as Map)) - .toList(), - networks: (json['networks'] as List?) - ?.map((e) => SeerrNetwork.fromJson(e as Map)) - .toList(), - numberOfEpisodes: (json['numberOfEpisodes'] as num?)?.toInt(), - numberOfSeasons: (json['numberOfSeasons'] as num?)?.toInt(), - seasons: (json['seasons'] as List?) - ?.map((e) => SeerrSeason.fromJson(e as Map)) - .toList(), - credits: json['credits'] == null - ? null - : SeerrCredits.fromJson(json['credits'] as Map), - externalIds: json['externalIds'] == null - ? null - : SeerrExternalIds.fromJson( - json['externalIds'] as Map, - ), - mediaInfo: json['mediaInfo'] == null - ? null - : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), - ); - -SeerrGenre _$SeerrGenreFromJson(Map json) => - SeerrGenre(name: json['name'] as String?); - -SeerrNetwork _$SeerrNetworkFromJson(Map json) => - SeerrNetwork(name: json['name'] as String?); +SeerrDetails _$SeerrDetailsFromJson(Map json) => SeerrDetails( + seasons: (json['seasons'] as List?) + ?.map((e) => SeerrSeason.fromJson(e as Map)) + .toList(), + credits: json['credits'] == null + ? null + : SeerrCredits.fromJson(json['credits'] as Map), + mediaInfo: json['mediaInfo'] == null + ? null + : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map), +); SeerrSeason _$SeerrSeasonFromJson(Map json) => SeerrSeason( seasonNumber: (json['seasonNumber'] as num).toInt(), @@ -97,9 +37,3 @@ SeerrCastMember _$SeerrCastMemberFromJson(Map json) => character: json['character'] as String?, profilePath: json['profilePath'] as String?, ); - -SeerrExternalIds _$SeerrExternalIdsFromJson(Map json) => - SeerrExternalIds( - imdbId: json['imdbId'] as String?, - tvdbId: (json['tvdbId'] as num?)?.toInt(), - ); diff --git a/lib/models/shader_preset.dart b/lib/models/shader_preset.dart index e55f9e88..14b01a1b 100644 --- a/lib/models/shader_preset.dart +++ b/lib/models/shader_preset.dart @@ -9,22 +9,35 @@ enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom } /// ArtCNN real-time model sizes. enum ArtCNNModel { /// Lightweight real-time model - c4f16, + c4f16('C4F16'), /// Higher-quality real-time model - c4f32, + c4f32('C4F32'); + + const ArtCNNModel(this.label); + + /// Display label for the model. + final String label; } /// ArtCNN luma doubler variants. enum ArtCNNVariant { /// Neutral luma doubler - neutral, + neutral('Neutral', 'neutral'), /// Denoise and soften - denoise, + denoise('Denoise', 'dn'), /// Denoise and sharpen - denoiseSharpen, + denoiseSharpen('Denoise + Sharpen', 'ds'); + + const ArtCNNVariant(this.label, this.slug); + + /// Display label for the variant. + final String label; + + /// Stable slug used in built-in preset ids and shader asset keys. + final String slug; } /// Quality tiers for Anime4K presets @@ -39,22 +52,27 @@ enum Anime4KQuality { /// Anime4K modes that define shader combinations enum Anime4KMode { /// Mode A: Clamp + Restore - modeA, + modeA('A'), /// Mode B: Clamp + Restore + Upscale + Downscale - modeB, + modeB('B'), /// Mode C: Clamp + Upscale + Downscale - modeC, + modeC('C'), /// Mode A+A: Clamp + Restore + Restore - modeAA, + modeAA('A+A'), /// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale - modeBB, + modeBB('B+B'), /// Mode C+A: Clamp + Upscale + Restore + Downscale - modeCA, + modeCA('C+A'); + + const Anime4KMode(this.label); + + /// Display label for the mode. + final String label; } @freezed @@ -120,93 +138,28 @@ class ShaderPreset { ); /// Create an ArtCNN preset with the specified model and variant - static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) { - final modelName = _getArtCNNModelName(model); - final variantName = _getArtCNNVariantName(variant); - final variantId = _getArtCNNVariantId(variant); - - return ShaderPreset( - id: 'artcnn_${model.name}_$variantId', - name: variant == ArtCNNVariant.neutral ? 'ArtCNN $modelName' : 'ArtCNN $modelName $variantName', - type: ShaderPresetType.artcnn, - artcnnConfig: ArtCNNConfig(model: model, variant: variant), - ); - } + static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) => ShaderPreset( + id: 'artcnn_${model.name}_${variant.slug}', + name: variant == ArtCNNVariant.neutral ? 'ArtCNN ${model.label}' : 'ArtCNN ${model.label} ${variant.label}', + type: ShaderPresetType.artcnn, + artcnnConfig: ArtCNNConfig(model: model, variant: variant), + ); /// Create an Anime4K preset with the specified quality and mode static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) { final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ'; - final modeName = _getModeName(mode); return ShaderPreset( id: 'anime4k_${quality.name}_${mode.name}', - name: 'Anime4K $qualityName $modeName', + name: 'Anime4K $qualityName ${mode.label}', type: ShaderPresetType.anime4k, anime4kConfig: Anime4KConfig(quality: quality, mode: mode), ); } - static String _getModeName(Anime4KMode mode) { - switch (mode) { - case Anime4KMode.modeA: - return 'A'; - case Anime4KMode.modeB: - return 'B'; - case Anime4KMode.modeC: - return 'C'; - case Anime4KMode.modeAA: - return 'A+A'; - case Anime4KMode.modeBB: - return 'B+B'; - case Anime4KMode.modeCA: - return 'C+A'; - } - } + String get modeDisplayName => anime4kConfig?.mode.label ?? ''; - static String _getArtCNNModelName(ArtCNNModel model) { - switch (model) { - case ArtCNNModel.c4f16: - return 'C4F16'; - case ArtCNNModel.c4f32: - return 'C4F32'; - } - } - - static String _getArtCNNVariantName(ArtCNNVariant variant) { - switch (variant) { - case ArtCNNVariant.neutral: - return 'Neutral'; - case ArtCNNVariant.denoise: - return 'Denoise'; - case ArtCNNVariant.denoiseSharpen: - return 'Denoise + Sharpen'; - } - } - - static String _getArtCNNVariantId(ArtCNNVariant variant) { - switch (variant) { - case ArtCNNVariant.neutral: - return 'neutral'; - case ArtCNNVariant.denoise: - return 'dn'; - case ArtCNNVariant.denoiseSharpen: - return 'ds'; - } - } - - String get modeDisplayName { - if (anime4kConfig != null) { - return _getModeName(anime4kConfig!.mode); - } - return ''; - } - - String get artcnnModelDisplayName { - if (artcnnConfig != null) { - return _getArtCNNModelName(artcnnConfig!.model); - } - return ''; - } + String get artcnnModelDisplayName => artcnnConfig?.model.label ?? ''; static final List _builtInPresets = List.unmodifiable([ none, diff --git a/lib/models/trakt/trakt_scrobble_request.dart b/lib/models/trakt/trakt_scrobble_request.dart index d15b8d8a..9be26a2d 100644 --- a/lib/models/trakt/trakt_scrobble_request.dart +++ b/lib/models/trakt/trakt_scrobble_request.dart @@ -41,11 +41,12 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest { }, }; - /// Build a `POST /sync/history` body that adds this item to history. + /// Build a `POST /sync/history[/remove]` body for this item. Both endpoints + /// take the same shape; only the removal path ignores [watchedAt]. /// /// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play /// to a specific point in time; defaults to "now" on Trakt's side. - Map toHistoryAddBody({String? watchedAt}) => switch (this) { + Map toHistoryBody({String? watchedAt}) => switch (this) { TraktScrobbleMovieRequest(:final ids) => { 'movies': [ {'watched_at': ?watchedAt, 'ids': ids.toJson()}, @@ -67,28 +68,4 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest { ], }, }; - - /// Build a `POST /sync/history/remove` body that removes this item from history. - Map toHistoryRemoveBody() => switch (this) { - TraktScrobbleMovieRequest(:final ids) => { - 'movies': [ - {'ids': ids.toJson()}, - ], - }, - TraktScrobbleEpisodeRequest(:final showIds, :final season, :final number) => { - 'shows': [ - { - 'ids': showIds.toJson(), - 'seasons': [ - { - 'number': season, - 'episodes': [ - {'number': number}, - ], - }, - ], - }, - ], - }, - }; } diff --git a/lib/models/user_switch_response.dart b/lib/models/user_switch_response.dart deleted file mode 100644 index 25f8ad83..00000000 --- a/lib/models/user_switch_response.dart +++ /dev/null @@ -1,160 +0,0 @@ -import '../utils/app_logger.dart'; -import '../utils/json_utils.dart'; -import 'plex/plex_user_profile.dart'; - -class UserSwitchResponse { - final int id; - final String uuid; - final String username; - final String title; - final String email; - final String? friendlyName; - final String? locale; - final bool confirmed; - final int joinedAt; - final bool emailOnlyAuth; - final bool hasPassword; - final bool protected; - final String thumb; - final String authToken; - final bool? mailingListActive; - final String scrobbleTypes; - final String country; - final bool restricted; - final bool? anonymous; - final bool home; - final bool guest; - final int homeSize; - final bool homeAdmin; - final int maxHomeSize; - final PlexUserProfile profile; - final bool twoFactorEnabled; - final bool backupCodesCreated; - final String? attributionPartner; - - UserSwitchResponse({ - required this.id, - required this.uuid, - required this.username, - required this.title, - required this.email, - this.friendlyName, - this.locale, - required this.confirmed, - required this.joinedAt, - required this.emailOnlyAuth, - required this.hasPassword, - required this.protected, - required this.thumb, - required this.authToken, - this.mailingListActive, - required this.scrobbleTypes, - required this.country, - required this.restricted, - this.anonymous, - required this.home, - required this.guest, - required this.homeSize, - required this.homeAdmin, - required this.maxHomeSize, - required this.profile, - required this.twoFactorEnabled, - required this.backupCodesCreated, - this.attributionPartner, - }); - - /// INVARIANT (#1488): a successful token mint must never be lost to parsing - /// of decorative fields. `authToken` is the only field any caller consumes - /// (see plex_home_switch.dart) — it alone parses strictly; every other - /// field tolerates missing/wrong-typed values with sane defaults. Plex has - /// changed field shapes on this endpoint before (July 2026: profile - /// language lists became CSV strings), and each drift used to brick token - /// minting outright. - factory UserSwitchResponse.fromJson(Map json) { - final authToken = json['authToken']; - if (authToken is! String || authToken.isEmpty) { - throw const FormatException('Plex /switch response has no usable authToken'); - } - - PlexUserProfile profile; - try { - profile = PlexUserProfile.fromJson(json); - } catch (e, st) { - appLogger.w('UserSwitchResponse: profile blob failed to parse; using defaults', error: e, stackTrace: st); - profile = PlexUserProfile.defaults(); - } - - String? optString(String key) => json[key]?.toString(); - - return UserSwitchResponse( - id: flexibleInt(json['id']) ?? 0, - uuid: optString('uuid') ?? '', - username: optString('username') ?? '', - title: optString('title') ?? '', - email: optString('email') ?? '', - friendlyName: optString('friendlyName'), - locale: optString('locale'), - confirmed: flexibleBool(json['confirmed']), - joinedAt: flexibleInt(json['joinedAt']) ?? 0, - emailOnlyAuth: flexibleBool(json['emailOnlyAuth']), - hasPassword: flexibleBool(json['hasPassword']), - protected: flexibleBool(json['protected']), - thumb: optString('thumb') ?? '', - authToken: authToken, - mailingListActive: flexibleBoolNullable(json['mailingListActive']), - scrobbleTypes: optString('scrobbleTypes') ?? '', - country: optString('country') ?? '', - restricted: flexibleBool(json['restricted']), - anonymous: flexibleBoolNullable(json['anonymous']), - home: flexibleBool(json['home']), - guest: flexibleBool(json['guest']), - homeSize: flexibleInt(json['homeSize']) ?? 1, - homeAdmin: flexibleBool(json['homeAdmin']), - maxHomeSize: flexibleInt(json['maxHomeSize']) ?? 1, - profile: profile, - twoFactorEnabled: flexibleBool(json['twoFactorEnabled']), - backupCodesCreated: flexibleBool(json['backupCodesCreated']), - attributionPartner: optString('attributionPartner'), - ); - } - - Map toJson() { - return { - 'id': id, - 'uuid': uuid, - 'username': username, - 'title': title, - 'email': email, - 'friendlyName': friendlyName, - 'locale': locale, - 'confirmed': confirmed, - 'joinedAt': joinedAt, - 'emailOnlyAuth': emailOnlyAuth, - 'hasPassword': hasPassword, - 'protected': protected, - 'thumb': thumb, - 'authToken': authToken, - 'mailingListActive': mailingListActive, - 'scrobbleTypes': scrobbleTypes, - 'country': country, - 'restricted': restricted, - 'anonymous': anonymous, - 'home': home, - 'guest': guest, - 'homeSize': homeSize, - 'homeAdmin': homeAdmin, - 'maxHomeSize': maxHomeSize, - 'profile': profile.toJson()['profile'], - 'twoFactorEnabled': twoFactorEnabled, - 'backupCodesCreated': backupCodesCreated, - 'attributionPartner': attributionPartner, - }; - } - - String get displayName => friendlyName ?? title; - - bool get isAdminUser => homeAdmin; - bool get isRestrictedUser => restricted; - bool get isGuestUser => guest; - bool get requiresPassword => hasPassword; -} diff --git a/lib/mpv/player/platform/player_android.dart b/lib/mpv/player/platform/player_android.dart index 37512204..ebd9fd50 100644 --- a/lib/mpv/player/platform/player_android.dart +++ b/lib/mpv/player/platform/player_android.dart @@ -145,6 +145,21 @@ class PlayerAndroid extends PlayerBase { } } + // A setting requested before the core is up is applied by _doInitialize from + // the stored fields; one requested while an init is in flight has to be + // replayed afterwards, but only if no newer request superseded it. + Future _applyWhenInitialized(Future Function() apply, bool Function() stillRequested) async { + final initFuture = _initFuture; + if (initialized) { + await apply(); + } else if (initFuture != null) { + await initFuture; + if (!disposed && initialized && stillRequested()) { + await apply(); + } + } + } + @override Future open( Media media, { @@ -279,15 +294,10 @@ class PlayerAndroid extends PlayerBase { break; case 'dv-conversion-mode': _dvConversionMode = value; - final initFuture = _initFuture; - if (initialized) { - await invoke('setDvConversionMode', {'mode': value}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _dvConversionMode == value) { - await invoke('setDvConversionMode', {'mode': value}); - } - } + await _applyWhenInitialized( + () => invoke('setDvConversionMode', {'mode': value}), + () => _dvConversionMode == value, + ); break; case 'sub-visibility': if (value == 'no') { @@ -316,15 +326,10 @@ class PlayerAndroid extends PlayerBase { Future setAudioNormalization(bool enabled) async { if (disposed) return; _audioNormalizationEnabled = enabled; - final initFuture = _initFuture; - if (initialized) { - await invoke('setAudioNormalization', {'enabled': enabled}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _audioNormalizationEnabled == enabled) { - await invoke('setAudioNormalization', {'enabled': enabled}); - } - } + await _applyWhenInitialized( + () => invoke('setAudioNormalization', {'enabled': enabled}), + () => _audioNormalizationEnabled == enabled, + ); // Keep the mpv af property flowing through setMpvProperty so the plugin's // pendingMpvProperties replay applies loudnorm if exo falls back to mpv. await super.setAudioNormalization(enabled); @@ -336,21 +341,10 @@ class PlayerAndroid extends PlayerBase { _downmixEnabled = enabled; _downmixCenterBoostDb = centerBoostDb; _downmixNormalize = normalize; - Future invokeNative() => - invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}); - final initFuture = _initFuture; - if (initialized) { - await invokeNative(); - } else if (initFuture != null) { - await initFuture; - if (!disposed && - initialized && - _downmixEnabled == enabled && - _downmixCenterBoostDb == centerBoostDb && - _downmixNormalize == normalize) { - await invokeNative(); - } - } + await _applyWhenInitialized( + () => invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}), + () => _downmixEnabled == enabled && _downmixCenterBoostDb == centerBoostDb && _downmixNormalize == normalize, + ); // Keep the mpv properties flowing through setMpvProperty so the plugin's // pendingMpvProperties replay applies downmix if exo falls back to mpv. await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize); @@ -360,15 +354,10 @@ class PlayerAndroid extends PlayerBase { Future setAudioPassthrough(bool enabled) async { if (disposed) return; _audioPassthroughEnabled = enabled; - final initFuture = _initFuture; - if (initialized) { - await invoke('setAudioPassthrough', {'enabled': enabled}); - } else if (initFuture != null) { - await initFuture; - if (!disposed && initialized && _audioPassthroughEnabled == enabled) { - await invoke('setAudioPassthrough', {'enabled': enabled}); - } - } + await _applyWhenInitialized( + () => invoke('setAudioPassthrough', {'enabled': enabled}), + () => _audioPassthroughEnabled == enabled, + ); await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); } diff --git a/lib/navigation/navigation_tabs.dart b/lib/navigation/navigation_tabs.dart index e4a70954..e1653b17 100644 --- a/lib/navigation/navigation_tabs.dart +++ b/lib/navigation/navigation_tabs.dart @@ -21,12 +21,6 @@ class NavigationTab { return NavigationDestination(icon: AppIcon(icon, fill: 1), selectedIcon: AppIcon(icon, fill: 1), label: getLabel()); } - /// Get the index for a tab ID in the visible tabs list - static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false, bool hasExplore = false}) { - final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv, hasExplore: hasExplore); - return tabs.indexWhere((tab) => tab.id == id); - } - /// Get tabs filtered by offline mode and feature availability static List getVisibleTabs({ required bool isOffline, diff --git a/lib/profiles/active_profile_binder.dart b/lib/profiles/active_profile_binder.dart index 32fda426..07083332 100644 --- a/lib/profiles/active_profile_binder.dart +++ b/lib/profiles/active_profile_binder.dart @@ -113,8 +113,6 @@ class ActiveProfileBinder { final Set _plexHomePreVerified = {}; final Set _userInitiatedActivations = {}; - bool get isSwitching => _isSwitching; - @visibleForTesting String? get debugLastBoundProfileId => _lastBoundProfileId; @@ -374,6 +372,11 @@ class ActiveProfileBinder { return success; } + /// Server ids the profile should reach once bound: its join rows plus the + /// implicit Plex Home parent, which normally has no row. Not shared with + /// `_serverIdsForProfile` (profile_connection_cleanup.dart) — that one is + /// join-rows-only and [ServerId]-typed, while this set keeps growing with + /// bind results and is compared against the manager's raw string ids. Set _expectedServerIdsForProfile( Profile profile, { required List joinRows, diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart index 0dc70c42..75a0c619 100644 --- a/lib/profiles/plex_home_service.dart +++ b/lib/profiles/plex_home_service.dart @@ -26,7 +26,7 @@ class PlexHomeService { this._storage, Future> Function(String accountToken)? plexHomeUserFetcher, this._refreshInterval = const Duration(hours: 1), - }) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher; + }) : _fetchHomeUsers = plexHomeUserFetcher ?? fetchPlexHomeUsers; final ConnectionRegistry _connections; final ProfileConnectionRegistry _profileConnections; @@ -498,13 +498,3 @@ class PlexHomeService { _started = false; } } - -Future> _defaultHomeUserFetcher(String accountToken) async { - final auth = await PlexAuthService.create(); - try { - final home = await auth.getHomeUsers(accountToken); - return home.users; - } finally { - auth.dispose(); - } -} diff --git a/lib/profiles/plex_home_switch.dart b/lib/profiles/plex_home_switch.dart index 1f101fa4..c772494b 100644 --- a/lib/profiles/plex_home_switch.dart +++ b/lib/profiles/plex_home_switch.dart @@ -51,8 +51,8 @@ Future switchPlexHomeUserWithPin({ if (pin == null) return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.cancelled, null); } try { - final response = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); - return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken); + final userToken = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); + return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, userToken); } on MediaServerHttpException catch (e) { if (e.statusCode == 403 && _isInvalidPin(e)) { error = t.profiles.incorrectPinTryAgain; diff --git a/lib/profiles/profile_activation.dart b/lib/profiles/profile_activation.dart index 416cdee5..c444c9bb 100644 --- a/lib/profiles/profile_activation.dart +++ b/lib/profiles/profile_activation.dart @@ -234,6 +234,8 @@ Future _preVerifyPlexHomePin(BuildContext context, Profile final connections = context.read(); final pcRegistry = context.read(); final binder = context.read(); + // Built before the await: capturing the prompt needs a live context. + final promptForPin = dialogPinPrompt(context, profile.displayName); final all = await connections.list(); PlexAccountConnection? account; for (final c in all) { @@ -248,10 +250,7 @@ Future _preVerifyPlexHomePin(BuildContext context, Profile account: account, homeUserUuid: homeUuid, requiresPin: true, - promptForPin: ({String? errorMessage}) async { - if (!context.mounted) return null; - return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage); - }, + promptForPin: promptForPin, persistTo: pcRegistry, persistProfileId: profile.id, logLabel: profile.displayName, diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart index 53fa0875..8328baa9 100644 --- a/lib/profiles/profile_connection_cleanup.dart +++ b/lib/profiles/profile_connection_cleanup.dart @@ -9,65 +9,6 @@ import 'profile_connection_registry.dart'; import 'profile_merge.dart'; import 'profile_registry.dart'; -Future removeProfileConnectionAndCleanup({ - required String profileId, - required Connection connection, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - final removedServerIds = _serverIdsForConnection(connection); - await profileConnections.remove(profileId, connection.id); - await _clearProfileServerPrefsNoLongerReferenced( - profileId: profileId, - removedServerIds: removedServerIds, - profileConnections: profileConnections, - connections: connections, - storage: storage, - clearEverywhereWhenUnreferenced: connection is JellyfinConnection, - ); - - if (connection is JellyfinConnection) { - await _removeUnreferencedJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - } -} - -Future removeAllProfileConnectionsAndCleanup({ - required String profileId, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - final rows = await profileConnections.listForProfile(profileId); - if (rows.isEmpty) return; - - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - for (final row in rows) { - final connection = byId[row.connectionId]; - if (connection == null) { - await profileConnections.remove(profileId, row.connectionId); - continue; - } - await removeProfileConnectionAndCleanup( - profileId: profileId, - connection: connection, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - } -} - /// Profile ids affected by a Plex account removal. Planning is read-only so /// callers can finish failure-prone cleanup before committing join/account /// deletion. @@ -96,226 +37,206 @@ Future planPlexAccountConnectionRemoval({ return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); } -/// Sign out of a Plex account: remove the account [Connection], every join -/// row referencing it, and everything owned by its virtual Plex Home -/// profiles — including borrowed Jellyfin connections left unreferenced, -/// which previously survived as orphans and wedged the session (#1423). -/// -/// Pass a read-only [plannedRemoval] from -/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup -/// must finish before this destructive commit. Omitting it preserves the -/// atomic add/cancel-account cleanup path. -/// -/// All cleanup is explicit and completes before this returns; correctness -/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which -/// runs later and no-ops. -Future removePlexAccountConnectionAndCleanup({ - required PlexAccountConnection account, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, - PlexAccountRemoval? plannedRemoval, -}) async { - final removal = - plannedRemoval ?? - await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); - final removedVirtualProfileIds = removal.removedVirtualProfileIds; - final borrowerProfileIds = removal.borrowerProfileIds; - final rows = await profileConnections.listAll(); - // Remove direct join rows first so per-profile pref cleanup observes each - // row going away; the FK cascade from the connection delete is then a no-op. - for (final row in rows.where((r) => r.connectionId == account.id)) { - await removeProfileConnectionAndCleanup( - profileId: row.profileId, - connection: account, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - } - await connections.remove(account.id); - await storage.clearPlexHomeUsersCache(account.id); - - // The account's virtual profiles die with the connection; their borrowed - // connections and per-profile prefs must go too. - for (final profileId in removedVirtualProfileIds) { - await removeAllProfileConnectionsAndCleanup( - profileId: profileId, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - await storage.clearProfileLastUsed(profileId); - await storage.clearUserScopedPreferencesForProfile(profileId); - } - - return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); -} - /// Where the session should land after a profile or connection removal. enum PostRemovalRoute { signedOut, staySignedIn } -/// In-session mirror of the boot guard (`main.dart`: "stored connections -/// exist but no profiles resolved — returning to auth"): prune orphaned -/// Jellyfin connections, then decide whether any selectable profile remains. -/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed -/// accounts are harmless because the connection map is re-read here. -Future<({PostRemovalRoute route, List profiles})> resolvePostRemovalState({ - required ProfileRegistry profileRegistry, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required Map> plexHomeUsers, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - final conns = await connections.list(); - if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); +/// Removal of profile↔connection join rows and everything they leave +/// unreferenced, bound to one set of registries. Every flow resolves the same +/// instances from the provider tree, so callers construct this once and call +/// through it. +class ProfileConnectionCleanup { + ProfileConnectionCleanup({ + required this.profileConnections, + required this.connections, + required this.storage, + this.serverManager, + }); - final merged = mergeLocalWithPlexHome( - locals: await profileRegistry.list(), - plexHomeByConnectionId: plexHomeUsers, - connectionsById: {for (final c in conns) c.id: c}, - storage: storage, - ); - if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); - return (route: PostRemovalRoute.staySignedIn, profiles: merged); -} + final ProfileConnectionRegistry profileConnections; + final ConnectionRegistry connections; + final StorageService storage; + final MultiServerManager? serverManager; -Future pruneUnreferencedJellyfinConnections({ - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - final all = await connections.list(); - final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); - var removed = 0; + Future removeProfileConnection({required String profileId, required Connection connection}) async { + final removedServerIds = _serverIdsForConnection(connection); + await profileConnections.remove(profileId, connection.id); + await _clearProfileServerPrefsNoLongerReferenced( + profileId: profileId, + removedServerIds: removedServerIds, + clearEverywhereWhenUnreferenced: connection is JellyfinConnection, + ); - for (final connection in all.whereType()) { - if (referencedConnectionIds.contains(connection.id)) continue; - await _removeJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, + if (connection is JellyfinConnection) { + await _removeUnreferencedJellyfinConnection(connection); + } + } + + Future removeAllProfileConnections(String profileId) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection == null) { + await profileConnections.remove(profileId, row.connectionId); + continue; + } + await removeProfileConnection(profileId: profileId, connection: connection); + } + } + + /// Sign out of a Plex account: remove the account [Connection], every join + /// row referencing it, and everything owned by its virtual Plex Home + /// profiles — including borrowed Jellyfin connections left unreferenced, + /// which previously survived as orphans and wedged the session (#1423). + /// + /// Pass a read-only [plannedRemoval] from + /// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup + /// must finish before this destructive commit. Omitting it preserves the + /// atomic add/cancel-account cleanup path. + /// + /// All cleanup is explicit and completes before this returns; correctness + /// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which + /// runs later and no-ops. + Future removePlexAccountConnection( + PlexAccountConnection account, { + PlexAccountRemoval? plannedRemoval, + }) async { + final removal = + plannedRemoval ?? + await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); + final removedVirtualProfileIds = removal.removedVirtualProfileIds; + final borrowerProfileIds = removal.borrowerProfileIds; + final rows = await profileConnections.listAll(); + // Remove direct join rows first so per-profile pref cleanup observes each + // row going away; the FK cascade from the connection delete is then a no-op. + for (final row in rows.where((r) => r.connectionId == account.id)) { + await removeProfileConnection(profileId: row.profileId, connection: account); + } + await connections.remove(account.id); + await storage.clearPlexHomeUsersCache(account.id); + + // The account's virtual profiles die with the connection; their borrowed + // connections and per-profile prefs must go too. + for (final profileId in removedVirtualProfileIds) { + await removeAllProfileConnections(profileId); + await storage.clearProfileLastUsed(profileId); + await storage.clearUserScopedPreferencesForProfile(profileId); + } + + return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); + } + + /// In-session mirror of the boot guard (`main.dart`: "stored connections + /// exist but no profiles resolved — returning to auth"): prune orphaned + /// Jellyfin connections, then decide whether any selectable profile remains. + /// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed + /// accounts are harmless because the connection map is re-read here. + Future<({PostRemovalRoute route, List profiles})> resolvePostRemovalState({ + required ProfileRegistry profileRegistry, + required Map> plexHomeUsers, + }) async { + await pruneUnreferencedJellyfinConnections(); + final conns = await connections.list(); + if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); + + final merged = mergeLocalWithPlexHome( + locals: await profileRegistry.list(), + plexHomeByConnectionId: plexHomeUsers, + connectionsById: {for (final c in conns) c.id: c}, storage: storage, - serverManager: serverManager, ); - removed++; + if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); + return (route: PostRemovalRoute.staySignedIn, profiles: merged); } - return removed; -} + Future pruneUnreferencedJellyfinConnections() async { + final all = await connections.list(); + final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); + var removed = 0; -Future _removeUnreferencedJellyfinConnection( - JellyfinConnection connection, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; - await _removeJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); -} + for (final connection in all.whereType()) { + if (referencedConnectionIds.contains(connection.id)) continue; + await _removeJellyfinConnection(connection); + removed++; + } -Future _removeJellyfinConnection( - JellyfinConnection connection, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - await connections.remove(connection.id); - serverManager?.removeJellyfinConnection(connection); - final serverId = ServerId.tryParse(connection.serverMachineId); - if (serverId != null && - !await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) { - await storage.clearLibraryPreferencesForServerEverywhere(serverId); + return removed; } -} -Future _clearProfileServerPrefsNoLongerReferenced({ - required String profileId, - required Set removedServerIds, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - required bool clearEverywhereWhenUnreferenced, -}) async { - if (removedServerIds.isEmpty) return; - final remainingProfileServerIds = await _serverIdsForProfile( - profileId, - profileConnections: profileConnections, - connections: connections, - ); - final activeProfileId = storage.getActiveProfileId(); + Future _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async { + if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; + await _removeJellyfinConnection(connection); + } - for (final serverId in removedServerIds) { - if (remainingProfileServerIds.contains(serverId)) continue; - final serverStillReferenced = await _isServerReferenced( - serverId, - profileConnections: profileConnections, - connections: connections, - ); - if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { - await storage.clearLibraryPreferencesForServer( - serverId, - profileId: profileId, - includeLegacy: activeProfileId == profileId, - ); - } else { + Future _removeJellyfinConnection(JellyfinConnection connection) async { + await connections.remove(connection.id); + serverManager?.removeJellyfinConnection(connection); + final serverId = ServerId.tryParse(connection.serverMachineId); + if (serverId != null && !await _isServerReferenced(serverId)) { await storage.clearLibraryPreferencesForServerEverywhere(serverId); } } -} -Future> _serverIdsForProfile( - String profileId, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, -}) async { - final rows = await profileConnections.listForProfile(profileId); - if (rows.isEmpty) return const {}; + Future _clearProfileServerPrefsNoLongerReferenced({ + required String profileId, + required Set removedServerIds, + required bool clearEverywhereWhenUnreferenced, + }) async { + if (removedServerIds.isEmpty) return; + final remainingProfileServerIds = await _serverIdsForProfile(profileId); + final activeProfileId = storage.getActiveProfileId(); - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - return { - for (final row in rows) - if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), - }; -} + for (final serverId in removedServerIds) { + if (remainingProfileServerIds.contains(serverId)) continue; + final serverStillReferenced = await _isServerReferenced(serverId); + if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { + await storage.clearLibraryPreferencesForServer( + serverId, + profileId: profileId, + includeLegacy: activeProfileId == profileId, + ); + } else { + await storage.clearLibraryPreferencesForServerEverywhere(serverId); + } + } + } -Future _isServerReferenced( - ServerId serverId, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, -}) async { - final rows = await profileConnections.listAll(); - if (rows.isEmpty) return false; + /// Server ids reachable through this profile's join rows. Narrower than + /// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home + /// parent is not counted here, so folding the two together would change which + /// per-profile prefs survive an unlink. + Future> _serverIdsForProfile(String profileId) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return const {}; - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - for (final row in rows) { - final connection = byId[row.connectionId]; - if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + return { + for (final row in rows) + if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), + }; + } + + Future _isServerReferenced(ServerId serverId) async { + final rows = await profileConnections.listAll(); + if (rows.isEmpty) return false; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; + } + return false; } - return false; } +// [ServerId]-typed for the preference APIs, which drops ids that fail to +// parse; the twin in profile_detail_screen.dart stays raw so it can be +// differenced against download keys. Set _serverIdsForConnection(Connection connection) { return switch (connection) { PlexAccountConnection(:final servers) => { diff --git a/lib/profiles/profile_selection_policy.dart b/lib/profiles/profile_selection_policy.dart new file mode 100644 index 00000000..b7126dbc --- /dev/null +++ b/lib/profiles/profile_selection_policy.dart @@ -0,0 +1,14 @@ +import '../services/settings_service.dart'; +import 'active_profile_provider.dart'; + +extension ProfileSelectionPolicy on ActiveProfileProvider { + /// The "ask for a profile every time the app opens" rule: the pref only bites + /// when there is more than one profile to pick from. + /// + /// Stated once because the sites must agree — ActiveProfileBinder defers its + /// cold-start bind exactly when this holds, and SetupScreen/MainScreen pop the + /// picker exactly when it holds. If they drifted, the binder would defer a bind + /// that nothing ever prompts for and the user would land on an unbound screen. + bool requiresSelectionOnOpen(SettingsService settings) => + settings.read(SettingsService.requireProfileSelectionOnOpen) && hasMultipleProfiles; +} diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index 652fe112..d7157e3c 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -684,57 +684,32 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}'); - final candidate = _peerServiceFactory(); - _pendingRemotePeer = candidate; - _session = RemoteSession( - role: RemoteSessionRole.remote, - status: RemoteSessionStatus.connecting, - createdAt: DateTime.now(), + String? winner; + final connected = await _runRemoteConnect( + generation: generation, + seedConnectingSession: true, + rethrowOnFailure: true, + join: (peer) async { + winner = await peer.joinSessionRacingWithContexts( + _deviceName, + _platform, + host.addresses, + _authContexts, + authContextId: authContext.id, + expectedHostClientId: host.clientId, + ); + }, + onConnected: (peer) { + _lastHostAddresses = [winner!]; + _lastAuthContextId = peer.selectedAuthContextId ?? authContext.id; + _lastHostClientId = peer.selectedHostClientId ?? host.clientId; + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + }, + failureLog: 'CompanionRemote: Failed to connect to host', + onFailure: _failRemoteConnectSession, ); - _setupPeerServiceListeners(candidate, generation); - safeNotifyListeners(); - - try { - final winner = await candidate.joinSessionRacingWithContexts( - _deviceName, - _platform, - host.addresses, - _authContexts, - authContextId: authContext.id, - expectedHostClientId: host.clientId, - ); - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _peerService = candidate; - _lastHostAddresses = [winner]; - _lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id; - _lastHostClientId = candidate.selectedHostClientId ?? host.clientId; - _session = _session?.copyWith(status: RemoteSessionStatus.connected); - safeNotifyListeners(); + if (connected) { appLogger.d('CompanionRemote: Connected to ${host.name} via $winner'); - } catch (error, stackTrace) { - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _cleanupSubscriptions(); - await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace); - _session = _session?.copyWith( - status: RemoteSessionStatus.error, - errorMessage: _localizedRemoteError( - error, - (details) => t.companionRemote.pairing.failedToConnect(error: details), - ), - ); - safeNotifyListeners(); - rethrow; } } @@ -754,48 +729,84 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin appLogger.d('CompanionRemote: Connecting to manual host $hostAddress'); + await _runRemoteConnect( + generation: generation, + seedConnectingSession: true, + rethrowOnFailure: true, + join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts), + onConnected: (peer) { + _lastAuthContextId = peer.selectedAuthContextId; + _lastHostClientId = peer.selectedHostClientId ?? ''; + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + }, + failureLog: 'CompanionRemote: Failed to connect to manual host', + onFailure: _failRemoteConnectSession, + ); + } + + void _failRemoteConnectSession(Object error) { + _session = _session?.copyWith( + status: RemoteSessionStatus.error, + errorMessage: _localizedRemoteError( + error, + (details) => t.companionRemote.pairing.failedToConnect(error: details), + ), + ); + safeNotifyListeners(); + } + + /// Runs the candidate-peer connect lifecycle shared by the discovered/manual + /// connect paths and by reconnect attempts: create a candidate, wire its + /// listeners, then promote it to [_peerService] or dispose it. The generation + /// guards live here so a candidate that lost ownership while joining is + /// disposed rather than promoted, in exactly one place. Returns true only + /// when the candidate was promoted. + Future _runRemoteConnect({ + required int generation, + required Future Function(CompanionRemotePeerService peer) join, + required void Function(CompanionRemotePeerService peer) onConnected, + required String failureLog, + required void Function(Object error) onFailure, + bool seedConnectingSession = false, + bool rethrowOnFailure = false, + }) async { final candidate = _peerServiceFactory(); _pendingRemotePeer = candidate; - _session = RemoteSession( - role: RemoteSessionRole.remote, - status: RemoteSessionStatus.connecting, - createdAt: DateTime.now(), - ); + if (seedConnectingSession) { + _session = RemoteSession( + role: RemoteSessionRole.remote, + status: RemoteSessionStatus.connecting, + createdAt: DateTime.now(), + ); + } _setupPeerServiceListeners(candidate, generation); - safeNotifyListeners(); + if (seedConnectingSession) safeNotifyListeners(); try { - await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts); + await join(candidate); if (!_ownsPeer(candidate, generation)) { await _disposePeerOnce(candidate); - return; + return false; } _pendingRemotePeer = null; _peerService = candidate; - _lastAuthContextId = candidate.selectedAuthContextId; - _lastHostClientId = candidate.selectedHostClientId ?? ''; - _session = _session?.copyWith(status: RemoteSessionStatus.connected); + onConnected(candidate); safeNotifyListeners(); + return true; } catch (error, stackTrace) { if (!_ownsPeer(candidate, generation)) { await _disposePeerOnce(candidate); - return; + return false; } _pendingRemotePeer = null; _cleanupSubscriptions(); await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace); - _session = _session?.copyWith( - status: RemoteSessionStatus.error, - errorMessage: _localizedRemoteError( - error, - (details) => t.companionRemote.pairing.failedToConnect(error: details), - ), - ); - safeNotifyListeners(); - rethrow; + appLogger.e(failureLog, error: error, stackTrace: stackTrace); + onFailure(error); + if (rethrowOnFailure) rethrow; + return false; } } @@ -981,47 +992,34 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin } if (generation != _remoteGeneration || isDisposed) return; - final candidate = _peerServiceFactory(); - _pendingRemotePeer = candidate; - _setupPeerServiceListeners(candidate, generation); final authContextId = _authContextForId(_lastAuthContextId)?.id; final expectedHostClientId = _lastHostClientId ?? ''; - try { - await candidate.joinSessionWithContexts( + final reconnected = await _runRemoteConnect( + generation: generation, + join: (peer) => peer.joinSessionWithContexts( _deviceName, _platform, hostAddresses.first, _authContexts, authContextId: authContextId, expectedHostClientId: expectedHostClientId, - ); - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _peerService = candidate; - _lastAuthContextId = candidate.selectedAuthContextId ?? authContextId; - _lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId; - _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); - _reconnectAttempts = 0; - safeNotifyListeners(); + ), + onConnected: (peer) { + _lastAuthContextId = peer.selectedAuthContextId ?? authContextId; + _lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId; + _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); + _reconnectAttempts = 0; + }, + failureLog: 'CompanionRemote: Reconnect failed', + onFailure: (_) { + if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) { + _scheduleReconnect(generation); + } + }, + ); + if (reconnected) { appLogger.d('CompanionRemote: Reconnected successfully'); - } catch (error, stackTrace) { - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _cleanupSubscriptions(); - await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace); - if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) { - _scheduleReconnect(generation); - } } } diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index 1905c58d..c9123f66 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -14,7 +14,7 @@ import '../services/system_shelf_service.dart'; import '../utils/app_logger.dart'; import '../utils/coalesced_load_coordinator.dart'; import '../utils/deletion_notifier.dart'; -import '../utils/global_key_utils.dart'; +import '../utils/media_event_keys.dart'; import '../utils/media_hub_ordering.dart'; import '../utils/watch_state_notifier.dart'; import 'hidden_libraries_provider.dart'; @@ -467,28 +467,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Watch on-deck items and their parent shows/seasons (an episode's watch /// flip changes what Continue Watching should show for its series). - Set? get _watchedIds { - final keys = {}; - for (final item in _onDeck) { - keys.add(item.id); - if (item.parentId != null) keys.add(item.parentId!); - if (item.grandparentId != null) keys.add(item.grandparentId!); - } - return keys; - } + Set? get _watchedIds => hierarchicalEventIds(_onDeck); - Set? get _watchedGlobalKeys { - final keys = {}; - for (final item in _onDeck) { - final serverId = item.serverId; - if (serverId == null) return null; - - keys.add(buildGlobalKey(ServerId(serverId), item.id)); - if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); - if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); - } - return keys; - } + Set? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck); void _onWatchStateChanged(WatchStateEvent event) { if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) { @@ -512,46 +493,15 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin unawaited(refreshContinueWatching()); } + /// Everything on screen: the Continue Watching row plus every hub row. + Iterable get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items)); + /// Deletions can affect any visible list, so the filter covers on-deck and /// hub items plus their parents (a deleted season/show takes its visible /// episodes with it). - Set? get _deletionIds { - final keys = {}; - void addItem(MediaItem item) { - keys.add(item.id); - if (item.parentId != null) keys.add(item.parentId!); - if (item.grandparentId != null) keys.add(item.grandparentId!); - } + Set? get _deletionIds => hierarchicalEventIds(_visibleItems); - _onDeck.forEach(addItem); - for (final hub in _hubs) { - hub.items.forEach(addItem); - } - return keys; - } - - Set? get _deletionGlobalKeys { - final keys = {}; - bool addItem(MediaItem item) { - final serverId = item.serverId; - if (serverId == null) return false; - - keys.add(buildGlobalKey(ServerId(serverId), item.id)); - if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); - if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); - return true; - } - - for (final item in _onDeck) { - if (!addItem(item)) return null; - } - for (final hub in _hubs) { - for (final item in hub.items) { - if (!addItem(item)) return null; - } - } - return keys; - } + Set? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems); void _onDeletion(DeletionEvent event) { // On-deck and hubs are server-backed: a download-only deletion leaves the diff --git a/lib/providers/download_metadata_store.dart b/lib/providers/download_metadata_store.dart index 3cef5056..406b84f6 100644 --- a/lib/providers/download_metadata_store.dart +++ b/lib/providers/download_metadata_store.dart @@ -139,7 +139,7 @@ class _DownloadMetadataStore extends ChangeNotifier { hydrated.add( HydratedWatchStatePatch( globalKey: scopedKey, - patch: WatchStatePatch.fromSnapshot(snapshot), + patch: snapshot, updatedAt: latest.updatedAt, order: latest.id, ), diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 05141de9..8ef3e117 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -1,6 +1,5 @@ import 'dart:async'; import '../media/ids.dart'; -import 'dart:io'; import 'package:flutter/foundation.dart'; import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; @@ -18,6 +17,7 @@ import '../services/download_manager_service.dart'; import '../services/api_cache.dart'; import '../services/download_artwork_service.dart'; import '../services/download_storage_service.dart'; +import '../services/downloaded_video_source.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_mode_source.dart'; import '../services/watch_state_resolver.dart'; @@ -26,7 +26,6 @@ import '../media/media_server_client.dart'; import '../services/sync_rule_executor.dart'; import '../utils/app_logger.dart'; import '../utils/deletion_notifier.dart'; -import '../utils/downloaded_version_match.dart'; import '../media/episode_collection.dart'; import '../utils/global_key_utils.dart'; import '../utils/content_utils.dart'; @@ -972,6 +971,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Check if an item is in the queue /// For shows/seasons, checks if any episodes are queued + @visibleForTesting bool isQueued(String globalKey) { final progress = getProgress(globalKey); return progress?.status == DownloadStatus.queued; @@ -1007,46 +1007,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin appLogger.w('No downloaded item found for globalKey: $globalKey'); return null; } - if (downloadedItem.status != DownloadStatus.completed.index) { - appLogger.w('Download not complete. Status: ${downloadedItem.status}'); - return null; - } - if (!downloadedVersionMatches( + + final source = await resolveDownloadedVideoSource( downloadedItem, requestedMediaIndex: mediaIndex, requestedMediaSourceId: mediaSourceId, - )) { - appLogger.w( - 'Downloaded version mismatch for $globalKey: have index ${downloadedItem.mediaIndex} ' - '(source ${downloadedItem.mediaSourceId}), expected index $mediaIndex ' - '(source ${mediaSourceId?.trim()})', - ); - return null; - } - if (downloadedItem.videoFilePath == null) { - appLogger.w('Video file path is null for globalKey: $globalKey'); - return null; - } - - final storedPath = downloadedItem.videoFilePath!; - final storageService = DownloadStorageService.instance; - - // SAF URIs (content://) are already valid - don't transform them - if (storageService.isSafUri(storedPath)) { - appLogger.d('Found SAF video path: $storedPath'); - return storedPath; - } - - // Convert stored path (may be relative) to absolute path - final absolutePath = await storageService.ensureAbsolutePath(storedPath); - - // Verify file exists - final file = File(absolutePath); - if (!await file.exists()) { - appLogger.w('Offline video file not found: $absolutePath'); - return null; - } - return absolutePath; + ); + return source?.path; } /// Queue a download for a media item. @@ -1150,14 +1117,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// Queue every playable item from a collection/playlist for download. /// - /// Movies, episodes, and tracks are queued directly. Shows and seasons are - /// expanded into their episodes and albums/artists into their tracks (when - /// [expandShows] is true). Nested collections/playlists and unknown types + /// Expansion follows [collectListLeaves] so a one-shot list download queues + /// exactly what a sync rule on the same list would. + /// + /// When [syncRule] is given, the rule's membership — the unfiltered leaves of + /// the list, not just the ones this pass queues — is linked to the rule so a + /// later "delete rule and its downloads" pass can find every associated row. Future queueListDownload( List items, MediaServerClient client, { DownloadFilter filter = DownloadFilter.all, - bool expandShows = true, SyncRuleItem? syncRule, }) async { if (!_downloadManager.downloadsSupported) return 0; @@ -1168,30 +1137,24 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } if (!_isQueueOwnershipCurrent(ownership)) return 0; + final unwatchedOnly = filter == DownloadFilter.unwatched; final membership = []; final candidates = []; - if (expandShows) { + // Expand one list entry at a time so a cancelled queue stops before the + // next container is fetched. + for (final item in items) { + if (!_isQueueOwnershipCurrent(ownership)) return 0; + final leaves = []; + await collectListLeaves(client, [item], unwatchedOnly: unwatchedOnly, out: leaves); + candidates.addAll(leaves); if (syncRule != null) { - await _syncRuleExecutor.collectItemsForList(client, items, unwatchedOnly: false, out: membership); + if (unwatchedOnly) { + // Rule membership spans the whole list; only the queue is filtered. + await collectListLeaves(client, [item], unwatchedOnly: false, out: membership); + } else { + membership.addAll(leaves); + } } - if (filter == DownloadFilter.all && syncRule != null) { - candidates.addAll(membership); - } else { - await _syncRuleExecutor.collectItemsForList( - client, - items, - unwatchedOnly: filter == DownloadFilter.unwatched, - out: candidates, - ); - } - } else { - final playableItems = items.where((item) => item.isMovie || item.isEpisode || item.kind == MediaKind.track); - if (syncRule != null) membership.addAll(playableItems); - candidates.addAll( - filter == DownloadFilter.unwatched - ? playableItems.where((item) => item.isUnwatchedOrInProgress) - : playableItems, - ); } if (!_isQueueOwnershipCurrent(ownership)) return 0; diff --git a/lib/providers/hidden_libraries_provider.dart b/lib/providers/hidden_libraries_provider.dart index 4f52aac7..7d64550d 100644 --- a/lib/providers/hidden_libraries_provider.dart +++ b/lib/providers/hidden_libraries_provider.dart @@ -76,6 +76,7 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi } /// Check if a specific library is hidden + @visibleForTesting bool isLibraryHidden(String libraryKey) => _hiddenLibraryKeys.contains(libraryKey); /// Refresh hidden libraries from storage diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index 533980c5..cf5c0757 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -65,9 +65,11 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi bool get isLoading => _loadState == LibrariesLoadState.loading; /// Whether libraries have been loaded at least once + @visibleForTesting bool get hasLoaded => _loadState == LibrariesLoadState.loaded; /// Current load state + @visibleForTesting LibrariesLoadState get loadState => _loadState; /// Error message if loading failed diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 3e09fe52..644d3d0a 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -108,24 +108,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi /// filter to a one-element set when no filter is currently set. void addToVisibleServerIds(ServerId serverId) { final current = _visibleServerIds; - if (current == null) { - _serverManager.setVisibleServerIds({serverId}); - _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; - safeNotifyListeners(); - _refreshLiveTvAvailabilitySoon(); - return; - } - if (current.contains(serverId)) return; - _serverManager.setVisibleServerIds({...current, serverId}); + if (current != null && current.contains(serverId)) return; + _serverManager.setVisibleServerIds({...?current, serverId}); _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; safeNotifyListeners(); _refreshLiveTvAvailabilitySoon(); } + /// Keep only ids the manager considers visible under the active filter. + List _visible(List ids) => + ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList(); + void _pruneLiveTvServersForVisibility() { - final filter = _visibleServerIds; - if (filter == null) return; - _liveTvServers.removeWhere((s) => !filter.contains(s.serverId)); + if (_visibleServerIds == null) return; + _liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId))); _hasLiveTv = _liveTvServers.isNotEmpty; } @@ -199,20 +195,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Get all online server IDs (visibility-filtered). - List get onlineServerIds { - final all = _serverManager.onlineServerIds; - final filter = _visibleServerIds; - if (filter == null) return all; - return all.where(filter.contains).toList(); - } + List get onlineServerIds => _visible(_serverManager.onlineServerIds); /// Get all server IDs (visibility-filtered). - List get serverIds { - final all = _serverManager.serverIds; - final filter = _visibleServerIds; - if (filter == null) return all; - return all.where(filter.contains).toList(); - } + List get serverIds => _visible(_serverManager.serverIds); /// Server ids the active profile is expected to have, including unreachable /// Plex servers that have no live client yet. @@ -223,11 +209,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Check if a server is online (and visible under the active profile). - bool isServerOnline(ServerId serverId) { - final filter = _visibleServerIds; - if (filter != null && !filter.contains(serverId)) return false; - return _serverManager.isServerOnline(serverId); - } + bool isServerOnline(ServerId serverId) => + _serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId); /// Get number of online servers int get onlineServerCount => onlineServerIds.length; @@ -312,10 +295,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi } } - final filter = _visibleServerIds; - final visibleLiveTvServers = filter == null - ? newLiveTvServers - : newLiveTvServers.where((s) => filter.contains(s.serverId)).toList(); + final visibleLiveTvServers = newLiveTvServers + .where((s) => _serverManager.isServerVisible(ServerId(s.serverId))) + .toList(); final hadLiveTv = _hasLiveTv; final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index c9541bc8..81b3c52a 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -75,9 +75,11 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi } /// Whether there is network connectivity (WiFi, mobile data, etc.) + @visibleForTesting bool get hasNetworkConnection => _hasNetworkConnection; /// Whether at least one media server (Plex or Jellyfin) is reachable + @visibleForTesting bool get hasServerConnection => _hasServerConnection; bool get _hasKnownVisibleServers => diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index d0fd9857..c4abd3f5 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -71,6 +71,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// 2. Metadata from download provider /// /// Returns null if no position is available. + @visibleForTesting Future getViewOffset(String globalKey) async { // First check local offline progress final localOffset = await _syncService.getLocalViewOffset(globalKey); diff --git a/lib/providers/playback_state_provider.dart b/lib/providers/playback_state_provider.dart index 377f99cf..b54f93d1 100644 --- a/lib/providers/playback_state_provider.dart +++ b/lib/providers/playback_state_provider.dart @@ -306,7 +306,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { var anchor = current; // Bounded so a pathological all-same-file queue cannot spin. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemAfter(anchor); + final result = await _itemAtOffset(anchor, 1); final candidate = result.item; if (result.status != QueueNavigationStatus.found || candidate == null) { return result; @@ -337,7 +337,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { final current = _loadedItems[indexResult.index!]; MediaItem candidate = current; for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemBefore(candidate); + final result = await _itemAtOffset(candidate, -1); final before = result.item; if (result.status != QueueNavigationStatus.found || before == null) { return result; @@ -348,7 +348,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { // Collapse to the first episode of the candidate's same-file group. for (var steps = 0; steps <= _playQueueTotalCount; steps++) { - final result = await _itemBefore(candidate); + final result = await _itemAtOffset(candidate, -1); final before = result.item; if (result.status == QueueNavigationStatus.failed) return result; if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) { @@ -359,18 +359,19 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { return QueueNavigationResult.found(candidate); } - /// The queue item immediately after [anchor], extending a server-backed + /// The queue item [delta] steps from [anchor], extending a server-backed /// window when needed. The centered response proves whether [anchor] is at /// the global boundary; a window-local index is never compared with the /// queue's global item count. - Future _itemAfter(MediaItem anchor) async { + Future _itemAtOffset(MediaItem anchor, int delta) async { final anchorId = playQueueItemIdFor(anchor); if (anchorId == null) return const QueueNavigationResult.unavailable(); var anchorIndex = _findLoadedIndex(anchorId); if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - if (anchorIndex + 1 < _loadedItems.length) { - return QueueNavigationResult.found(_loadedItems[anchorIndex + 1]); + var target = anchorIndex + delta; + if (target >= 0 && target < _loadedItems.length) { + return QueueNavigationResult.found(_loadedItems[target]); } // Local queues are fully resident, so their window edge is the queue edge. @@ -382,43 +383,15 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin { } // Refresh around the actual anchor. Queue ids are opaque and need not be - // consecutive, so never guess `anchorId + 1`. + // consecutive, so never guess the neighbour's id. if (!await _loadServerWindow(anchorId)) { return const QueueNavigationResult.failed(); } anchorIndex = _findLoadedIndex(anchorId); if (anchorIndex == -1) return const QueueNavigationResult.failed(); - return anchorIndex + 1 < _loadedItems.length - ? QueueNavigationResult.found(_loadedItems[anchorIndex + 1]) - : const QueueNavigationResult.boundary(); - } - - /// The queue item immediately before [anchor], extending a server-backed - /// window when needed. - Future _itemBefore(MediaItem anchor) async { - final anchorId = playQueueItemIdFor(anchor); - if (anchorId == null) return const QueueNavigationResult.unavailable(); - var anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); - - if (anchorIndex > 0) { - return QueueNavigationResult.found(_loadedItems[anchorIndex - 1]); - } - - if (_windowFetcher == null || _playQueueId == null) { - return const QueueNavigationResult.boundary(); - } - if (_playQueueTotalCount > 0 && _loadedItems.length >= _playQueueTotalCount) { - return const QueueNavigationResult.boundary(); - } - - if (!await _loadServerWindow(anchorId)) { - return const QueueNavigationResult.failed(); - } - anchorIndex = _findLoadedIndex(anchorId); - if (anchorIndex == -1) return const QueueNavigationResult.failed(); - return anchorIndex > 0 - ? QueueNavigationResult.found(_loadedItems[anchorIndex - 1]) + target = anchorIndex + delta; + return target >= 0 && target < _loadedItems.length + ? QueueNavigationResult.found(_loadedItems[target]) : const QueueNavigationResult.boundary(); } diff --git a/lib/providers/theme_provider.dart b/lib/providers/theme_provider.dart index 6a25bedb..25cfacd6 100644 --- a/lib/providers/theme_provider.dart +++ b/lib/providers/theme_provider.dart @@ -88,6 +88,7 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W static const _themeChannel = MethodChannel('com.plezy/theme'); + @visibleForTesting Future setThemeMode(settings.ThemeMode mode) async { if (_themeMode == mode) return; final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance(); diff --git a/lib/providers/trackers_provider.dart b/lib/providers/trackers_provider.dart index 3508adf7..d81dad1a 100644 --- a/lib/providers/trackers_provider.dart +++ b/lib/providers/trackers_provider.dart @@ -62,13 +62,23 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin final MalAuthService _malAuth; final AnilistAuthService _anilistAuth; final SimklAuthService _simklAuth; - final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal); - final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist); - final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl); - TrackerSession? _mal; - TrackerSession? _anilist; - TrackerSession? _simkl; + final _TrackerSlot _mal = _TrackerSlot( + TrackerService.mal, + (session, {required onInvalidated, onUpdated}) => + MalTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated), + ); + final _TrackerSlot _anilist = _TrackerSlot( + TrackerService.anilist, + (session, {required onInvalidated, onUpdated}) => + AnilistTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated), + ); + final _TrackerSlot _simkl = _TrackerSlot( + TrackerService.simkl, + (session, {required onInvalidated, onUpdated}) => + SimklTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated), + ); + late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl]; String _activeUserUuid = ''; int _profileBindingGeneration = 0; @@ -76,22 +86,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin Completer? _cancelCompleter; int _connectGeneration = 0; - // Bumped on every rebind so a late callback from a disposed client (e.g. an - // in-flight MAL token refresh that resolves after a profile switch) can't - // persist or clear a session under the wrong profile, and so a disconnect - // racing an in-flight profile load only suppresses its own service. Mirrors - // TraktAccountProvider's binding-generation guard, but per service. - final _RebindGeneration _malRebind = _RebindGeneration(); - final _RebindGeneration _anilistRebind = _RebindGeneration(); - final _RebindGeneration _simklRebind = _RebindGeneration(); + TrackerSession? get mal => _mal.session; + TrackerSession? get anilist => _anilist.session; + TrackerSession? get simkl => _simkl.session; - TrackerSession? get mal => _mal; - TrackerSession? get anilist => _anilist; - TrackerSession? get simkl => _simkl; - - bool get isMalConnected => _mal != null; - bool get isAnilistConnected => _anilist != null; - bool get isSimklConnected => _simkl != null; + bool get isMalConnected => _mal.session != null; + bool get isAnilistConnected => _anilist.session != null; + bool get isSimklConnected => _simkl.session != null; /// The live MAL client for the Explore catalog, shared with the scrobble /// tracker so both ride one session (MAL rotates refresh tokens — a second @@ -99,17 +100,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// provider's own session so a freshly-mounted profile subtree never sees /// the previous profile's client while its sessions are still loading; /// every rebind is followed by a notify, so proxy consumers track identity. - MalClient? get malCatalogClient => _mal == null ? null : MalTracker.instance.client; + MalClient? get malCatalogClient => _mal.session == null ? null : MalTracker.instance.client; /// Live AniList and Simkl clients for Explore. Like [malCatalogClient], /// these are gated on this provider's profile-bound sessions so a fresh /// profile subtree cannot observe clients still bound to the prior profile. - AnilistClient? get anilistCatalogClient => _anilist == null ? null : AnilistTracker.instance.client; - SimklClient? get simklCatalogClient => _simkl == null ? null : SimklTracker.instance.client; + AnilistClient? get anilistCatalogClient => _anilist.session == null ? null : AnilistTracker.instance.client; + SimklClient? get simklCatalogClient => _simkl.session == null ? null : SimklTracker.instance.client; - String? get malUsername => _mal?.username; - String? get anilistUsername => _anilist?.username; - String? get simklUsername => _simkl?.username; + String? get malUsername => _mal.session?.username; + String? get anilistUsername => _anilist.session?.username; + String? get simklUsername => _simkl.session?.username; bool isConnecting(TrackerService service) => _connecting == service; @@ -131,26 +132,14 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Snapshot each service's rebind generation before the await so a disconnect // that races this load only suppresses its own service (whose generation // moves) rather than dropping the freshly-loaded sessions for the others. - final malRebind = _malRebind.value; - final anilistRebind = _anilistRebind.value; - final simklRebind = _simklRebind.value; - final results = await Future.wait([ - _malStore.load(userUuid), - _anilistStore.load(userUuid), - _simklStore.load(userUuid), - ]); + final rebinds = [for (final slot in _slots) slot.rebindGeneration]; + final results = await Future.wait([for (final slot in _slots) slot.store.load(userUuid)]); if (!_isCurrentProfileBinding(userUuid, generation)) return; - if (_malRebind.value == malRebind) { - _mal = results.first; - _rebindMal(); - } - if (_anilistRebind.value == anilistRebind) { - _anilist = results[1]; - _rebindAnilist(); - } - if (_simklRebind.value == simklRebind) { - _simkl = results[2]; - _rebindSimkl(); + for (var i = 0; i < _slots.length; i++) { + final slot = _slots[i]; + if (slot.rebindGeneration != rebinds[i]) continue; + slot.session = results[i]; + _rebind(slot); } // Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so // the next lookup re-evaluates whether to consult Fribb. @@ -159,78 +148,51 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } Future connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( - service: TrackerService.mal, - alreadyConnected: isMalConnected, + _mal, authorize: () => _malAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichMal, - store: _malStore, - assign: (s) { - _mal = s; - _rebindMal(); - }, ); - Future disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () { - _mal = null; - _rebindMal(); - }); + Future disconnectMal() => _clearAndRebind(_mal); Future connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( - service: TrackerService.anilist, - alreadyConnected: isAnilistConnected, + _anilist, authorize: () => _anilistAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichAnilist, - store: _anilistStore, - assign: (s) { - _anilist = s; - _rebindAnilist(); - }, ); - Future disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () { - _anilist = null; - _rebindAnilist(); - }); + Future disconnectAnilist() => _clearAndRebind(_anilist); Future connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect( - service: TrackerService.simkl, - alreadyConnected: isSimklConnected, + _simkl, authorize: () => _simklAuth.authorize( onCodeReady: onCodeReady, - shouldCancel: () => _cancelCompleter?.isCompleted ?? false, + shouldCancel: _isConnectCancelled, onCancel: _cancelCompleter!.future, ), enrich: _enrichSimkl, - store: _simklStore, - assign: (s) { - _simkl = s; - _rebindSimkl(); - }, ); - Future disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () { - _simkl = null; - _rebindSimkl(); - }); + Future disconnectSimkl() => _clearAndRebind(_simkl); - Future _runConnect({ - required TrackerService service, - required bool alreadyConnected, + bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false; + + Future _runConnect( + _TrackerSlot slot, { required Future Function() authorize, required Future Function(TrackerSession raw) enrich, - required TrackerAccountStore store, - required void Function(TrackerSession session) assign, }) async { - if (isDisposed || _connecting != null || alreadyConnected) return false; + if (isDisposed || _connecting != null || slot.session != null) return false; + final service = slot.service; final userUuid = _activeUserUuid; final generation = ++_connectGeneration; _connecting = service; @@ -248,11 +210,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin enrich: enrich, save: (session) async { if (!_isCurrentConnect(service, userUuid, generation)) return; - await store.save(userUuid, session); + await slot.store.save(userUuid, session); }, assign: (session) { if (!_isCurrentConnect(service, userUuid, generation)) return; - assign(session); + slot.session = session; + _rebind(slot); TrackerCoordinator.instance.invalidateResolverCache(); assigned = true; }, @@ -267,20 +230,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - Future _clearAndRebind( - TrackerService service, - TrackerAccountStore store, - void Function() clearAndRebind, - ) async { - _invalidateConnect(service); + Future _clearAndRebind(_TrackerSlot slot) async { + _invalidateConnect(slot.service); final userUuid = _activeUserUuid; - // `clearAndRebind` bumps the affected service's rebind generation, which is - // what stops an in-flight profile load from resurrecting the cleared - // session — so we no longer touch the shared profile-binding generation - // (which would also abort that load for the other two services). - clearAndRebind(); + // The rebind bumps the affected service's generation, which is what stops + // an in-flight profile load from resurrecting the cleared session — so we + // no longer touch the shared profile-binding generation (which would also + // abort that load for the other two services). + slot.session = null; + _rebind(slot); safeNotifyListeners(); - await store.clear(userUuid); + await slot.store.clear(userUuid); } void _invalidateConnect([TrackerService? service]) { @@ -322,68 +282,33 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin }, ); - /// Snapshot the active profile + bump this service's rebind generation, - /// returning the bound uuid and an `isCurrent` predicate. Bumping here is what - /// lets a stale client callback — or a racing profile load — detect that it - /// has been superseded for this service. - (String, bool Function()) _beginRebind(_RebindGeneration gen) { - final boundUuid = _activeUserUuid; - final generation = gen.bump(); - bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == gen.value; - return (boundUuid, isCurrent); - } - - void _rebindMal() { + /// Push a slot's session to its tracker, snapshotting the active profile and + /// bumping the slot's rebind generation first. Bumping here is what lets a + /// stale client callback — or a racing profile load — detect that it has been + /// superseded for this service. + void _rebind(_TrackerSlot slot) { if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_malRebind); - MalTracker.instance.rebindSession( - _mal, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_malStore, boundUuid, () => _mal = null, _rebindMal); - }, - onSessionUpdated: (next) { + final boundUuid = _activeUserUuid; + final generation = ++slot.rebindGeneration; + bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == slot.rebindGeneration; + slot.bind( + slot.session, + onInvalidated: () { if (!isCurrent()) return; - _mal = next; - _malStore.save(boundUuid, next); + slot.store.clear(boundUuid); + slot.session = null; + _rebind(slot); + safeNotifyListeners(); + }, + onUpdated: (next) { + if (!isCurrent()) return; + slot.session = next; + slot.store.save(boundUuid, next); safeNotifyListeners(); }, ); } - void _rebindAnilist() { - if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_anilistRebind); - AnilistTracker.instance.rebindSession( - _anilist, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_anilistStore, boundUuid, () => _anilist = null, _rebindAnilist); - }, - ); - } - - void _rebindSimkl() { - if (isDisposed) return; - final (boundUuid, isCurrent) = _beginRebind(_simklRebind); - SimklTracker.instance.rebindSession( - _simkl, - onSessionInvalidated: () { - if (isCurrent()) _handleInvalidated(_simklStore, boundUuid, () => _simkl = null, _rebindSimkl); - }, - ); - } - - void _handleInvalidated( - TrackerAccountStore store, - String userUuid, - void Function() clearSession, - void Function() rebind, - ) { - store.clear(userUuid); - clearSession(); - rebind(); - safeNotifyListeners(); - } - @override void dispose() { _invalidateConnect(); @@ -394,12 +319,29 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } -/// A monotonic per-service rebind counter. Each rebind bumps it so a stale -/// client callback — or a profile load that started earlier — can tell it has -/// been superseded for that service. -class _RebindGeneration { - int _value = 0; +/// Pushes a session to one service's tracker singleton. `onUpdated` is only +/// wired for MAL, the one service that rotates its refresh token. +typedef _TrackerBind = + void Function( + TrackerSession? session, { + required void Function() onInvalidated, + void Function(TrackerSession session)? onUpdated, + }); - int bump() => ++_value; - int get value => _value; +/// Owns one service's session, the generation guarding its rebinds, and the +/// adapter that pushes that session to the service's tracker singleton. +class _TrackerSlot { + _TrackerSlot(this.service, this.bind) : store = trackerAccountStore(service); + + final TrackerService service; + final TrackerAccountStore store; + final _TrackerBind bind; + TrackerSession? session; + + /// Bumped on every rebind so a late callback from a disposed client (e.g. an + /// in-flight MAL token refresh that resolves after a profile switch) can't + /// persist or clear a session under the wrong profile, and so a disconnect + /// racing an in-flight profile load only suppresses its own service. Mirrors + /// TraktAccountProvider's binding-generation guard, but per service. + int rebindGeneration = 0; } diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index 385ce3e6..f4cac153 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -11,36 +11,10 @@ import '../services/watch_state_resolver.dart'; import '../utils/global_key_utils.dart'; import '../utils/watch_state_notifier.dart'; -@immutable -class WatchStatePatch { - final bool? isWatched; - final bool hasViewOffsetMs; - final int? viewOffsetMs; - - const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs}); - - factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch( - isWatched: snapshot.isWatched, - hasViewOffsetMs: snapshot.hasViewOffsetMs, - viewOffsetMs: snapshot.viewOffsetMs, - ); - - @override - bool operator ==(Object other) => - identical(this, other) || - other is WatchStatePatch && - other.isWatched == isWatched && - other.hasViewOffsetMs == hasViewOffsetMs && - other.viewOffsetMs == viewOffsetMs; - - @override - int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); -} - @immutable class HydratedWatchStatePatch { final String globalKey; - final WatchStatePatch patch; + final WatchStateSnapshot patch; final int updatedAt; final int order; @@ -53,7 +27,7 @@ class HydratedWatchStatePatch { } class _WatchStatePatchEntry { - final WatchStatePatch patch; + final WatchStateSnapshot patch; final int updatedAt; final int sequence; final bool isSessionEvent; @@ -123,9 +97,10 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin return _exactEntryFor(globalKey); } - WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; + @visibleForTesting + WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; - WatchStatePatch? patchForItem(MediaItem item) { + WatchStateSnapshot? patchForItem(MediaItem item) { var best = _entryFor(item.globalKey); if (item.parentChain.isNotEmpty) { final serverId = serverIdOrNull(item.serverId); @@ -147,14 +122,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin return [for (final item in items) apply(item)]; } - static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) { - if (patch == null) return item; - return WatchStateSnapshot( - isWatched: patch.isWatched, - hasViewOffsetMs: patch.hasViewOffsetMs, - viewOffsetMs: patch.viewOffsetMs, - ).apply(item); - } + static MediaItem applyPatch(MediaItem item, WatchStateSnapshot? patch) => patch == null ? item : patch.apply(item); void setActiveProfileId(String? profileId) { if (_activeProfileId == profileId) return; @@ -216,7 +184,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin ? buildGlobalKey(ServerId(resolvedScope), event.itemId) : event.globalKey; _patches[key] = _WatchStatePatchEntry( - WatchStatePatch.fromSnapshot(snapshot), + snapshot, updatedAt: DateTime.now().millisecondsSinceEpoch, sequence: ++_sequence, isSessionEvent: true, @@ -240,7 +208,7 @@ extension WatchStateResolution on BuildContext { /// ancestor). Use in `build`. MediaItem withFreshWatchState(MediaItem item) { try { - final patch = select((store) => store.patchForItem(item)); + final patch = select((store) => store.patchForItem(item)); return WatchStateStore.applyPatch(item, patch); } on ProviderNotFoundException { return item; diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index e63b9608..9157a8a0 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -7,6 +7,7 @@ import '../media/media_item.dart'; import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../mixins/paginated_item_loader.dart'; +import '../mixins/standard_paginated_view.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_http_client.dart'; import '../utils/provider_extensions.dart'; @@ -48,7 +49,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen with GridFocusNodeMixin, FocusableDetailScreenMixin, - PaginatedItemLoader { + PaginatedItemLoader, + PaginatedItemUpdatable, + StandardPaginatedView { static const int _pageSize = 200; @override @@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen } @override - void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { - for (final entry in loadedItems.entries) { - if (entry.value.globalKey == sourceGlobalKey) { - loadedItems[entry.key] = updatedItem; - return; - } - } - } - - @override - Future loadItems() async { - await loadInitialPaginatedItems( + Future loadItems() { + return loadStandardPaginatedItems( pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, _) { - errorMessage = t.messages.errorLoading(error: error.toString()); - isLoading = false; + errorMessageFor: (error, stackTrace) { + appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace); + return t.messages.errorLoading(error: error.toString()); }, onLoaded: (loadedCount, totalCount) { appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}'); autoFocusFirstItemAfterLoad(); }, - onError: (error, stackTrace) { - appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace); - }, ); } diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index bc15eeb2..06d09a52 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -12,6 +12,7 @@ import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; import '../profiles/profile.dart'; import '../profiles/profile_connection_registry.dart'; +import '../profiles/profile_selection_policy.dart'; import '../services/plex_auth_service.dart'; import '../services/settings_service.dart'; import '../services/storage_service.dart'; @@ -196,8 +197,7 @@ class _AuthScreenState extends State { activeProfile: activeProfiles.active, hasProfiles: activeProfiles.profiles.isNotEmpty, accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true, - requireProfileSelectionOnOpen: - settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles, + requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings), ); if (promptHandled) { final selected = await Navigator.of( diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index 63d7a30b..e3a91392 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -1,11 +1,10 @@ import 'package:flutter/material.dart'; import '../media/ids.dart'; -import 'package:plezy/widgets/app_icon.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../media/media_item.dart'; import '../media/media_playlist.dart'; import '../media/media_server_client.dart'; +import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; import '../utils/provider_extensions.dart'; import '../services/media_list_playback_launcher.dart'; @@ -43,19 +42,34 @@ abstract class BaseMediaListDetailScreen extends State /// Optional icon to show when list is empty IconData? get emptyIcon => null; + /// Server the displayed item was tagged with, if any. + String? get _mediaItemServerId => switch (mediaItem) { + MediaItem(:final serverId) => serverId, + MediaPlaylist(:final serverId) => serverId, + _ => null, + }; + + /// Sync-rule global key for the displayed collection/playlist, keyed to the + /// item's own server when it has one and to the resolved client's otherwise. + String get syncRuleKey { + final client = mediaClient; + final id = switch (mediaItem) { + MediaItem(:final id) => id, + MediaPlaylist(:final id) => id, + _ => '', + }; + return context.read().syncRuleKeyForClient( + client, + id, + serverId: ServerId(_mediaItemServerId ?? client.serverId), + ); + } + String? _resolveMediaItemServerId() { - final item = mediaItem; - String? serverId; - if (item is MediaItem) { - serverId = item.serverId; - } else if (item is MediaPlaylist) { - serverId = item.serverId; - } - if (serverId == null) { - final multiServerProvider = Provider.of(context, listen: false); - serverId = multiServerProvider.onlineServerIds.firstOrNull; - } - return serverId; + final serverId = _mediaItemServerId; + if (serverId != null) return serverId; + final multiServerProvider = Provider.of(context, listen: false); + return multiServerProvider.onlineServerIds.firstOrNull; } MediaServerClient _getMediaClientForMediaItem() { @@ -133,40 +147,6 @@ abstract class BaseMediaListDetailScreen extends State return []; } - - /// Build standard app bar actions (play, shuffle, delete) - /// Subclasses can override to customize actions - List buildAppBarActions({ - VoidCallback? onDelete, - String? deleteTooltip, - Color? deleteColor, - bool showDelete = true, - }) { - return [ - // Play button - if (items.isNotEmpty) - IconButton( - icon: const AppIcon(Symbols.play_arrow_rounded, fill: 1), - tooltip: t.common.play, - onPressed: playItems, - ), - // Shuffle button - if (items.isNotEmpty) - IconButton( - icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), - tooltip: t.common.shuffle, - onPressed: shufflePlayItems, - ), - // Delete button - if (showDelete && onDelete != null) - IconButton( - icon: const AppIcon(Symbols.delete_rounded, fill: 1), - tooltip: deleteTooltip ?? t.common.delete, - onPressed: onDelete, - color: deleteColor ?? Colors.red, - ), - ]; - } } /// Mixin that provides standard loadItems implementation for media lists diff --git a/lib/screens/catalog_search_screen.dart b/lib/screens/catalog_search_screen.dart index 072801a1..9ce54d8d 100644 --- a/lib/screens/catalog_search_screen.dart +++ b/lib/screens/catalog_search_screen.dart @@ -1,19 +1,16 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../focus/focusable_text_field.dart'; -import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../mixins/debounced_media_search.dart'; import '../services/catalog/catalog_source.dart'; import '../utils/focus_utils.dart'; import '../utils/platform_detector.dart'; -import '../widgets/app_icon.dart'; import '../widgets/focusable_media_card.dart'; import '../widgets/focused_scroll_scaffold.dart'; import '../widgets/loading_indicator_box.dart'; -import '../widgets/pill_input_decoration.dart'; +import '../widgets/search_input_field.dart'; import 'libraries/state_messages.dart'; /// Free-text search of one catalog source (the Explore tab's active source), @@ -30,7 +27,6 @@ class CatalogSearchScreen extends StatefulWidget { } class _CatalogSearchScreenState extends State with DebouncedMediaSearch { - final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear'); @override String get searchDebugLabel => 'CatalogSearch'; @@ -46,17 +42,6 @@ class _CatalogSearchScreenState extends State with Debounce FocusUtils.requestFocusAfterBuild(this, searchFocusNode); } - @override - void dispose() { - _clearFocusNode.dispose(); - super.dispose(); - } - - void _clearSearch() { - searchController.clear(); - searchFocusNode.requestFocus(); - } - @override Widget build(BuildContext context) { final sourceName = widget.source.displayName; @@ -64,36 +49,13 @@ class _CatalogSearchScreenState extends State with Debounce title: Text(t.explore.searchHint(source: sourceName)), slivers: [ SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - child: Stack( - alignment: Alignment.centerRight, - children: [ - FocusableTextField( - controller: searchController, - focusNode: searchFocusNode, - textInputAction: TextInputAction.search, - onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, - onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, - onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, - decoration: pillInputDecoration( - context, - hintText: t.explore.searchHint(source: sourceName), - prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null, - ), - ), - if (searchController.text.isNotEmpty) - FocusableButton( - focusNode: _clearFocusNode, - onPressed: _clearSearch, - onNavigateLeft: searchFocusNode.requestFocus, - onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, - autoScroll: false, - child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch), - ), - ], - ), + child: SearchInputField( + controller: searchController, + focusNode: searchFocusNode, + debugLabel: searchDebugLabel, + hintText: t.explore.searchHint(source: sourceName), + onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, + onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, ), ), if (isSearching) @@ -125,26 +87,16 @@ class _CatalogSearchScreenState extends State with Debounce } Widget _buildResultsList() { - return SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = searchResults[index]; - return FocusableMediaCard( - key: Key(item.globalKey), - item: item, - forceListMode: true, - disableScale: true, - focusNode: index == 0 ? firstResultFocusNode : null, - onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null, - ); - }, - childCount: searchResults.length, - addAutomaticKeepAlives: false, - addSemanticIndexes: false, - ), - ), - ); + return buildResultsSliver((context, index) { + final item = searchResults[index]; + return FocusableMediaCard( + key: Key(item.globalKey), + item: item, + forceListMode: true, + disableScale: true, + focusNode: index == 0 ? firstResultFocusNode : null, + onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null, + ); + }); } } diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index e79b48f3..f010bedf 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,17 +1,17 @@ import 'package:flutter/material.dart'; -import '../media/ids.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../focus/focusable_action_bar.dart'; import '../media/library_query.dart'; import '../media/media_item.dart'; import '../mixins/paginated_item_loader.dart'; +import '../mixins/standard_paginated_view.dart'; import '../providers/download_provider.dart'; import '../utils/app_logger.dart'; +import '../utils/content_utils.dart'; import '../utils/dialogs.dart'; import '../utils/error_message_utils.dart'; import '../utils/download_utils.dart'; -import '../utils/platform_detector.dart'; import '../utils/media_server_http_client.dart'; import '../utils/snackbar_helper.dart'; import '../widgets/desktop_app_bar.dart'; @@ -35,7 +35,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen, FocusableDetailScreenMixin, - PaginatedItemLoader { + PaginatedItemLoader, + PaginatedItemUpdatable, + StandardPaginatedView { static const int _pageSize = 200; @override @@ -75,49 +77,21 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( + Future loadItems() { + return loadStandardPaginatedItems( pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, stackTrace) { - errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: t.collections.collection); - isLoading = false; - }, + errorMessageFor: (error, stackTrace) => + localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection), onLoaded: (loadedCount, totalCount) { appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}'); autoFocusFirstItemAfterLoad(); }, - onError: (error, stackTrace) { - loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection); - }, ); } @override List getAppBarActions() { - final ruleKey = _collectionSyncRuleKey(); + final ruleKey = syncRuleKey; // Select the specific bool we care about so unrelated DownloadProvider // ticks (e.g. active download progress) don't rebuild the app bar. final hasRule = context.select((p) => p.hasSyncRule(ruleKey)); @@ -127,19 +101,16 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _manageCollectionSyncRule() => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _collectionSyncRuleKey()); - - Future _removeCollectionSyncRule() => removeSyncRuleAndSnack( - context, - downloadProvider: context.read(), - globalKey: _collectionSyncRuleKey(), - displayTitle: widget.collection.displayTitle, - ); - - String _collectionSyncRuleKey() { - final serverId = widget.collection.serverId ?? mediaClient.serverId; - return context.read().syncRuleKeyForClient( - mediaClient, - widget.collection.id, - serverId: ServerId(serverId), - ); - } - Future _deleteCollection() async { final confirmed = await showDeleteConfirmation( context, diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index d5aebd88..4b4f0d9b 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -21,7 +21,7 @@ import '../utils/media_image_helper.dart'; import '../utils/content_utils.dart'; import '../widgets/cycling_media_backdrop.dart'; import '../widgets/optimized_media_image.dart' show ClearLogoImage, blurArtwork; -import '../widgets/rasterized_gradient.dart'; +import '../widgets/toolbar_scrim.dart'; import '../providers/discover_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/watch_state_store.dart'; @@ -47,6 +47,7 @@ import '../i18n/strings.g.dart'; import '../utils/app_logger.dart'; import '../utils/dialogs.dart'; import '../utils/formatters.dart'; +import '../utils/hub_icons.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/provider_extensions.dart'; import '../utils/video_player_navigation.dart'; @@ -176,17 +177,7 @@ class _DiscoverScreenState extends State if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!; final hubs = []; if (_onDeck.isNotEmpty) { - hubs.add( - MediaHub( - id: 'continue_watching', - title: t.discover.continueWatching, - type: 'mixed', - identifier: '_continue_watching_', - size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), - more: _hasMoreContinueWatching, - items: _onDeck, - ), - ); + hubs.add(_continueWatchingHub); } hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty)); _tvBrowseHubsCache = hubs; @@ -194,6 +185,18 @@ class _DiscoverScreenState extends State return hubs; } + /// The synthesized Continue Watching row, rendered ahead of the backend hubs + /// on both the mobile list and the TV rail. + MediaHub get _continueWatchingHub => MediaHub( + id: 'continue_watching', + title: t.discover.continueWatching, + type: 'mixed', + identifier: '_continue_watching_', + size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), + more: _hasMoreContinueWatching, + items: _onDeck, + ); + void _setSpotlightItem(MediaItem item) => _spotlight.select(item); void _scrollToTop() { @@ -597,101 +600,6 @@ class _DiscoverScreenState extends State unawaited(_discover.load()); } - /// Get icon for hub based on its title - IconData _getHubIcon(String title) { - final lowerTitle = title.toLowerCase(); - - // Trending/Popular content - if (lowerTitle.contains('trending')) { - return Symbols.trending_up_rounded; - } - if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) { - return Symbols.whatshot_rounded; - } - - // Seasonal/Time-based - if (lowerTitle.contains('seasonal')) { - return Symbols.calendar_month_rounded; - } - if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) { - return Symbols.new_releases_rounded; - } - if (lowerTitle.contains('recently released') || lowerTitle.contains('recent')) { - return Symbols.schedule_rounded; - } - - // Top/Rated content - if (lowerTitle.contains('top rated') || lowerTitle.contains('highest rated')) { - return Symbols.star_rounded; - } - if (lowerTitle.contains('top ')) { - return Symbols.military_tech_rounded; - } - - // Genre-specific - if (lowerTitle.contains('thriller')) { - return Symbols.warning_amber_rounded; - } - if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) { - return Symbols.mood_rounded; - } - if (lowerTitle.contains('action')) { - return Symbols.flash_on_rounded; - } - if (lowerTitle.contains('drama')) { - return Symbols.theater_comedy_rounded; - } - if (lowerTitle.contains('fantasy')) { - return Symbols.auto_fix_high_rounded; - } - if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) { - return Symbols.rocket_launch_rounded; - } - if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) { - return Symbols.nights_stay_rounded; - } - if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) { - return Symbols.favorite_border_rounded; - } - if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) { - return Symbols.explore_rounded; - } - - // Watchlist/Playlists - if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) { - return Symbols.playlist_play_rounded; - } - if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) { - return Symbols.visibility_off_rounded; - } - if (lowerTitle.contains('watched') || lowerTitle.contains('played')) { - return Symbols.visibility_rounded; - } - - // Network/Studio - if (lowerTitle.contains('network') || lowerTitle.contains('more from')) { - return Symbols.tv_rounded; - } - - // Actor/Director - if (lowerTitle.contains('actor') || lowerTitle.contains('director')) { - return Symbols.person_rounded; - } - - // Year-based (80s, 90s, etc.) - if (lowerTitle.contains('80') || lowerTitle.contains('90') || lowerTitle.contains('00')) { - return Symbols.history_rounded; - } - - // Rediscover/Start Watching - if (lowerTitle.contains('rediscover') || lowerTitle.contains('start watching')) { - return Symbols.play_arrow_rounded; - } - - // Default icon for other hubs - return Symbols.auto_awesome_rounded; - } - /// Whether the loaded hubs span more than one connected server. bool _hubsSpanMultipleServers() { final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet(); @@ -815,153 +723,125 @@ class _DiscoverScreenState extends State } Widget _buildOverlaidAppBar() { - final statusBarHeight = MediaQuery.paddingOf(context).top; final colorScheme = Theme.of(context).colorScheme; - final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; final foregroundColor = colorScheme.onSurface; - return RasterizedGradient( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.5), - overlayColor.withValues(alpha: 0.3), - Colors.transparent, - ], - stops: const [0.0, 0.3, 0.6, 1.0], - ), - child: Padding( - padding: .only(top: statusBarHeight, left: 16, right: 16, bottom: 8), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - children: [ - if (!PlatformDetector.isTV()) - Text( - t.discover.title, - style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), - ), - const Spacer(), - Consumer2( - builder: (context, watchTogether, companionRemote, _) { - final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); + return ToolbarScrim( + child: Row( + children: [ + if (!PlatformDetector.isTV()) + Text( + t.discover.title, + style: Theme.of(context).textTheme.titleLarge?.copyWith(color: foregroundColor, fontWeight: .bold), + ), + const Spacer(), + Consumer2( + builder: (context, watchTogether, companionRemote, _) { + final isDesktop = PlatformDetector.shouldActAsRemoteHost(context); - return FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: _navigateToSidebar, - onNavigateDown: _focusContentFromAppBar, - actions: [ - FocusableAction( - icon: Symbols.refresh_rounded, - iconColor: foregroundColor, - onPressed: _discover.load, - ), - // Watch Together - FocusableAction( - onPressed: () => - Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.group_rounded, - fill: watchTogether.isInSession ? 1 : 0, - color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, + return FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _focusContentFromAppBar, + actions: [ + FocusableAction(icon: Symbols.refresh_rounded, iconColor: foregroundColor, onPressed: _discover.load), + // Watch Together + FocusableAction( + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.group_rounded, + fill: watchTogether.isInSession ? 1 : 0, + color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, + ), + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + tooltip: t.watchTogether.title, + ), + if (watchTogether.isInSession && watchTogether.participantCount > 1) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: colorScheme.primary, + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - onPressed: () => Navigator.push( + child: Text( + '${watchTogether.participantCount}', + style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), + ), + ), + ), + ], + ), + ), + // Companion Remote + FocusableAction( + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen())); + } + }, + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.phone_android_rounded, + fill: companionRemote.isConnected ? 1 : 0, + color: companionRemote.isConnected ? colorScheme.primary : foregroundColor, + ), + onPressed: () { + if (isDesktop) { + RemoteSessionDialog.show(context); + } else { + Navigator.push( context, - MaterialPageRoute(builder: (_) => const WatchTogetherScreen()), + MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), + ); + } + }, + tooltip: t.companionRemote.title, + ), + if (companionRemote.isConnected) + Positioned( + top: 6, + right: 6, + child: Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: Colors.green, + shape: BoxShape.circle, + border: Border.fromBorderSide(BorderSide(color: foregroundColor, width: 1)), ), - tooltip: t.watchTogether.title, ), - if (watchTogether.isInSession && watchTogether.participantCount > 1) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: colorScheme.primary, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Text( - '${watchTogether.participantCount}', - style: TextStyle(color: colorScheme.onPrimary, fontSize: 10, fontWeight: .bold), - ), - ), - ), - ], - ), - ), - // Companion Remote - FocusableAction( - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), - ); - } - }, - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.phone_android_rounded, - fill: companionRemote.isConnected ? 1 : 0, - color: companionRemote.isConnected ? colorScheme.primary : foregroundColor, - ), - onPressed: () { - if (isDesktop) { - RemoteSessionDialog.show(context); - } else { - Navigator.push( - context, - MaterialPageRoute(builder: (context) => const MobileRemoteScreen()), - ); - } - }, - tooltip: t.companionRemote.title, - ), - if (companionRemote.isConnected) - Positioned( - top: 6, - right: 6, - child: Container( - width: 8, - height: 8, - decoration: BoxDecoration( - color: Colors.green, - shape: BoxShape.circle, - border: Border.fromBorderSide(BorderSide(color: foregroundColor, width: 1)), - ), - ), - ), - ], - ), - ), - // Server Tasks — Plex-only (`/activities` API has no - // Jellyfin equivalent), hide the button entirely on - // Jellyfin-only profiles so the chrome doesn't show - // a permanently empty popover. - if (PlatformDetector.isDesktop(context) && - context.select((p) => p.hasOnlinePlexServers)) - FocusableAction( - onPressed: () => _serverActivitiesButtonKey.currentState?.togglePanel(), - child: ServerActivitiesButton(key: _serverActivitiesButtonKey), - ), - // User menu — profiles + sign out - _buildUserMenuAction(context), - ], - ); - }, - ), - ], + ), + ], + ), + ), + // Server Tasks — Plex-only (`/activities` API has no + // Jellyfin equivalent), hide the button entirely on + // Jellyfin-only profiles so the chrome doesn't show + // a permanently empty popover. + if (PlatformDetector.isDesktop(context) && + context.select((p) => p.hasOnlinePlexServers)) + FocusableAction( + onPressed: () => _serverActivitiesButtonKey.currentState?.togglePanel(), + child: ServerActivitiesButton(key: _serverActivitiesButtonKey), + ), + // User menu — profiles + sign out + _buildUserMenuAction(context), + ], + ); + }, ), - ), + ], ), ); } @@ -993,6 +873,7 @@ class _DiscoverScreenState extends State final bottomPadding = MediaQuery.paddingOf(context).bottom; final theme = Theme.of(context); + final continueWatchingHub = _onDeck.isEmpty ? null : _continueWatchingHub; return Material( color: theme.scaffoldBackgroundColor, child: Stack( @@ -1016,21 +897,13 @@ class _DiscoverScreenState extends State if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load), if (!_isLoading && _errorMessage == null) ...[ // On Deck / Continue Watching - if (_onDeck.isNotEmpty) + if (continueWatchingHub != null) SliverToBoxAdapter( child: HubSection( key: _continueWatchingHubKey, - hub: MediaHub( - id: 'continue_watching', - title: t.discover.continueWatching, - type: 'mixed', - identifier: '_continue_watching_', - size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), - more: _hasMoreContinueWatching, - items: _onDeck, - ), + hub: continueWatchingHub, focusMemory: _hubFocusMemory, - icon: Symbols.play_circle_rounded, + icon: hubIconFor(continueWatchingHub), onRefresh: _discover.updateItem, onRemoveFromContinueWatching: _discover.refreshContinueWatching, isInContinueWatching: true, @@ -1048,7 +921,7 @@ class _DiscoverScreenState extends State key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null, hub: _hubs[i], focusMemory: _hubFocusMemory, - icon: _getHubIcon(_hubs[i].title), + icon: hubIconFor(_hubs[i]), showServerName: showServerNameOnHubs || hubsSpanMultipleServers, onRefresh: _discover.updateItem, // Hub index is i + 1 if continue watching exists, otherwise i @@ -1135,7 +1008,7 @@ class _DiscoverScreenState extends State initialHubId: 'continue_watching', focusMemory: _hubFocusMemory, showServerName: showServerName, - iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), + iconForHub: (hub, _) => hubIconFor(hub), onFocusedItemChanged: _setSpotlightItem, onRefresh: _discover.updateItem, onRemoveFromContinueWatching: _discover.refreshContinueWatching, @@ -1155,7 +1028,6 @@ class _DiscoverScreenState extends State final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); final hubsSpanMultipleServers = _hubsSpanMultipleServers(); final browseHubs = _tvBrowseHubs; - final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); return TvSpotlightScaffold( hubs: browseHubs, @@ -1189,14 +1061,7 @@ class _DiscoverScreenState extends State bottom: 0, child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers), ), - Builder( - builder: (context) => SideNavigationBleedBuilder( - targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), - child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()), - builder: (context, animatedBleed, child) => - Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), - ), - ), + TvToolbarOverlay(child: _buildOverlaidAppBar()), if (_switchingProfile) const ProfileSwitchingOverlay(), ], ), diff --git a/lib/screens/explore_screen.dart b/lib/screens/explore_screen.dart index bce2f485..32931716 100644 --- a/lib/screens/explore_screen.dart +++ b/lib/screens/explore_screen.dart @@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/hub_section.dart'; import '../widgets/focusable_popup_menu_button.dart'; import '../widgets/settings_builder.dart'; -import '../widgets/rasterized_gradient.dart'; +import '../widgets/toolbar_scrim.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_scaffold.dart'; import 'catalog_search_screen.dart'; @@ -332,75 +332,57 @@ class ExploreScreenState extends State Widget _buildTvToolbar(CatalogSourcesProvider sources) { final active = sources.activeSource; - final statusBarHeight = MediaQuery.paddingOf(context).top; - final colorScheme = Theme.of(context).colorScheme; - final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; - final foregroundColor = colorScheme.onSurface; + final foregroundColor = Theme.of(context).colorScheme.onSurface; - return RasterizedGradient( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.5), - overlayColor.withValues(alpha: 0.3), - Colors.transparent, - ], - stops: const [0.0, 0.3, 0.6, 1.0], - ), - child: Padding( - padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16), - child: Row( - children: [ - const Spacer(), - FocusableActionBar( - key: _actionBarKey, - onNavigateLeft: _navigateToSidebar, - onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus, - onBack: _navigateToSidebar, - spacing: 4, - actions: [ - if (active != null && sources.connectedSources.length > 1) - FocusableAction( - debugLabel: 'ExploreSourceSwitcher', - onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true), - child: _buildSourceSwitcher( - sources, - active, - textStyle: Theme.of( - context, - ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), - anchorAlignment: AppMenuAnchorAlignment.end, - parentOwnsFocus: true, - ), - ), - if (active != null) - FocusableAction( - icon: Symbols.search_rounded, - iconColor: foregroundColor, - tooltip: t.common.search, - onPressed: () => Navigator.of( - context, - ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: active))), - ), + return ToolbarScrim( + child: Row( + children: [ + const Spacer(), + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: _tvBrowseRailKey.currentState?.requestFocus, + onBack: _navigateToSidebar, + spacing: 4, + actions: [ + if (active != null && sources.connectedSources.length > 1) FocusableAction( - icon: Symbols.refresh_rounded, - iconColor: foregroundColor, - tooltip: t.common.refresh, - onPressed: () => unawaited(_explore.load()), + debugLabel: 'ExploreSourceSwitcher', + onPressed: () => _sourceMenuKey.currentState?.showButtonMenu(focusFirstItem: true), + child: _buildSourceSwitcher( + sources, + active, + textStyle: Theme.of( + context, + ).textTheme.titleMedium?.copyWith(color: foregroundColor, fontWeight: .w600), + anchorAlignment: AppMenuAnchorAlignment.end, + parentOwnsFocus: true, + ), ), - ], - ), - ], - ), + if (active != null) + FocusableAction( + icon: Symbols.search_rounded, + iconColor: foregroundColor, + tooltip: t.common.search, + onPressed: () => Navigator.of( + context, + ).push(MaterialPageRoute(builder: (_) => CatalogSearchScreen(source: active))), + ), + FocusableAction( + icon: Symbols.refresh_rounded, + iconColor: foregroundColor, + tooltip: t.common.refresh, + onPressed: () => unawaited(_explore.load()), + ), + ], + ), + ], ), ); } Widget _buildTvContent(List rowHubs, CatalogSourcesProvider sources) { final tvHubs = [for (final rowHub in rowHubs) rowHub.hub]; - final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); return TvSpotlightScaffold( hubs: tvHubs, spotlightListenable: _spotlight, @@ -448,14 +430,7 @@ class ExploreScreenState extends State tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ), ), - Builder( - builder: (context) => SideNavigationBleedBuilder( - targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), - child: ExcludeFocusTraversal(child: _buildTvToolbar(sources)), - builder: (context, animatedBleed, child) => - Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), - ), - ), + TvToolbarOverlay(child: _buildTvToolbar(sources)), ], ), ); diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 02c7bc9c..ff784071 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -4,7 +4,6 @@ import '../focus/input_mode_tracker.dart'; import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; -import '../media/media_playlist.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../services/settings_service.dart'; import '../utils/platform_detector.dart'; @@ -15,14 +14,6 @@ import '../widgets/media_card_sliver_layout.dart'; import '../widgets/overlay_sheet.dart'; import '../widgets/skeleton_media_card.dart'; -/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a -/// Flutter widget Key. -String _idForItem(Object item) { - if (item is MediaItem) return item.id; - if (item is MediaPlaylist) return item.id; - return identityHashCode(item).toString(); -} - /// Mixin that provides common focus navigation functionality for detail screens. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. /// @@ -164,56 +155,27 @@ mixin FocusableDetailScreenMixin on State, GridFocu /// Used by collection, smart playlist, and music artist detail screens. /// [shape] overrides the grid cell silhouette (e.g. [CardShape.square] /// for album grids); null keeps the stock poster geometry. + /// + /// Fully-loaded case of [buildSparseFocusableGrid]: every slot resolves to an + /// item, so the skeleton branch is unreachable. Widget buildFocusableGrid({ - required List items, + required List items, required void Function(MediaItem source) onRefresh, String? collectionId, VoidCallback? onListRefresh, CardShape? shape, }) { - return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], - builder: (context) { - final svc = SettingsService.instance; - final viewMode = svc.read(SettingsService.viewMode); - final libraryDensity = svc.read(SettingsService.libraryDensity); - final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); - final useFullCardLayout = fullCardLayout && shape != CardShape.square; - - return MediaCardSliverLayout( - viewMode: viewMode, - itemCount: items.length, - density: libraryDensity, - padding: const EdgeInsets.all(8), - fullBleedImage: useFullCardLayout, - shape: shape, - itemBuilder: (context, position) { - final index = position.index; - final item = items[index]; - final focusNode = _focusNodeForIndex(index); - - return FocusableMediaCard( - key: Key(_idForItem(item)), - item: item, - focusNode: focusNode, - semanticValue: _semanticPosition(position), - disableScale: position.disableScale, - onRefresh: onRefresh, - collectionId: collectionId, - onListRefresh: onListRefresh, - fullBleedImage: useFullCardLayout && position.isGrid, - cardShapeOverride: shape, - onNavigateUp: position.isFirstRow ? navigateToAppBar : null, - onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), - ); - }, - ); - }, + return buildSparseFocusableGrid( + totalItems: items.length, + itemAt: (index) => items[index], + onRefresh: onRefresh, + collectionId: collectionId, + onListRefresh: onListRefresh, + shape: shape, ); } - /// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems] + /// Sparse-loading counterpart of [buildFocusableGrid]. Renders [totalItems] /// slots; for each, [itemAt] returns the loaded item or null if not yet /// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so /// the caller can kick off a page fetch containing that index. @@ -242,7 +204,7 @@ mixin FocusableDetailScreenMixin on State, GridFocu onSkeletonVisible?.call(index); return const SkeletonMediaCard(); } - final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item'); + final focusNode = _focusNodeForIndex(index); return FocusableMediaCard( key: Key(item.id), item: item, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 2334ca08..1aaf4242 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; import '../widgets/overlay_sheet.dart'; import '../focus/focusable_action_bar.dart'; -import '../focus/focusable_button.dart'; import '../focus/key_event_utils.dart'; import '../mixins/grid_focus_node_mixin.dart'; import '../mixins/paginated_item_loader.dart'; @@ -493,34 +492,6 @@ class _HubDetailScreenState extends State Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error; bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading; - Widget _buildContinuationStatusSliver() { - final exception = _pageLoadError; - final error = exception == null ? null : t.messages.errorLoading(error: exception.toString()); - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.all(24), - child: Center( - child: error == null - ? const CircularProgressIndicator() - : Column( - mainAxisSize: .min, - children: [ - Text(error, textAlign: TextAlign.center), - const SizedBox(height: 8), - FocusableButton( - focusNode: _continuationRetryFocusNode, - onPressed: _retryHubContinuation, - onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), - onBack: handleBackFromContent, - child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)), - ), - ], - ), - ), - ), - ); - } - @override void refresh() { _loadMoreItems(); @@ -633,7 +604,13 @@ class _HubDetailScreenState extends State }, ), if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null)) - _buildContinuationStatusSliver(), + ContinuationStatusSliver( + error: _pageLoadError, + onRetry: _retryHubContinuation, + retryFocusNode: _continuationRetryFocusNode, + onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), + onBack: handleBackFromContent, + ), ], ), ), diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index d0c7f2d1..22e3e2f2 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; +import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import 'state_messages.dart'; @@ -10,9 +11,7 @@ class SliverErrorState extends StatelessWidget { final String? retryLabel; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; final bool actionAutofocus; final bool actionUseBackgroundFocus; @@ -24,9 +23,7 @@ class SliverErrorState extends StatelessWidget { this.retryLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionAutofocus = false, this.actionUseBackgroundFocus = false, @@ -41,9 +38,7 @@ class SliverErrorState extends StatelessWidget { retryLabel: retryLabel, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, actionAutofocus: actionAutofocus, actionUseBackgroundFocus: actionUseBackgroundFocus, @@ -61,9 +56,7 @@ class SliverEmptyState extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const SliverEmptyState({ @@ -76,9 +69,7 @@ class SliverEmptyState extends StatelessWidget { this.actionIcon, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, }); @@ -93,14 +84,61 @@ class SliverEmptyState extends StatelessWidget { actionIcon: actionIcon, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, ), ); } +/// Footer sliver for continuation (append-to-list) pagination: a spinner while +/// the next page loads, or the error message with a focusable retry button. +class ContinuationStatusSliver extends StatelessWidget { + /// Failure from the last page load; null while the page is still loading. + final Object? error; + final VoidCallback onRetry; + final FocusNode retryFocusNode; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; + + const ContinuationStatusSliver({ + super.key, + required this.error, + required this.onRetry, + required this.retryFocusNode, + this.onNavigateUp, + this.onBack, + }); + + @override + Widget build(BuildContext context) { + final exception = error; + final message = exception == null ? null : t.messages.errorLoading(error: exception.toString()); + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: message == null + ? const CircularProgressIndicator() + : Column( + mainAxisSize: .min, + children: [ + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 8), + FocusableButton( + focusNode: retryFocusNode, + onPressed: onRetry, + onNavigateUp: onNavigateUp, + onBack: onBack, + child: TextButton(onPressed: onRetry, child: Text(t.common.retry)), + ), + ], + ), + ), + ), + ); + } +} + /// A widget that handles loading, error, empty, and content states /// Provides a consistent UI pattern across the app for data-driven screens class ContentStateBuilder extends StatelessWidget { diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index bfde4f39..f08ca328 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -6,6 +6,7 @@ import '../../media/media_item.dart'; import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../services/jellyfin_sequential_launcher.dart'; +import '../../services/media_list_playback_launcher.dart'; import '../../services/play_queue_launcher.dart'; import '../../utils/app_logger.dart'; import '../../utils/error_message_utils.dart'; @@ -243,42 +244,19 @@ class FolderTreeViewState extends State { } } - Future _handleFolderPlay(MediaItem folder) async { + /// Play (or shuffle) a folder row through the backend's launcher. Built + /// here rather than via [MediaListPlaybackLauncher.forItem] because this + /// tree is pinned to one server: the Plex client must be the one backing + /// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution. + Future _launchFolder(MediaItem folder, {required bool shuffle}) async { + final MediaListPlaybackLauncher launcher; if (folder.backend == MediaBackend.jellyfin) { - final launcher = JellyfinSequentialLauncher(context: context); - await launcher.launchFromFolder(folder: folder, shuffle: false); - return; + launcher = JellyfinSequentialLauncher(context: context); + } else { + final client = context.getPlexClientForServer(ServerId(widget.serverId!)); + launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); } - - final folderKey = folder.backendFolderKey; - if (folderKey == null) return; - final client = context.getPlexClientForServer(ServerId(widget.serverId!)); - final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder( - folderKey: folderKey, - shuffle: false, - libraryId: folder.libraryId, - libraryTitle: folder.libraryTitle, - ); - } - - Future _handleFolderShuffle(MediaItem folder) async { - if (folder.backend == MediaBackend.jellyfin) { - final launcher = JellyfinSequentialLauncher(context: context); - await launcher.launchFromFolder(folder: folder, shuffle: true); - return; - } - - final folderKey = folder.backendFolderKey; - if (folderKey == null) return; - final client = context.getPlexClientForServer(ServerId(widget.serverId!)); - final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder( - folderKey: folderKey, - shuffle: true, - libraryId: folder.libraryId, - libraryTitle: folder.libraryTitle, - ); + await launcher.launchFromFolder(folder: folder, shuffle: shuffle); } /// Expandable rows: directory rows plus Jellyfin media containers whose @@ -400,8 +378,8 @@ class FolderTreeViewState extends State { serverId: widget.serverId, onExpand: isExpandable ? () => _toggleFolder(item) : null, onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null, - onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null, - onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, + onPlayAll: canPlayFolder ? () => _launchFolder(item, shuffle: false) : null, + onShuffle: canPlayFolder ? () => _launchFolder(item, shuffle: true) : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, onNavigateLeft: widget.onNavigateLeft, diff --git a/lib/screens/libraries/state_messages.dart b/lib/screens/libraries/state_messages.dart index cf6bf3fe..a1b113fe 100644 --- a/lib/screens/libraries/state_messages.dart +++ b/lib/screens/libraries/state_messages.dart @@ -39,9 +39,7 @@ class StateMessageWidget extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; /// Whether the action button should request focus when it appears. @@ -63,9 +61,7 @@ class StateMessageWidget extends StatelessWidget { this.actionLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionIcon, this.actionAutofocus = false, @@ -110,9 +106,7 @@ class StateMessageWidget extends StatelessWidget { FocusableButton( focusNode: actionFocusNode, onNavigateUp: onActionNavigateUp, - onNavigateDown: onActionNavigateDown, onNavigateLeft: onActionNavigateLeft, - onNavigateRight: onActionNavigateRight, onBack: onActionBack, onPressed: onAction, autofocus: actionAutofocus, @@ -155,9 +149,7 @@ class EmptyStateWidget extends StatelessWidget { final IconData? actionIcon; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const EmptyStateWidget({ @@ -171,9 +163,7 @@ class EmptyStateWidget extends StatelessWidget { this.actionIcon, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, }); @@ -189,9 +179,7 @@ class EmptyStateWidget extends StatelessWidget { actionIcon: actionIcon ?? Symbols.add_rounded, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, ); } @@ -218,9 +206,7 @@ class ErrorStateWidget extends StatelessWidget { final String? retryLabel; final FocusNode? actionFocusNode; final VoidCallback? onActionNavigateUp; - final VoidCallback? onActionNavigateDown; final VoidCallback? onActionNavigateLeft; - final VoidCallback? onActionNavigateRight; final VoidCallback? onActionBack; const ErrorStateWidget({ @@ -231,9 +217,7 @@ class ErrorStateWidget extends StatelessWidget { this.retryLabel, this.actionFocusNode, this.onActionNavigateUp, - this.onActionNavigateDown, this.onActionNavigateLeft, - this.onActionNavigateRight, this.onActionBack, this.actionAutofocus = false, this.actionUseBackgroundFocus = false, @@ -251,9 +235,7 @@ class ErrorStateWidget extends StatelessWidget { actionIcon: Symbols.refresh_rounded, actionFocusNode: actionFocusNode, onActionNavigateUp: onActionNavigateUp, - onActionNavigateDown: onActionNavigateDown, onActionNavigateLeft: onActionNavigateLeft, - onActionNavigateRight: onActionNavigateRight, onActionBack: onActionBack, actionAutofocus: actionAutofocus, actionUseBackgroundFocus: actionUseBackgroundFocus, diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index be58fff8..79e2583c 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -216,6 +216,20 @@ abstract class BaseLibraryTabState> extends State } } + /// Post-load bookkeeping for tabs that replace [loadItems] with their own + /// (paginated) fetch: mark the tab loaded, take focus if it's due, and let + /// the parent know once the frame carrying the items is in. + @protected + void markItemsLoaded() { + _hasLoadedData = true; + tryFocus(); + if (widget.onDataLoaded != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) widget.onDataLoaded!(); + }); + } + } + /// Whether [focusFirstItem] has a real content target to focus. @protected bool get hasFocusableContent => _items.isNotEmpty; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 41508945..6652e02c 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -41,7 +41,6 @@ import '../../../widgets/media_card_list_layout.dart'; import '../../../widgets/bottom_sheet_page_scaffold.dart'; import '../../../widgets/overlay_sheet.dart'; import '../../../mixins/library_tab_focus_mixin.dart'; -import '../../../services/plex_client.dart'; import '../folder_tree_view.dart'; import '../filters_bottom_sheet.dart'; import '../sort_bottom_sheet.dart'; @@ -55,6 +54,7 @@ import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; import '../../../mixins/deletion_aware.dart'; import '../../../mixins/paginated_item_loader.dart'; +import '../../../mixins/standard_paginated_view.dart'; import '../../../widgets/card_inflation_budget.dart'; import '../../../widgets/skeleton_media_card.dart'; import '../../../widgets/sliver_child_memo.dart'; @@ -104,13 +104,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState, + PaginatedItemUpdatable, SkeletonUpgradeScheduler { String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey); - @override - String? get deletionServerId => widget.library.serverId; - + // DeletionMirrorsWatchState points the deletion filters at these three: the + // grid shows the same loaded items for both event families. @override String? get watchStateServerId => widget.library.serverId; @@ -130,22 +131,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState? get deletionIds => loadedItems.values.map((e) => e.id).toSet(); - - @override - Set? get deletionGlobalKeys { - if (loadedItems.isEmpty) return {}; - - final keys = {}; - for (final item in loadedItems.values) { - final serverId = serverIdOrNull(item.serverId ?? widget.library.serverId); - if (serverId == null) return null; - keys.add(_toGlobalKey(item.id, serverId: serverId)); - } - return keys; - } - @override void onWatchStateChanged(WatchStateEvent event) { if (event.changeType == WatchStateChangeType.progressUpdate || @@ -213,16 +198,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState totalSize; - @override - void updateItemInLists(String sourceGlobalKey, MediaItem updatedMetadata) { - for (final entry in loadedItems.entries) { - if (entry.value.globalKey == sourceGlobalKey) { - loadedItems[entry.key] = updatedMetadata; - break; - } - } - } - // Browse-specific state (not in base class) List _filters = []; List _sortOptions = []; @@ -1061,8 +1036,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState> _loadFilterValues(MediaFilter filter) async { if (!mounted) return const []; - final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId)); - if (client is PlexClient) return client.getFilterValues(filter.key); + final client = context.tryGetPlexClientForServer(serverIdOrNull(widget.library.serverId)); + if (client != null) return client.getFilterValues(filter.key); // Jellyfin's canonical filter values come from the cached `/Items/Filters` // payload. If that payload missed a category, there is no neutral endpoint diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index df59c57a..6df6ed59 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -1,25 +1,12 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../focus/input_mode_tracker.dart'; import '../../../media/library_query.dart'; import '../../../media/media_item.dart'; -import '../../../mixins/library_tab_focus_mixin.dart'; -import '../../../mixins/paginated_item_loader.dart'; -import '../../../services/settings_service.dart'; -import '../../../utils/error_message_utils.dart'; -import '../../../utils/layout_constants.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/media_server_http_client.dart'; -import '../../../utils/platform_detector.dart'; -import '../../../widgets/card_inflation_budget.dart'; -import '../../../widgets/focusable_media_card.dart'; -import '../../../widgets/media_card_sliver_layout.dart'; -import '../../../widgets/settings_builder.dart'; -import '../../../widgets/skeleton_media_card.dart'; -import '../../../widgets/sliver_child_memo.dart'; import '../../../i18n/strings.g.dart'; -import '../../main_screen.dart'; import 'base_library_tab.dart'; +import 'paginated_card_grid_tab.dart'; /// Collections tab for library screen. /// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root. @@ -39,23 +26,13 @@ class LibraryCollectionsTab extends BaseLibraryTab { State createState() => _LibraryCollectionsTabState(); } -class _LibraryCollectionsTabState extends BaseLibraryTabState - with - LibraryTabFocusMixin, - PaginatedItemLoader, - SkeletonUpgradeScheduler { - static const int _pageSize = 36; - - /// Reuses card widgets across delegate swaps so tab-level setStates - /// (pagination, refreshes) don't rebuild every realized card inside layout. - final SliverChildMemo _cardMemo = SliverChildMemo(); +class _LibraryCollectionsTabState extends PaginatedCardGridTabState { + @override + int get pageSize => 36; @override String get focusNodeDebugLabel => 'collections_first_item'; - @override - int get itemCount => totalSize; - @override IconData get emptyIcon => Symbols.collections_rounded; @@ -69,7 +46,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState? getRefreshStream() => LibraryRefreshNotifier().collectionsStream; @override - Future> loadData() async => const []; + String idOf(MediaItem item) => item.id; @override Future> fetchPage(int start, int size, AbortController? abort) { @@ -78,66 +55,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( - pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, stackTrace) { - errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext); - isLoading = false; - }, - onLoaded: (_, _) { - hasLoadedData = true; - tryFocus(); - if (widget.onDataLoaded != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onDataLoaded!(); - }); - } - }, - onError: (error, stackTrace) { - loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext); - }, - ); - } - - @override - Widget buildContent(List items) { - return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], - builder: (context) { - final settings = SettingsService.instance; - final viewMode = settings.read(SettingsService.viewMode); - final density = settings.read(SettingsService.libraryDensity); - final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); - return CustomScrollView( - clipBehavior: Clip.none, - slivers: [ - SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), - _buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout), - ], - ); - }, - ); - } - - static const double _focusDecorationPadding = 3.0; - - EdgeInsets get _effectivePadding { - final base = GridLayoutConstants.gridPadding; - return base.copyWith(top: base.top + _focusDecorationPadding); - } - - bool get _usesSquareCards { + bool get usesSquareCards { final loaded = loadedItems.values; return loaded.isNotEmpty && loaded.every(_isMusicCollection); } @@ -146,94 +64,4 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState item.kind.isMusic || (item is PlexMediaItem && widget.library.kind.isMusic); - - Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { - final shape = _usesSquareCards ? CardShape.square : null; - final useFullCardLayout = fullCardLayout && shape != CardShape.square; - return MediaCardSliverLayout( - viewMode: viewMode, - itemCount: totalSize, - density: density, - padding: _effectivePadding, - fullBleedImage: useFullCardLayout, - shape: shape, - listEpoch: (ViewMode.list, totalSize, density, shape), - gridEpochBuilder: (geometry) => - (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), - itemBuilder: (context, position) { - final index = position.index; - final item = loadedItems[index]; - if (item == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - if (!position.isGrid) { - return _cardMemo.widgetFor( - index, - item, - epoch: position.layoutEpoch!, - build: () => - _buildMediaCardItem(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true), - ); - } - - final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!); - if (cached != null) return cached; - if (CardInflationBudget.isScrollingContext(context) && - !InputModeTracker.isKeyboardMode(context) && - !CardInflationBudget.tryTake()) { - scheduleSkeletonUpgrade(); - return const SkeletonMediaCard(); - } - return _cardMemo.widgetFor( - index, - item, - epoch: position.layoutEpoch!, - build: () => _buildMediaCardItem( - index, - isFirstRow: position.isFirstRow, - isFirstColumn: position.isFirstColumn, - fullBleedImage: useFullCardLayout, - ), - ); - }, - ); - } - - Widget _buildMediaCardItem( - int index, { - required bool isFirstRow, - required bool isFirstColumn, - bool disableScale = false, - bool fullBleedImage = false, - }) { - final item = loadedItems[index]; - if (item == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - - return FocusableMediaCard( - key: Key(item.id), - item: item, - focusNode: index == 0 ? firstItemFocusNode : null, - disableScale: disableScale, - fullBleedImage: fullBleedImage, - cardShapeOverride: _usesSquareCards ? CardShape.square : null, - onListRefresh: loadItems, - onNavigateUp: isFirstRow ? widget.onBack : null, - onBack: widget.onBack, - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, - ); - } - - void _navigateToSidebar() { - MainScreenFocusScope.focusSidebarOf(context); - } - - @override - void dispose() { - disposePagination(); - super.dispose(); - } } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 9e6b7848..93b82cac 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -1,27 +1,13 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../../focus/input_mode_tracker.dart'; import '../../../media/library_query.dart'; -import '../../../media/media_item.dart'; import '../../../media/media_kind.dart'; import '../../../media/media_playlist.dart'; -import '../../../mixins/library_tab_focus_mixin.dart'; -import '../../../mixins/paginated_item_loader.dart'; -import '../../../services/settings_service.dart'; -import '../../../utils/error_message_utils.dart'; -import '../../../utils/layout_constants.dart'; import '../../../utils/library_refresh_notifier.dart'; import '../../../utils/media_server_http_client.dart'; -import '../../../utils/platform_detector.dart'; -import '../../../widgets/card_inflation_budget.dart'; -import '../../../widgets/focusable_media_card.dart'; -import '../../../widgets/media_card_sliver_layout.dart'; -import '../../../widgets/settings_builder.dart'; -import '../../../widgets/skeleton_media_card.dart'; -import '../../../widgets/sliver_child_memo.dart'; import '../../../i18n/strings.g.dart'; -import '../../main_screen.dart'; import 'base_library_tab.dart'; +import 'paginated_card_grid_tab.dart'; /// Playlists tab for library screen /// Shows playlists that contain items from the current library @@ -41,23 +27,13 @@ class LibraryPlaylistsTab extends BaseLibraryTab { State createState() => _LibraryPlaylistsTabState(); } -class _LibraryPlaylistsTabState extends BaseLibraryTabState - with - LibraryTabFocusMixin, - PaginatedItemLoader, - SkeletonUpgradeScheduler { - static const int _pageSize = 200; - - /// Reuses card widgets across delegate swaps so tab-level setStates - /// (pagination, refreshes) don't rebuild every realized card inside layout. - final SliverChildMemo _cardMemo = SliverChildMemo(); +class _LibraryPlaylistsTabState extends PaginatedCardGridTabState { + @override + int get pageSize => 200; @override String get focusNodeDebugLabel => 'playlists_first_item'; - @override - int get itemCount => totalSize; - @override IconData get emptyIcon => Symbols.playlist_play_rounded; @@ -71,7 +47,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState? getRefreshStream() => LibraryRefreshNotifier().playlistsStream; @override - Future> loadData() async => const []; + String idOf(MediaPlaylist playlist) => playlist.id; @override Future> fetchPage(int start, int size, AbortController? abort) { @@ -84,154 +60,5 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( - pageSize: _pageSize, - resetViewState: () { - isLoading = true; - errorMessage = null; - items = []; - }, - applyLoadedItems: (loaded) { - items = loaded; - isLoading = false; - }, - applyError: (error, stackTrace) { - errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext); - isLoading = false; - }, - onLoaded: (_, _) { - hasLoadedData = true; - tryFocus(); - if (widget.onDataLoaded != null) { - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) widget.onDataLoaded!(); - }); - } - }, - onError: (error, stackTrace) { - loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext); - }, - ); - } - - @override - Widget buildContent(List items) { - return SettingsBuilder( - prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], - builder: (context) { - final settings = SettingsService.instance; - final viewMode = settings.read(SettingsService.viewMode); - final density = settings.read(SettingsService.libraryDensity); - final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); - return CustomScrollView( - clipBehavior: Clip.none, - slivers: [ - SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), - _buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout), - ], - ); - }, - ); - } - - static const double _focusDecorationPadding = 3.0; - - EdgeInsets get _effectivePadding { - final base = GridLayoutConstants.gridPadding; - return base.copyWith(top: base.top + _focusDecorationPadding); - } - - bool get _usesSquareCards => widget.library.kind.isMusic; - - Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { - final shape = _usesSquareCards ? CardShape.square : null; - final useFullCardLayout = fullCardLayout && shape != CardShape.square; - return MediaCardSliverLayout( - viewMode: viewMode, - itemCount: totalSize, - density: density, - padding: _effectivePadding, - fullBleedImage: useFullCardLayout, - shape: shape, - listEpoch: (ViewMode.list, totalSize, density, shape), - gridEpochBuilder: (geometry) => - (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), - itemBuilder: (context, position) { - final index = position.index; - final playlist = loadedItems[index]; - if (playlist == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - if (!position.isGrid) { - return _cardMemo.widgetFor( - index, - playlist, - epoch: position.layoutEpoch!, - build: () => - _buildPlaylistCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true), - ); - } - - final cached = _cardMemo.tryGet(index, playlist, epoch: position.layoutEpoch!); - if (cached != null) return cached; - if (CardInflationBudget.isScrollingContext(context) && - !InputModeTracker.isKeyboardMode(context) && - !CardInflationBudget.tryTake()) { - scheduleSkeletonUpgrade(); - return const SkeletonMediaCard(); - } - return _cardMemo.widgetFor( - index, - playlist, - epoch: position.layoutEpoch!, - build: () => _buildPlaylistCard( - index, - isFirstRow: position.isFirstRow, - isFirstColumn: position.isFirstColumn, - fullBleedImage: useFullCardLayout, - ), - ); - }, - ); - } - - Widget _buildPlaylistCard( - int index, { - required bool isFirstRow, - required bool isFirstColumn, - bool disableScale = false, - bool fullBleedImage = false, - }) { - final playlist = loadedItems[index]; - if (playlist == null) { - ensureIndexLoaded(index, pageSize: _pageSize); - return const SkeletonMediaCard(); - } - - return FocusableMediaCard( - key: Key(playlist.id), - item: playlist, - focusNode: index == 0 ? firstItemFocusNode : null, - disableScale: disableScale, - fullBleedImage: fullBleedImage, - cardShapeOverride: _usesSquareCards ? CardShape.square : null, - onListRefresh: loadItems, - onNavigateUp: isFirstRow ? widget.onBack : null, - onBack: widget.onBack, - onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, - ); - } - - void _navigateToSidebar() { - MainScreenFocusScope.focusSidebarOf(context); - } - - @override - void dispose() { - disposePagination(); - super.dispose(); - } + bool get usesSquareCards => widget.library.kind.isMusic; } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 5826a780..b997ede5 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -15,7 +15,8 @@ import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; import '../../../services/settings_service.dart'; import '../../../utils/deletion_notifier.dart'; -import '../../../utils/global_key_utils.dart'; +import '../../../utils/hub_icons.dart'; +import '../../../utils/media_event_keys.dart'; import '../../../utils/platform_detector.dart'; import '../../../utils/provider_extensions.dart'; import '../../../utils/watch_state_notifier.dart'; @@ -46,7 +47,7 @@ class LibraryRecommendedTab extends BaseLibraryTab { } class _LibraryRecommendedTabState extends BaseLibraryTabState - with ItemUpdatable, WatchStateAware, DeletionAware { + with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState { /// GlobalKeys for each hub section to enable vertical navigation final List> _hubKeys = []; final _tvBrowseRailKey = GlobalKey(); @@ -72,45 +73,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState widget.library.serverId; - @override - String? get deletionServerId => widget.library.serverId; + /// Every item on screen, across all hubs. + Iterable get _visibleItems => items.expand((hub) => hub.items); - // Deletion filtering needs the same id sets as watch state: each visible - // item plus its parents, so deleting a season/show also matches the - // episodes it contains here. + // Deletion mirrors these via DeletionMirrorsWatchState: each visible item + // plus its parents, so deleting a season/show also matches the episodes it + // contains here. @override - Set? get deletionIds => watchedIds; + Set? get watchedIds => hierarchicalEventIds(_visibleItems); @override - Set? get deletionGlobalKeys => watchedGlobalKeys; - - @override - Set? get watchedIds { - final keys = {}; - for (final hub in items) { - for (final item in hub.items) { - keys.add(item.id); - if (item.parentId != null) keys.add(item.parentId!); - if (item.grandparentId != null) keys.add(item.grandparentId!); - } - } - return keys; - } - - @override - Set? get watchedGlobalKeys { - final keys = {}; - for (final hub in items) { - for (final item in hub.items) { - final serverId = item.serverId ?? widget.library.serverId; - if (serverId == null) return null; - keys.add(buildGlobalKey(ServerId(serverId), item.id)); - if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!)); - if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!)); - } - } - return keys; - } + Set? get watchedGlobalKeys => + hierarchicalEventGlobalKeys(_visibleItems, fallbackServerId: widget.library.serverId); @override void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { @@ -316,7 +290,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _getHubIcon(hub), + iconForHub: (hub, _) => hubIconFor(hub), onFocusedItemChanged: _setSpotlightItem, onRefresh: updateItem, onRemoveFromContinueWatching: _refreshContinueWatching, @@ -371,24 +345,4 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState> + extends BaseLibraryTabState + with + LibraryTabFocusMixin, + PaginatedItemLoader, + StandardPaginatedView, + SkeletonUpgradeScheduler { + static const double _focusDecorationPadding = 3.0; + + /// Reuses card widgets across delegate swaps so tab-level setStates + /// (pagination, refreshes) don't rebuild every realized card inside layout. + final SliverChildMemo _cardMemo = SliverChildMemo(); + + /// Items fetched per page. + int get pageSize; + + /// Whether cards render with the square container silhouette. + bool get usesSquareCards; + + /// Card key for [item]. The tabs' item types share no common supertype. + String idOf(T item); + + @override + int get itemCount => totalSize; + + @override + Future> loadData() async => []; + + @override + Future loadItems() { + return loadStandardPaginatedItems( + pageSize: pageSize, + errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), + onLoaded: (_, _) => markItemsLoaded(), + ); + } + + @override + Widget buildContent(List items) { + return SettingsBuilder( + prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], + builder: (context) { + final settings = SettingsService.instance; + final viewMode = settings.read(SettingsService.viewMode); + final density = settings.read(SettingsService.libraryDensity); + final fullCardLayout = PlatformDetector.isTV() && settings.read(SettingsService.tvFullCardLayout); + return CustomScrollView( + clipBehavior: Clip.none, + slivers: [ + SliverOverlapInjector(handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context)), + _buildItemsSliver(viewMode, density, fullCardLayout: fullCardLayout), + ], + ); + }, + ); + } + + EdgeInsets get _effectivePadding { + final base = GridLayoutConstants.gridPadding; + return base.copyWith(top: base.top + _focusDecorationPadding); + } + + Widget _buildItemsSliver(ViewMode viewMode, int density, {required bool fullCardLayout}) { + final shape = usesSquareCards ? CardShape.square : null; + final useFullCardLayout = fullCardLayout && shape != CardShape.square; + return MediaCardSliverLayout( + viewMode: viewMode, + itemCount: totalSize, + density: density, + padding: _effectivePadding, + fullBleedImage: useFullCardLayout, + shape: shape, + listEpoch: (ViewMode.list, totalSize, density, shape), + gridEpochBuilder: (geometry) => + (ViewMode.grid, geometry.columnCount, totalSize, useFullCardLayout, density, shape), + itemBuilder: (context, position) { + final index = position.index; + final item = loadedItems[index]; + if (item == null) { + ensureIndexLoaded(index, pageSize: pageSize); + return const SkeletonMediaCard(); + } + if (!position.isGrid) { + return _cardMemo.widgetFor( + index, + item, + epoch: position.layoutEpoch!, + build: () => _buildCard(index, isFirstRow: position.isFirstRow, isFirstColumn: true, disableScale: true), + ); + } + + final cached = _cardMemo.tryGet(index, item, epoch: position.layoutEpoch!); + if (cached != null) return cached; + if (CardInflationBudget.isScrollingContext(context) && + !InputModeTracker.isKeyboardMode(context) && + !CardInflationBudget.tryTake()) { + scheduleSkeletonUpgrade(); + return const SkeletonMediaCard(); + } + return _cardMemo.widgetFor( + index, + item, + epoch: position.layoutEpoch!, + build: () => _buildCard( + index, + isFirstRow: position.isFirstRow, + isFirstColumn: position.isFirstColumn, + fullBleedImage: useFullCardLayout, + ), + ); + }, + ); + } + + Widget _buildCard( + int index, { + required bool isFirstRow, + required bool isFirstColumn, + bool disableScale = false, + bool fullBleedImage = false, + }) { + final item = loadedItems[index]; + if (item == null) { + ensureIndexLoaded(index, pageSize: pageSize); + return const SkeletonMediaCard(); + } + + return FocusableMediaCard( + key: Key(idOf(item)), + item: item, + focusNode: index == 0 ? firstItemFocusNode : null, + disableScale: disableScale, + fullBleedImage: fullBleedImage, + cardShapeOverride: usesSquareCards ? CardShape.square : null, + onListRefresh: loadItems, + onNavigateUp: isFirstRow ? widget.onBack : null, + onBack: widget.onBack, + onNavigateLeft: isFirstColumn ? _navigateToSidebar : null, + ); + } + + void _navigateToSidebar() { + MainScreenFocusScope.focusSidebarOf(context); + } + + @override + void dispose() { + disposePagination(); + super.dispose(); + } +} diff --git a/lib/screens/livetv/live_tv_actions_mixin.dart b/lib/screens/livetv/live_tv_actions_mixin.dart index 89593331..310b3c50 100644 --- a/lib/screens/livetv/live_tv_actions_mixin.dart +++ b/lib/screens/livetv/live_tv_actions_mixin.dart @@ -50,6 +50,7 @@ mixin LiveTvActionsMixin on State { required LiveTvChannel? channel, required String? posterThumb, required String? posterServerId, + ValueChanged? onRecordingStateChanged, }) { final effectiveContext = sheetContext ?? context; final multiServer = effectiveContext.read(); @@ -74,6 +75,7 @@ mixin LiveTvActionsMixin on State { posterUrl: posterUrl, onTuneChannel: channel != null ? () => tuneChannel(channel) : null, client: client, + onRecordingStateChanged: onRecordingStateChanged, ); } } diff --git a/lib/screens/livetv/live_tv_screen.dart b/lib/screens/livetv/live_tv_screen.dart index 4733b1f8..e1961a5c 100644 --- a/lib/screens/livetv/live_tv_screen.dart +++ b/lib/screens/livetv/live_tv_screen.dart @@ -20,6 +20,7 @@ import '../../widgets/settings_builder.dart'; import '../../utils/app_logger.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/desktop_window_padding.dart'; +import '../../utils/live_tv_matching.dart'; import '../../utils/platform_detector.dart'; import '../../utils/serial_future_queue.dart'; import '../../utils/snackbar_helper.dart'; @@ -31,6 +32,8 @@ import 'tabs/guide_tab.dart'; import 'tabs/recordings_tab.dart'; import 'tabs/whats_on_tab.dart'; +typedef _FavoriteScope = ({String source, String storeKey, FavoriteChannelPersistenceMode mode}); + enum LiveTvTab { guide, whatsOn, recordings } class LiveTvScreen extends StatefulWidget { @@ -66,13 +69,15 @@ class _LiveTvScreenState extends State Set _favoriteKeys = {}; List _favoriteChannels = []; - /// Source URI per Live TV server/DVR, built from machineIdentifier + EPG provider identifier. - final Map _favoriteSourceByLiveServer = {}; - final Map _favoriteSourceByChannel = {}; - final Map _favoriteStoreByLiveServer = {}; - final Map _favoriteStoreByChannel = {}; + /// Favorite source URI, store key and persistence mode per Live TV server/DVR. + /// The source is built from machineIdentifier + EPG provider identifier. + final Map _favoriteScopeByLiveServer = {}; + final Map _liveServerKeyByChannel = {}; + + /// Store key per favorite source. A superset of the scope sources: it also + /// collects sources of fetched and toggled favorites that belong to other + /// servers sharing an account-scoped store. final Map _favoriteStoreBySource = {}; - final Map _favoriteModeByStore = {}; Future? _channelsLoadFuture; int _favoritesLoadGeneration = 0; Future? _favoritesLoadFuture; @@ -90,8 +95,13 @@ class _LiveTvScreenState extends State String _liveServerScopeKey(LiveTvServerInfo serverInfo) => '${serverInfo.serverId}\u0000${serverInfo.dvrKey}'; + _FavoriteScope? _favoriteScopeForChannel(LiveTvChannel channel) { + final liveServerKey = _liveServerKeyByChannel[liveTvChannelScopeKey(channel)]; + return liveServerKey == null ? null : _favoriteScopeByLiveServer[liveServerKey]; + } + String _sourceForChannel(LiveTvChannel channel) { - return channel.favoriteSource ?? _favoriteSourceByChannel[liveTvChannelScopeKey(channel)] ?? ''; + return channel.favoriteSource ?? _favoriteScopeForChannel(channel)?.source ?? ''; } String _favoriteKeyForChannel(LiveTvChannel channel) => favoriteChannelKey(_sourceForChannel(channel), channel.key); @@ -166,61 +176,56 @@ class _LiveTvScreenState extends State await _recordingsTabKey.currentState?.reload(); return; } - await _serverReloadGuide(); + await _broadcastToDvrs( + actionLabel: 'Reload guide', + successMessage: t.liveTv.guideReloadRequested, + action: (dvr, serverInfo) => dvr.reloadGuide(serverInfo.dvrKey), + ); await _loadChannels(); } - Future _serverReloadGuide() async { + /// Runs [action] on every DVR-capable Live TV server in parallel, then reports + /// [successMessage]. Per-DVR failures are non-fatal — 403 (admin only) and + /// transient errors are logged under [actionLabel] and swallowed, since + /// callers re-fetch their own client-side state regardless. Returns `true` + /// once at least one DVR was reached and this widget is still mounted. + Future _broadcastToDvrs({ + required String actionLabel, + required String successMessage, + required Future Function(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) action, + }) async { final multiServer = context.read(); + Future runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async { + try { + await action(dvr, serverInfo); + } catch (e) { + appLogger.d('$actionLabel failed for DVR ${serverInfo.dvrKey}: $e'); + } + } + final futures = >[]; for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null || client.liveTvDvr == null) continue; - futures.add(_reloadGuideSafe(client, serverInfo.dvrKey)); + final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr; + if (dvr == null) continue; + futures.add(runSafely(dvr, serverInfo)); } - if (futures.isEmpty) return; + if (futures.isEmpty) return false; await Future.wait(futures); - if (!mounted) return; - showSnackBar(context, t.liveTv.guideReloadRequested); - } - - Future _reloadGuideSafe(MediaServerClient client, String dvrId) async { - try { - final dvr = client.liveTvDvr; - if (dvr == null) return; - await dvr.reloadGuide(dvrId); - } catch (e) { - // 403 (admin only) and transient errors are non-fatal — caller still - // re-fetches client-side channels. - appLogger.d('Reload guide failed for DVR $dvrId: $e'); - } + if (!mounted) return false; + showSnackBar(context, successMessage); + return true; } Future _processRecordingRules() async { - final multiServer = context.read(); - final futures = >[]; - for (final serverInfo in multiServer.liveTvServers) { - final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); - if (client == null || client.liveTvDvr == null) continue; - futures.add(_processRulesSafe(client)); - } - if (futures.isEmpty) return; - await Future.wait(futures); - if (!mounted) return; - showSnackBar(context, t.liveTv.rulesProcessRequested); + final reached = await _broadcastToDvrs( + actionLabel: 'processRecordingRules', + successMessage: t.liveTv.rulesProcessRequested, + action: (dvr, _) => dvr.processRecordingRules(), + ); + if (!reached) return; await _recordingsTabKey.currentState?.reload(); } - Future _processRulesSafe(MediaServerClient client) async { - try { - final dvr = client.liveTvDvr; - if (dvr == null) return; - await dvr.processRecordingRules(); - } catch (e) { - appLogger.d('processRecordingRules failed: $e'); - } - } - /// Recompute visible tabs from the current MultiServerProvider state. /// Re-inits the tab controller when the visible set changes (matches the /// libraries-screen pattern at libraries_screen.dart:365). @@ -265,15 +270,10 @@ class _LiveTvScreenState extends State String? _sourceTitleForServerInfo(LiveTvServerInfo serverInfo) { for (final dvr in serverInfo.dvrs) { if (dvr.key == serverInfo.dvrKey) { - return _nonEmpty(dvr.lineupTitle) ?? _nonEmpty(dvr.lineupURL) ?? _nonEmpty(dvr.lineup); + return liveTvNonEmpty(dvr.lineupTitle) ?? liveTvNonEmpty(dvr.lineupURL) ?? liveTvNonEmpty(dvr.lineup); } } - return _nonEmpty(serverInfo.lineup); - } - - String? _nonEmpty(String? value) { - final trimmed = value?.trim(); - return trimmed == null || trimmed.isEmpty ? null : trimmed; + return liveTvNonEmpty(serverInfo.lineup); } Future _loadChannels() { @@ -308,12 +308,9 @@ class _LiveTvScreenState extends State final allChannels = []; final seenChannels = {}; - final favoriteSourceByLiveServer = {}; - final favoriteSourceByChannel = {}; - final favoriteStoreByLiveServer = {}; - final favoriteStoreByChannel = {}; + final favoriteScopeByLiveServer = {}; + final liveServerKeyByChannel = {}; final favoriteStoreBySource = {}; - final favoriteModeByStore = {}; appLogger.d( 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', @@ -338,10 +335,12 @@ class _LiveTvScreenState extends State final sourceTitle = _sourceTitleForServerInfo(serverInfo); final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - favoriteSourceByLiveServer[liveServerKey] = source; - favoriteStoreByLiveServer[liveServerKey] = storeKey; + favoriteScopeByLiveServer[liveServerKey] = ( + source: source, + storeKey: storeKey, + mode: liveTv.favoritePersistenceMode, + ); favoriteStoreBySource[source] = storeKey; - favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode; final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin @@ -360,9 +359,7 @@ class _LiveTvScreenState extends State ); final dedupKey = liveTvChannelScopeKey(scopedChannel); if (seenChannels.add(dedupKey)) { - final scopeKey = liveTvChannelScopeKey(scopedChannel); - favoriteSourceByChannel[scopeKey] = source; - favoriteStoreByChannel[scopeKey] = storeKey; + liveServerKeyByChannel[dedupKey] = liveServerKey; allChannels.add(scopedChannel); } } @@ -383,24 +380,15 @@ class _LiveTvScreenState extends State setState(() { _channels = allChannels; - _favoriteSourceByLiveServer + _favoriteScopeByLiveServer ..clear() - ..addAll(favoriteSourceByLiveServer); - _favoriteSourceByChannel + ..addAll(favoriteScopeByLiveServer); + _liveServerKeyByChannel ..clear() - ..addAll(favoriteSourceByChannel); - _favoriteStoreByLiveServer - ..clear() - ..addAll(favoriteStoreByLiveServer); - _favoriteStoreByChannel - ..clear() - ..addAll(favoriteStoreByChannel); + ..addAll(liveServerKeyByChannel); _favoriteStoreBySource ..clear() ..addAll(favoriteStoreBySource); - _favoriteModeByStore - ..clear() - ..addAll(favoriteModeByStore); _isLoading = false; }); @@ -438,10 +426,8 @@ class _LiveTvScreenState extends State _favoritesLoaded = false; _favoritesWritable = false; final previousStoreBySource = Map.of(_favoriteStoreBySource); - final sourceByLiveServer = Map.of(_favoriteSourceByLiveServer); - final storeByLiveServer = Map.of(_favoriteStoreByLiveServer); + final scopeByLiveServer = Map.of(_favoriteScopeByLiveServer); final storeBySource = Map.of(_favoriteStoreBySource); - final modeByStore = Map.of(_favoriteModeByStore); final merged = []; final successfulStores = {}; final failedStores = {}; @@ -453,12 +439,10 @@ class _LiveTvScreenState extends State final liveTv = client.liveTv; final storeKey = liveTv.favoriteStoreKey; final liveServerKey = _liveServerScopeKey(serverInfo); - storeByLiveServer[liveServerKey] = storeKey; - modeByStore[storeKey] = liveTv.favoritePersistenceMode; try { final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); - sourceByLiveServer[liveServerKey] = source; + scopeByLiveServer[liveServerKey] = (source: source, storeKey: storeKey, mode: liveTv.favoritePersistenceMode); storeBySource[source] = storeKey; if (successfulStores.contains(storeKey)) continue; @@ -487,18 +471,12 @@ class _LiveTvScreenState extends State if (!mounted || loadGeneration != _favoritesLoadGeneration) return; setState(() { - _favoriteSourceByLiveServer + _favoriteScopeByLiveServer ..clear() - ..addAll(sourceByLiveServer); - _favoriteStoreByLiveServer - ..clear() - ..addAll(storeByLiveServer); + ..addAll(scopeByLiveServer); _favoriteStoreBySource ..clear() ..addAll(storeBySource); - _favoriteModeByStore - ..clear() - ..addAll(modeByStore); _favoriteChannels = merged; _refreshFavoriteKeys(); _favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty; @@ -520,8 +498,7 @@ class _LiveTvScreenState extends State _enqueueFavoriteMutation(() { final source = _sourceForChannel(channel); final favoriteKey = favoriteChannelKey(source, channel.key); - final scopeKey = liveTvChannelScopeKey(channel); - final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey]; + final storeKey = channel.favoriteStoreKey ?? _favoriteScopeForChannel(channel)?.storeKey; if (storeKey != null) _favoriteStoreBySource[source] = storeKey; setState(() { @@ -601,16 +578,13 @@ class _LiveTvScreenState extends State for (final serverInfo in multiServer.liveTvServers) { final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); if (client == null) continue; - final liveServerKey = _liveServerScopeKey(serverInfo); - final storeKey = _favoriteStoreByLiveServer[liveServerKey]; - if (storeKey == null || !writtenStores.add(storeKey)) continue; - final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode; - final source = _favoriteSourceByLiveServer[liveServerKey]; - if (source == null) continue; - final channels = switch (mode) { - FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const [], + final scope = _favoriteScopeByLiveServer[_liveServerScopeKey(serverInfo)]; + if (scope == null || !writtenStores.add(scope.storeKey)) continue; + final storeChannels = byStore[scope.storeKey] ?? const []; + final channels = switch (scope.mode) { + FavoriteChannelPersistenceMode.sharedFullList => storeChannels, FavoriteChannelPersistenceMode.serverSlice => - (byStore[storeKey] ?? const []).where((favorite) => favorite.source == source).toList(), + storeChannels.where((favorite) => favorite.source == scope.source).toList(), }; writes.add(client.liveTv.setFavoriteChannels(channels)); } diff --git a/lib/screens/livetv/record_options_sheet.dart b/lib/screens/livetv/record_options_sheet.dart index 4251ce14..f69e0be0 100644 --- a/lib/screens/livetv/record_options_sheet.dart +++ b/lib/screens/livetv/record_options_sheet.dart @@ -419,9 +419,23 @@ class _SettingRow extends StatelessWidget { return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); } if (type == 'int') { - return _IntSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); + return _TextFieldSettingRow( + setting: setting, + currentValue: currentValue, + autofocus: autofocus, + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], + parseValue: int.tryParse, + onChanged: onChanged, + ); } - return _TextSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); + return _TextFieldSettingRow( + setting: setting, + currentValue: currentValue, + autofocus: autofocus, + parseValue: (text) => text, + onChanged: onChanged, + ); } } @@ -435,6 +449,28 @@ bool _coerceBool(Object? value) { return false; } +/// Setting label with its optional secondary summary line. +class _SettingLabel extends StatelessWidget { + final String label; + final String? summary; + + const _SettingLabel({required this.label, this.summary}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final summary = this.summary; + return Column( + crossAxisAlignment: .start, + children: [ + Text(label, style: theme.textTheme.bodyMedium), + if (summary != null && summary.isNotEmpty) + Text(summary, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), + ], + ); + } +} + class _BoolSettingRow extends StatelessWidget { final SubscriptionSetting setting; final Object? currentValue; @@ -450,7 +486,6 @@ class _BoolSettingRow extends StatelessWidget { @override Widget build(BuildContext context) { - final theme = Theme.of(context); final value = _coerceBool(currentValue); void toggle() => onChanged(!value); return FocusableWrapper( @@ -466,17 +501,7 @@ class _BoolSettingRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium), - if (setting.summary != null && setting.summary!.isNotEmpty) - Text( - setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - ], - ), + child: _SettingLabel(label: setting.label ?? setting.id, summary: setting.summary), ), IgnorePointer( child: Switch(value: value, onChanged: (v) => onChanged(v)), @@ -549,14 +574,7 @@ class _PickerRow extends StatelessWidget { child: Row( children: [ Expanded( - child: Column( - crossAxisAlignment: .start, - children: [ - Text(label, style: theme.textTheme.bodyMedium), - if (summary != null && summary!.isNotEmpty) - Text(summary!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)), - ], - ), + child: _SettingLabel(label: label, summary: summary), ), const SizedBox(width: 12), Text(value, style: theme.textTheme.bodyMedium), @@ -575,24 +593,32 @@ class _PickerRow extends StatelessWidget { } } -class _IntSettingRow extends StatefulWidget { +/// Free-text setting row. [parseValue] maps the field text to the value handed +/// back to [onChanged] — identity for text settings, `int.tryParse` for ints. +class _TextFieldSettingRow extends StatefulWidget { final SubscriptionSetting setting; final Object? currentValue; final bool autofocus; + final TextInputType? keyboardType; + final List? inputFormatters; + final Object? Function(String) parseValue; final void Function(Object?) onChanged; - const _IntSettingRow({ + const _TextFieldSettingRow({ required this.setting, required this.currentValue, required this.autofocus, + required this.parseValue, required this.onChanged, + this.keyboardType, + this.inputFormatters, }); @override - State<_IntSettingRow> createState() => _IntSettingRowState(); + State<_TextFieldSettingRow> createState() => _TextFieldSettingRowState(); } -class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerMixin { +class _TextFieldSettingRowState extends State<_TextFieldSettingRow> with ControllerDisposerMixin { late final TextEditingController _controller; @override @@ -602,7 +628,7 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM } @override - void didUpdateWidget(covariant _IntSettingRow oldWidget) { + void didUpdateWidget(covariant _TextFieldSettingRow oldWidget) { super.didUpdateWidget(oldWidget); final next = widget.currentValue?.toString() ?? ''; if (next != _controller.text) _controller.text = next; @@ -610,91 +636,21 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM @override Widget build(BuildContext context) { - final theme = Theme.of(context); return Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), child: Column( crossAxisAlignment: .start, children: [ - Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), - if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) - Text( - widget.setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), + _SettingLabel(label: widget.setting.label ?? widget.setting.id, summary: widget.setting.summary), const SizedBox(height: 4), FocusableTextField( controller: _controller, autofocus: widget.autofocus, - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], + keyboardType: widget.keyboardType, + inputFormatters: widget.inputFormatters, onNavigateUp: () => FocusScope.of(context).previousFocus(), onNavigateDown: () => FocusScope.of(context).nextFocus(), - onChanged: (text) { - final parsed = int.tryParse(text); - widget.onChanged(parsed); - }, - ), - ], - ), - ); - } -} - -class _TextSettingRow extends StatefulWidget { - final SubscriptionSetting setting; - final Object? currentValue; - final bool autofocus; - final void Function(Object?) onChanged; - - const _TextSettingRow({ - required this.setting, - required this.currentValue, - required this.autofocus, - required this.onChanged, - }); - - @override - State<_TextSettingRow> createState() => _TextSettingRowState(); -} - -class _TextSettingRowState extends State<_TextSettingRow> with ControllerDisposerMixin { - late final TextEditingController _controller; - - @override - void initState() { - super.initState(); - _controller = createTextEditingController(text: widget.currentValue?.toString() ?? ''); - } - - @override - void didUpdateWidget(covariant _TextSettingRow oldWidget) { - super.didUpdateWidget(oldWidget); - final next = widget.currentValue?.toString() ?? ''; - if (next != _controller.text) _controller.text = next; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), - child: Column( - crossAxisAlignment: .start, - children: [ - Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), - if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty) - Text( - widget.setting.summary!, - style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant), - ), - const SizedBox(height: 4), - FocusableTextField( - controller: _controller, - autofocus: widget.autofocus, - onNavigateUp: () => FocusScope.of(context).previousFocus(), - onNavigateDown: () => FocusScope.of(context).nextFocus(), - onChanged: (text) => widget.onChanged(text), + onChanged: (text) => widget.onChanged(widget.parseValue(text)), ), ], ), diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index 83dd05e7..f1f0be53 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; import '../../media/ids.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../focus/dpad_navigator.dart'; +import '../../focus/dpad_reorder_mixin.dart'; import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; -import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../providers/multi_server_provider.dart'; @@ -34,18 +32,33 @@ class ReorderFavoritesSheet extends StatefulWidget { State createState() => _ReorderFavoritesSheetState(); } -class _ReorderFavoritesSheetState extends State { +class _ReorderFavoritesSheetState extends State + with DpadReorderListMixin { late List _tempFavorites; - // Keyboard navigation state - int _focusedIndex = 0; - int _focusedColumn = 0; // 0 = row, 1 = remove button - int? _movingIndex; - int? _originalIndex; - List? _originalOrder; final FocusNode _listFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); - bool _backKeyDownSeen = false; + + // Keyboard navigation: column 0 = row, column 1 = remove button. + @override + List get reorderItems => _tempFavorites; + + @override + set reorderItems(List value) => _tempFavorites = value; + + @override + int get lastReorderColumn => 1; + + @override + ScrollController? get reorderScrollController => _scrollController; + + @override + void onReorderMoveConfirmed() => widget.onReorder(_tempFavorites); + + @override + void onReorderColumnActivated(int column, int index) { + if (column == 1) _removeItem(index); + } @override void initState() { @@ -60,139 +73,6 @@ class _ReorderFavoritesSheetState extends State { super.dispose(); } - void _ensureFocusedVisible() { - if (!_scrollController.hasClients) return; - - const double itemHeight = 72.0; - const double listTopPadding = 8.0; - final double targetTop = listTopPadding + (_focusedIndex * itemHeight); - final double targetBottom = targetTop + itemHeight; - - final double viewportTop = _scrollController.offset; - final double viewportHeight = _scrollController.position.viewportDimension; - final double viewportBottom = viewportTop + viewportHeight; - - if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; - - final double destination = (targetTop - viewportHeight * 0.25).clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - - _scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - if (key.isBackKey) { - if (event is KeyDownEvent) { - _backKeyDownSeen = true; - } else if (event is KeyUpEvent && !_backKeyDownSeen) { - return KeyEventResult.handled; - } - if (event is KeyUpEvent) { - _backKeyDownSeen = false; - } - } - - final backResult = handleBackKeyAction(event, () { - if (_movingIndex != null) { - setState(() { - if (_originalOrder != null) { - _tempFavorites = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - } else { - OverlaySheetController.popAdaptive(context); - } - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (!event.isActionable) return KeyEventResult.ignored; - - if (_movingIndex != null) { - if (key.isUpKey && _movingIndex! > 0) { - setState(() { - final item = _tempFavorites.removeAt(_movingIndex!); - _tempFavorites.insert(_movingIndex! - 1, item); - _movingIndex = _movingIndex! - 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _movingIndex! < _tempFavorites.length - 1) { - setState(() { - final item = _tempFavorites.removeAt(_movingIndex!); - _tempFavorites.insert(_movingIndex! + 1, item); - _movingIndex = _movingIndex! + 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - widget.onReorder(_tempFavorites); - setState(() { - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - return KeyEventResult.handled; - } - } else { - if (key.isUpKey && _focusedIndex > 0) { - setState(() { - _focusedIndex--; - _focusedColumn = 0; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _focusedIndex < _tempFavorites.length - 1) { - setState(() { - _focusedIndex++; - _focusedColumn = 0; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isLeftKey && _focusedColumn > 0) { - setState(() => _focusedColumn--); - return KeyEventResult.handled; - } - if (key.isRightKey && _focusedColumn < 1) { - setState(() => _focusedColumn++); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - if (_focusedColumn == 0) { - setState(() { - _movingIndex = _focusedIndex; - _originalIndex = _focusedIndex; - _originalOrder = List.from(_tempFavorites); - }); - } else if (_focusedColumn == 1) { - _removeItem(_focusedIndex); - } - return KeyEventResult.handled; - } - } - - if (key.isDpadDirection) { - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - void _onReorder(int oldIndex, int newIndex) { setState(() { final item = _tempFavorites.removeAt(oldIndex); @@ -205,8 +85,8 @@ class _ReorderFavoritesSheetState extends State { final removed = _tempFavorites[index]; setState(() { _tempFavorites.removeAt(index); - if (_focusedIndex >= _tempFavorites.length) { - _focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length); + if (focusedIndex >= _tempFavorites.length) { + focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length); } }); widget.onRemove(removed); @@ -229,7 +109,7 @@ class _ReorderFavoritesSheetState extends State { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: isKeyboardMode, - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleReorderKeyEvent, child: ReorderableListView.builder( scrollController: _scrollController, onReorderItem: _onReorder, @@ -239,8 +119,8 @@ class _ReorderFavoritesSheetState extends State { itemBuilder: (context, index) { final fav = _tempFavorites[index]; final channel = widget.channelMap[fav.stableKey]; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; + final isFocused = isKeyboardMode && index == focusedIndex; + final isMoving = index == movingIndex; return _buildFavoriteTile( key: ValueKey(fav.stableKey), @@ -249,7 +129,7 @@ class _ReorderFavoritesSheetState extends State { index: index, isFocused: isFocused, isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, + focusedColumn: isFocused ? focusedColumn : null, ); }, ), diff --git a/lib/screens/livetv/tabs/guide_tab.dart b/lib/screens/livetv/tabs/guide_tab.dart index 6d1051ec..824f9dbd 100644 --- a/lib/screens/livetv/tabs/guide_tab.dart +++ b/lib/screens/livetv/tabs/guide_tab.dart @@ -23,19 +23,17 @@ import '../../../providers/multi_server_provider.dart'; import '../../../media/media_server_client.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/app_logger.dart'; +import '../live_tv_actions_mixin.dart'; import '../live_tv_refresh_lifecycle.dart'; import '../../../utils/formatters.dart'; import '../../../utils/live_tv_grouping.dart'; import '../../../utils/live_tv_matching.dart'; -import '../../../utils/media_image_helper.dart'; -import '../../../utils/live_tv_player_navigation.dart'; import '../../../utils/platform_detector.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/app_menu.dart'; import '../../../widgets/clickable_cursor.dart'; import '../../../widgets/optimized_media_image.dart'; import '../livetv_styles.dart'; -import '../program_details_sheet.dart'; class GuideTab extends StatefulWidget { final List channels; @@ -109,7 +107,8 @@ final class _GuideChannelRow extends _GuideRow { const _GuideChannelRow({required this.channel, required this.channelIndex}); } -class GuideTabState extends State with MountedSetStateMixin, WidgetsBindingObserver { +class GuideTabState extends State + with LiveTvActionsMixin, MountedSetStateMixin, WidgetsBindingObserver { static const _slotWidth = 180.0; static const _channelColumnWidth = 132.0; static const _rowHeight = 64.0; @@ -165,6 +164,9 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi LiveTvProgram? _focusedProgram; bool _pendingFocus = false; + @override + List get liveTvChannels => widget.channels; + /// Focus into the guide content (called from tab bar navigation or initial load). void focusContent() { if (!InputModeTracker.isKeyboardMode(context)) return; @@ -210,6 +212,9 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi }); } + // Not the gated data-refresh timer the other tabs run: pause/resume drive the + // per-minute UI ticker, and pause has to stamp _hiddenSince on both a section + // hide and an app background so _catchUpIfStale can measure the absence. void pauseRefresh() { _hiddenSince ??= DateTime.now(); _timeIndicatorTimer?.cancel(); @@ -534,12 +539,12 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } Set _recordingKeysForProgram(LiveTvProgram program, {String? fallbackServerId}) { - final serverId = _nonEmpty(program.serverId) ?? _nonEmpty(fallbackServerId); + final serverId = liveTvNonEmpty(program.serverId) ?? liveTvNonEmpty(fallbackServerId); if (serverId == null) return const {}; final keys = {}; void addMediaId(String? value) { - final normalized = _nonEmpty(value); + final normalized = liveTvNonEmpty(value); if (normalized != null) keys.add(_recordingKey(ServerId(serverId), 'media', normalized)); } @@ -547,7 +552,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi addMediaId(program.guid); addMediaId(program.key); - final channelIdentifier = _nonEmpty(program.channelIdentifier); + final channelIdentifier = liveTvNonEmpty(program.channelIdentifier); final beginsAt = program.beginsAt; if (channelIdentifier != null && beginsAt != null) { keys.add(_recordingKey(ServerId(serverId), 'slot', '$channelIdentifier|$beginsAt|${program.endsAt ?? ''}')); @@ -558,11 +563,6 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi String _recordingKey(ServerId serverId, String type, String value) => '$serverId\u0000$type\u0000$value'; - String? _nonEmpty(String? value) { - final trimmed = value?.trim(); - return trimmed == null || trimmed.isEmpty ? null : trimmed; - } - List<_GuideRow> get _guideRows { final groups = groupLiveTvChannelsBySource(widget.channels); if (groups.length <= 1) { @@ -655,14 +655,9 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi return (totalMinutes / _minutesPerSlot) * _slotWidth; } - Future _tuneChannel(LiveTvChannel channel) async { - final multiServer = context.read(); - await navigateToLiveTv(context, multiServer: multiServer, channel: channel, channels: widget.channels); - } - void _activateProgram(LiveTvChannel channel, LiveTvProgram program) { if (PlatformDetector.isTV() && program.isCurrentlyAiring) { - _tuneChannel(channel); + tuneChannel(channel); return; } @@ -855,7 +850,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi if (_gridChannelIndex >= 0 && _gridChannelIndex < widget.channels.length) { final channel = widget.channels[_gridChannelIndex]; if (_gridColumn == 0) { - _tuneChannel(channel); + tuneChannel(channel); } else if (_focusedProgram != null) { _activateProgram(channel, _focusedProgram!); } @@ -1375,7 +1370,7 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi client: client, channel: channel, theme: theme, - onTap: () => _tuneChannel(channel), + onTap: () => tuneChannel(channel), onLongPress: widget.onToggleFavorite != null ? () => widget.onToggleFavorite!(channel) : null, isFocused: isFocused, isFavorite: widget.isFavoriteChannel?.call(channel) ?? false, @@ -1592,28 +1587,11 @@ class GuideTabState extends State with MountedSetStateMixin, WidgetsBi } void _showProgramDetails(LiveTvChannel channel, LiveTvProgram program) { - final multiServer = context.read(); - final serverId = serverIdOrNull(channel.serverId); - final client = serverId == null ? null : multiServer.getClientForServer(serverId); - String? posterUrl; - if (program.thumb != null && client != null) { - posterUrl = MediaImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: program.thumb, - maxWidth: 80, - maxHeight: 120, - devicePixelRatio: MediaImageHelper.effectiveDevicePixelRatio(context), - imageType: ImageType.poster, - ); - } - - showProgramDetailsSheet( - context, + showProgramDetails( program: program, channel: channel, - posterUrl: posterUrl, - onTuneChannel: () => _tuneChannel(channel), - client: client, + posterThumb: program.thumb, + posterServerId: channel.serverId, onRecordingStateChanged: (isScheduled) => _handleRecordingStateChanged(program, isScheduled), ); } diff --git a/lib/screens/livetv/tabs/recordings_tab.dart b/lib/screens/livetv/tabs/recordings_tab.dart index 998515f9..a68e0746 100644 --- a/lib/screens/livetv/tabs/recordings_tab.dart +++ b/lib/screens/livetv/tabs/recordings_tab.dart @@ -125,6 +125,9 @@ class RecordingsTabState extends State with WidgetsBindingObserve } } + // Same three gates as WhatsOnTab (tab selected, subtree visible, app + // foregrounded), but resume also reloads: a recording scheduled from the + // guide has to show up on arrival, not on the next 30s tick. void pauseRefresh() { _refreshRequested = false; _syncRefreshTimer(); diff --git a/lib/screens/livetv/tabs/whats_on_tab.dart b/lib/screens/livetv/tabs/whats_on_tab.dart index 70c400b9..7d46f7c2 100644 --- a/lib/screens/livetv/tabs/whats_on_tab.dart +++ b/lib/screens/livetv/tabs/whats_on_tab.dart @@ -80,6 +80,9 @@ class WhatsOnTabState extends State } } + // Refreshes only while all three gates hold: tab selected, subtree visible, + // app foregrounded. Resume just re-arms the tick — unlike RecordingsTab there + // is no immediate reload, since nothing done on the other tabs changes hubs. void pauseRefresh() { _refreshRequested = false; _syncRefreshTimer(); diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 66a2a6f3..c27c6885 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import '../media/ids.dart'; +import '../media/media_server_client.dart'; import '../navigation/main_screen_scope.dart'; import 'dart:io' show Platform, exit; @@ -34,6 +35,7 @@ import '../profiles/active_profile_binder.dart'; import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; +import '../profiles/profile_selection_policy.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -260,13 +262,12 @@ class _MainScreenState extends State bool _isShowingProfileSelection = false; late List _screens; - final GlobalKey> _discoverKey = GlobalKey(); - final GlobalKey> _exploreKey = GlobalKey(); - final GlobalKey> _librariesKey = GlobalKey(); - final GlobalKey> _liveTvKey = GlobalKey(); - final GlobalKey> _searchKey = GlobalKey(); - final GlobalKey> _downloadsKey = GlobalKey(); - final GlobalKey> _settingsKey = GlobalKey(); + + /// One [GlobalKey] per tab, so a tab's live [State] can be reached from + /// anywhere in this class via [_onScreen]. Deliberately untyped: every + /// consumer discards the concrete `State` type and pattern-matches on a + /// capability mixin (Refreshable, FocusableTab, …) instead. + final Map _screenKeys = {for (final id in NavigationTabId.values) id: GlobalKey()}; final GlobalKey _sideNavKey = GlobalKey(); /// Measures the mobile bottom navigation area for the music mini-player. @@ -470,23 +471,11 @@ class _MainScreenState extends State } void tryDownloadResume() { - if (_downloadResumeFired || !mounted) return; // Wait for any online client before firing the resume — the download // pipeline is backend-neutral (resumeQueuedDownloads accepts a // MediaServerClient and per-item resolution picks up the right // backend), so a Jellyfin-only setup can resume too. - final onlineClient = manager.onlineClients.values.firstOrNull; - if (onlineClient == null) return; - _downloadResumeFired = true; - _serverStatusSub?.cancel(); - _serverStatusSub = null; - final downloadProvider = context.read(); - unawaited( - downloadProvider.ensureInitialized().then((_) { - if (!mounted) return; - downloadProvider.resumeQueuedDownloads(onlineClient); - }), - ); + _resumeQueuedDownloadsOnce(manager.onlineClients.values.firstOrNull); } // Listen for binding-settle so the once-only priming runs after both @@ -524,36 +513,35 @@ class _MainScreenState extends State if (!mounted) return; context.read().onServersConnected(); unawaited(context.read().refreshMetadataFromCache()); - _resumeQueuedDownloadsIfPossible(mp); + _resumeQueuedDownloadsOnce( + mp.onlineServerIds.map((id) => mp.getClientForServer(ServerId(id))).nonNulls.firstOrNull, + ); } } if (!mounted) return; - if (_discoverKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_librariesKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_searchKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } + _fullRefreshContentTabs(); } - void _resumeQueuedDownloadsIfPossible(MultiServerProvider mp) { + /// Single-shot "resume queued downloads once any client is online" rule, + /// shared by the startup status-stream path and [_primeOnlineServices] — + /// each caller resolves its own candidate client (unfiltered manager view + /// vs the visibility-filtered provider) and hands it here. No-op once the + /// resume has fired, or while no client is online yet. + void _resumeQueuedDownloadsOnce(MediaServerClient? onlineClient) { if (_downloadResumeFired || !mounted) return; - for (final serverId in mp.onlineServerIds) { - final onlineClient = mp.getClientForServer(ServerId(serverId)); - if (onlineClient == null) continue; - _downloadResumeFired = true; - unawaited( - context.read().ensureInitialized().then((_) { - if (!mounted) return; - context.read().resumeQueuedDownloads(onlineClient); - }), - ); - return; - } + if (onlineClient == null) return; + _downloadResumeFired = true; + // The status subscription exists only to drive this one-shot. + _serverStatusSub?.cancel(); + _serverStatusSub = null; + final downloadProvider = context.read(); + unawaited( + downloadProvider.ensureInitialized().then((_) { + if (!mounted) return; + downloadProvider.resumeQueuedDownloads(onlineClient); + }), + ); } void _onActiveProfileChanged() { @@ -623,11 +611,15 @@ class _MainScreenState extends State // has no profile to bind, and the user lands on an empty screen with // no way back to the picker. final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; - final requireOnOpen = - settingsService.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; - if (!hasNoActive && !requireOnOpen) return; + if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return; + await _pushProfileSelection(); + } + + /// Push the picker in "must choose" mode, suppressing the tvOS menu-button + /// passthrough for as long as it is up. + Future _pushProfileSelection() async { _isShowingProfileSelection = true; _setTvosMenuPassthrough(false); await Navigator.of( @@ -867,9 +859,7 @@ class _MainScreenState extends State _selectTab(NavigationTabId.search, focusSearchInput: !hasQuery); if (hasQuery) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.submitSearchQuery(trimmed); - } + _onScreen(NavigationTabId.search, (screen) => screen.submitSearchQuery(trimmed)); }); } }; @@ -954,21 +944,11 @@ class _MainScreenState extends State Future _showProfileSelectionOnResume() async { final settingsService = await SettingsService.getInstance(); - if (!settingsService.read(SettingsService.requireProfileSelectionOnOpen)) return; if (!mounted) return; - final activeProfile = context.read(); - if (!activeProfile.hasMultipleProfiles) return; + if (!context.read().requiresSelectionOnOpen(settingsService)) return; - _isShowingProfileSelection = true; - _setTvosMenuPassthrough(false); - await Navigator.of( - context, - rootNavigator: true, - ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); - if (!mounted) return; - _isShowingProfileSelection = false; - _updateTvosMenuPassthrough(); + await _pushProfileSelection(); } /// IndexedStack that disables tickers for offscreen children to prevent @@ -994,17 +974,17 @@ class _MainScreenState extends State return [ for (final tab in _getVisibleTabs(offline)) switch (tab.id) { - NavigationTabId.discover => DiscoverScreen(key: _discoverKey), - NavigationTabId.explore => ExploreScreen(key: _exploreKey), + NavigationTabId.discover => DiscoverScreen(key: _screenKeys[tab.id]), + NavigationTabId.explore => ExploreScreen(key: _screenKeys[tab.id]), NavigationTabId.libraries => LibrariesScreen( - key: _librariesKey, + key: _screenKeys[tab.id], onLibraryOrderChanged: _onLibraryOrderChanged, onLibrarySelected: _handleLibrariesScreenSelected, ), - NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey), - NavigationTabId.search => SearchScreen(key: _searchKey), - NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey), - NavigationTabId.settings => SettingsScreen(key: _settingsKey), + NavigationTabId.liveTv => LiveTvScreen(key: _screenKeys[tab.id]), + NavigationTabId.search => SearchScreen(key: _screenKeys[tab.id]), + NavigationTabId.downloads => DownloadsScreen(key: _screenKeys[tab.id]), + NavigationTabId.settings => SettingsScreen(key: _screenKeys[tab.id]), }, ]; } @@ -1059,16 +1039,22 @@ class _MainScreenState extends State }()); } - void _handleLiveTvChanged() { - final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; - if (hasLiveTv == _lastHasLiveTv) return; - _lastHasLiveTv = hasLiveTv; - + /// Rebuilds navigation after a tab's availability flipped: _currentTab may + /// need normalizing, and passthrough depends on it being the first tab. + void _handleTabAvailabilityChanged() { setState(() { _screens = _buildScreens(_isOffline); _currentTab = _normalizeTabForMode(_currentTab, _isOffline); }); _updateTvosMenuPassthrough(); + } + + void _handleLiveTvChanged() { + final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; + if (hasLiveTv == _lastHasLiveTv) return; + _lastHasLiveTv = hasLiveTv; + + _handleTabAvailabilityChanged(); // A preferred startup section (only Live TV can be deferred) just became // available — switch to it via _selectTab so it gets the usual visibility @@ -1084,13 +1070,7 @@ class _MainScreenState extends State if (hasExplore == _lastHasExplore) return; _lastHasExplore = hasExplore; - setState(() { - _screens = _buildScreens(_isOffline); - _currentTab = _normalizeTabForMode(_currentTab, _isOffline); - }); - // Same as the live-TV handler: the passthrough flag depends on whether - // _currentTab is the first tab, which the normalize above can change. - _updateTvosMenuPassthrough(); + _handleTabAvailabilityChanged(); } void _handleOfflineStatusChanged() { @@ -1183,17 +1163,8 @@ class _MainScreenState extends State // This preserves the user's focus position when returning from sidebar. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (restorePreviousFocus) { - if (_contentFocusScope.focusedChild == null) { - if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } - } - } else { - if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } - } + if (restorePreviousFocus && _contentFocusScope.focusedChild != null) return; + _onScreen(_currentTab, (screen) => screen.focusActiveTabIfReady()); }); } @@ -1396,9 +1367,7 @@ class _MainScreenState extends State if (_isSidebarFocused) _focusContent(); // Schedule focus after the frame so the search screen is visible in the IndexedStack WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.focusSearchInput(); - } + _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); return KeyEventResult.handled; } @@ -1419,9 +1388,7 @@ class _MainScreenState extends State _miniPlayerInsets?.setNavBarSuspended(true); // Called when a child route is pushed on top (e.g., video player) if (_currentTab == NavigationTabId.discover) { - if (_discoverKey.currentState case final TabVisibilityAware aware) { - aware.onTabHidden(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.onTabHidden()); } } @@ -1440,9 +1407,7 @@ class _MainScreenState extends State _updateTvosMenuPassthrough(); _miniPlayerInsets?.setNavBarSuspended(false); if (_currentTab == NavigationTabId.discover) { - if (_discoverKey.currentState case final TabVisibilityAware aware) { - aware.onTabShown(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.onTabShown()); _onDiscoverBecameVisible(); } } @@ -1450,9 +1415,7 @@ class _MainScreenState extends State void _onDiscoverBecameVisible() { appLogger.d('Navigated to home'); // Refresh content when returning to discover page - if (_discoverKey.currentState case final Refreshable refreshable) { - refreshable.refresh(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.refresh()); } void _onLibraryOrderChanged() { @@ -1497,15 +1460,7 @@ class _MainScreenState extends State playbackStateProvider.clearShuffle(); - if (_discoverKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_librariesKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_searchKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } + _fullRefreshContentTabs(); // Refresh user-level settings (audio/sub defaults) for the new identity. if (mounted) { @@ -1533,14 +1488,9 @@ class _MainScreenState extends State if (previousTab != tab) { // Notify previous screen it's being hidden - if (_screenKeyFor(previousTab)?.currentState case final TabVisibilityAware aware) { - aware.onTabHidden(); - } + _onScreen(previousTab, (screen) => screen.onTabHidden()); // Notify and focus new screen - final newState = _screenKeyFor(tab)?.currentState; - if (newState case final TabVisibilityAware aware) { - aware.onTabShown(); - } + _onScreen(tab, (screen) => screen.onTabShown()); // Back-to-home keeps the sidebar focused (chain: content → sidebar → // home → exit); stealing focus here left _isSidebarFocused stuck true // while real focus sat on a content card (#1411). @@ -1548,9 +1498,7 @@ class _MainScreenState extends State // search input, since focusing it auto-opens the on-screen keyboard; the // query submit focuses results instead. if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) { - if (newState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } + _onScreen(tab, (screen) => screen.focusActiveTabIfReady()); } } @@ -1564,9 +1512,7 @@ class _MainScreenState extends State // submit runs the search and focuses results without opening the keyboard. if (tab == NavigationTabId.search && focusSearchInput) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.focusSearchInput(); - } + _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); } } @@ -1576,12 +1522,8 @@ class _MainScreenState extends State _selectedLibraryGlobalKey = libraryGlobalKey; _selectTab(NavigationTabId.libraries); // Tell LibrariesScreen to load this library after tab switch - if (_librariesKey.currentState case final LibraryLoadable loadable) { - loadable.loadLibraryByKey(libraryGlobalKey); - } - if (_librariesKey.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } + _onScreen(NavigationTabId.libraries, (screen) => screen.loadLibraryByKey(libraryGlobalKey)); + _onScreen(NavigationTabId.libraries, (screen) => screen.focusActiveTabIfReady()); } void _openSettings() { @@ -1672,17 +1614,20 @@ class _MainScreenState extends State ); } - /// Get the GlobalKey for a given tab. - GlobalKey? _screenKeyFor(NavigationTabId tab) { - return switch (tab) { - NavigationTabId.discover => _discoverKey, - NavigationTabId.explore => _exploreKey, - NavigationTabId.libraries => _librariesKey, - NavigationTabId.liveTv => _liveTvKey, - NavigationTabId.search => _searchKey, - NavigationTabId.downloads => _downloadsKey, - NavigationTabId.settings => _settingsKey, - }; + /// Invoke [fn] on the tab's current [State] when it exists and implements + /// the capability [T]. Screens are only built for visible tabs and mount a + /// frame later, so a missing key or a non-matching state is a no-op. + void _onScreen(NavigationTabId tab, void Function(T state) fn) { + if (_screenKeys[tab]?.currentState case final T state) fn(state); + } + + /// Full-refresh the primary content tabs. Shared by the online-entry hook + /// ([_primeOnlineServices]) and the profile-switch invalidation + /// ([_invalidateAllScreens]), which refresh the same set. + void _fullRefreshContentTabs() { + for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) { + _onScreen(tab, (screen) => screen.fullRefresh()); + } } Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index a16f643b..365f72b1 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -268,7 +268,13 @@ PageRoute mediaDetailRoute({ } class _MediaDetailScreenState extends State - with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware { + with + WatchStateAware, + DeletionAware, + DeletionMirrorsWatchState, + MountedSetStateMixin, + ServerBoundMediaMixin, + RouteAware { /// Public input alias — used as the live source of truth until the detail /// fetch returns. Holds backend-neutral [MediaItem] data. MediaItem get _metadata => _fullMetadata ?? widget.metadata; @@ -394,7 +400,9 @@ class _MediaDetailScreenState extends State @override bool get isServerBoundOffline => widget.isOffline; - // WatchStateAware: watch the show/movie and all season/episode ratingKeys + // WatchStateAware: watch the show/movie and all season/episode ratingKeys. + // DeletionMirrorsWatchState reuses these three getters for deletion events — + // the same items are on screen either way. @override Set? get watchedIds { final keys = {_metadata.id}; @@ -534,36 +542,6 @@ class _MediaDetailScreenState extends State } } - @override - Set? get deletionIds { - final keys = {_metadata.id}; - for (final season in _seasons) { - keys.add(season.id); - } - for (final ep in _episodes) { - keys.add(ep.id); - } - return keys; - } - - @override - String? get deletionServerId => serverBoundServerId; - - @override - Set? get deletionGlobalKeys { - final serverId = serverBoundServerId; - if (serverId == null) return null; - - final keys = {toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))}; - for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId))); - } - for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId))); - } - return keys; - } - @override void onDeletionEvent(DeletionEvent event) { // Download-only deletions should only remove items when viewing offline content @@ -3592,6 +3570,42 @@ class _MediaDetailScreenState extends State ); } + /// The ordered metadata fields the TV detail line renders and its announcement reads, + /// built through [text] for plain fields and [rating] for the rating slot. + List _tvDetailMetadataParts( + MediaItem metadata, { + required T Function(String value) text, + required T? Function(MediaItem item) rating, + }) { + final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata; + final parts = []; + + void add(T? part) { + if (part != null) parts.add(part); + } + + final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); + if (lineMetadata.isEpisode && episodeLabel != null) add(text(episodeLabel)); + if (lineMetadata.isMovie) { + add(text(t.discover.movie)); + } else if (lineMetadata.isShow) { + add(text(t.discover.tvShow)); + } + add(rating(lineMetadata)); + if (lineMetadata.contentRating != null) add(text(formatContentRating(lineMetadata.contentRating!))); + if (lineMetadata.durationMs != null) add(text(formatDurationTextual(lineMetadata.durationMs!))); + if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { + add(text(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!))); + } else if (lineMetadata.year != null) { + add(text(lineMetadata.year.toString())); + } + for (final label in buildMediaQualityLabels(lineMetadata)) { + add(text(label)); + } + + return parts; + } + String _tvDetailInformationSemanticLabel( MediaItem metadata, { required String? description, @@ -3608,23 +3622,13 @@ class _MediaDetailScreenState extends State add(metadata.displayTitle); if (!identical(lineMetadata, metadata)) add(lineMetadata.displayTitle); - final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); - if (lineMetadata.isEpisode) add(episodeLabel); - if (lineMetadata.isMovie) { - add(t.discover.movie); - } else if (lineMetadata.isShow) { - add(t.discover.tvShow); - } - add(MediaRatingBadge.semanticLabelForMedia(lineMetadata, fallbackItem: metadata)); - if (lineMetadata.contentRating != null) add(formatContentRating(lineMetadata.contentRating!)); - if (lineMetadata.durationMs != null) add(formatDurationTextual(lineMetadata.durationMs!)); - if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { - add(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!)); - } else if (lineMetadata.year != null) { - add(lineMetadata.year.toString()); - } - for (final label in buildMediaQualityLabels(lineMetadata)) { - add(label); + final fields = _tvDetailMetadataParts( + metadata, + text: (value) => value, + rating: (item) => MediaRatingBadge.semanticLabelForMedia(item, fallbackItem: metadata), + ); + for (final field in fields) { + add(field); } if (genres.isNotEmpty) add(genres.join(', ')); add(description); @@ -3681,60 +3685,32 @@ class _MediaDetailScreenState extends State } Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) { - final lineMetadata = _tvDetailFocusedEpisode.value ?? metadata; - final episodeLabel = formatSeasonEpisodeLabel(lineMetadata.parentIndex, lineMetadata.index); - final qualityLabels = buildMediaQualityLabels(lineMetadata); final textStyle = TextStyle( color: _tvDetailForegroundColor(context), fontSize: 18 * scale, fontWeight: .w700, letterSpacing: 0.1, ); - final children = []; - - void addSeparator() { - if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle)); - } - - void addTextPart(String text) { - addSeparator(); - children.add(Text(text, maxLines: 1, style: textStyle)); - } - - void addWidgetPart(Widget widget) { - addSeparator(); - children.add(widget); - } - - if (lineMetadata.isEpisode && episodeLabel != null) addTextPart(episodeLabel); - if (lineMetadata.isMovie) { - addTextPart(t.discover.movie); - } else if (lineMetadata.isShow) { - addTextPart(t.discover.tvShow); - } - final ratingBadge = MediaRatingBadge.inlineForMedia( - item: lineMetadata, - fallbackItem: metadata, - foregroundColor: textStyle.color, - iconSize: textStyle.fontSize, - spacing: 4 * scale, - textStyle: textStyle, + final fields = _tvDetailMetadataParts( + metadata, + text: (value) => Text(value, maxLines: 1, style: textStyle), + rating: (item) => MediaRatingBadge.inlineForMedia( + item: item, + fallbackItem: metadata, + foregroundColor: textStyle.color, + iconSize: textStyle.fontSize, + spacing: 4 * scale, + textStyle: textStyle, + ), ); - if (ratingBadge != null) { - addWidgetPart(ratingBadge); - } - if (lineMetadata.contentRating != null) addTextPart(formatContentRating(lineMetadata.contentRating!)); - if (lineMetadata.durationMs != null) addTextPart(formatDurationTextual(lineMetadata.durationMs!)); - if (lineMetadata.isEpisode && lineMetadata.originallyAvailableAt != null) { - addTextPart(formatAbbreviatedDate(lineMetadata.originallyAvailableAt!)); - } else if (lineMetadata.year != null) { - addTextPart(lineMetadata.year.toString()); - } - for (final label in qualityLabels) { - addTextPart(label); - } - if (children.isEmpty) return const SizedBox.shrink(); + if (fields.isEmpty) return const SizedBox.shrink(); + + final children = []; + for (final field in fields) { + if (children.isNotEmpty) children.add(Text(' • ', maxLines: 1, style: textStyle)); + children.add(field); + } return SingleChildScrollView( scrollDirection: Axis.horizontal, diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index 68e2e46c..813524be 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -122,23 +122,14 @@ class _MetadataEditScreenState extends State { final draft = _draft; if (draft == null || _isCommitting) return; final currentValue = draft.value(field.id) ?? ''; - final result = multiline - ? await showTextInputDialog( - context, - title: field.label, - labelText: field.label, - initialValue: currentValue, - allowEmpty: true, - multiline: true, - ) - : await showTextInputDialog( - context, - title: field.label, - labelText: field.label, - hintText: '', - initialValue: currentValue, - allowEmpty: true, - ); + final result = await showTextInputDialog( + context, + title: field.label, + labelText: field.label, + initialValue: currentValue, + allowEmpty: true, + multiline: multiline, + ); if (result != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() => draft.setValue(field.id, result)); diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index 6e5679fd..60e48104 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -28,14 +28,12 @@ import '../../widgets/app_icon.dart'; import '../../widgets/background_download_warning_banner.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/download_status_icon.dart'; -import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/media_context_menu.dart'; import '../../widgets/music/mini_player.dart'; import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/music/track_row.dart'; import '../../widgets/optimized_media_image.dart'; -import '../../widgets/overlay_sheet.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -391,35 +389,15 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), - ), - ], - ), - ), - ), - ), + return buildDetailScaffold( + slivers: [ + CustomAppBar(title: Text(widget.album.displayTitle)), + SliverToBoxAdapter(child: _buildHeader()), + ...buildStateSlivers(), + if (hasItems) _buildTrackList(), + // Keep the last rows reachable above the floating mini-player. + SliverToBoxAdapter(child: SizedBox(height: context.watch()?.overlayHeight ?? 0)), + ], ); } } diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index 60b31c68..b349c231 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -5,7 +5,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../media/ids.dart'; import '../../media/media_item.dart'; @@ -16,17 +15,14 @@ import '../../utils/formatters.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.dart'; -import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/collapsible_text.dart'; import '../../widgets/desktop_app_bar.dart'; -import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/music/mini_player.dart'; import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/optimized_media_image.dart'; -import '../../widgets/overlay_sheet.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -78,34 +74,19 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen _playAll({bool shuffle = false}) async { - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - List tracks; - try { - tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id); - } catch (e, stackTrace) { - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle); - showErrorSnackBar(context, message); - return; - } - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - if (tracks.isEmpty) { - showAppSnackBar(context, emptyMessage); - return; - } - await playTracks( + await playFetchedTracks( context, - tracks: tracks, + fetch: () => mediaClient.fetchPlayableDescendants(widget.artist.id), playContext: MusicPlayContext( id: widget.artist.id, title: widget.artist.displayTitle, kind: MusicPlayContextKind.artist, ), + onError: (e, stackTrace) => + showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle)), + onEmpty: () => showAppSnackBar(context, emptyMessage), shuffle: shuffle, ); } @@ -193,36 +174,16 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), - ), - ], - ), - ), - ), - ), + return buildDetailScaffold( + slivers: [ + CustomAppBar(title: Text(widget.artist.displayTitle)), + SliverToBoxAdapter(child: _buildHeader()), + ...buildStateSlivers(), + // Albums arrive newest-first from both backends — no client-side sort. + if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square), + // Keep the last rows reachable above the floating mini-player. + SliverToBoxAdapter(child: SizedBox(height: context.watch()?.overlayHeight ?? 0)), + ], ); } } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 3f944684..8be3f5f6 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,11 +1,9 @@ import 'dart:async'; -import '../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/focusable_button.dart'; import '../../media/library_query.dart'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; @@ -14,6 +12,7 @@ import '../../services/media_list_playback_launcher.dart'; import '../../services/music/music_playback_service.dart'; import '../../services/playlist_items_loader.dart'; import '../../utils/app_logger.dart'; +import '../../utils/content_utils.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/continuation_pagination_coordinator.dart'; import '../../utils/music_navigation.dart'; @@ -34,6 +33,7 @@ import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/listenable_selector.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; +import '../libraries/content_state_builder.dart'; import '../../mixins/grid_focus_node_mixin.dart'; import '../../widgets/overlay_sheet.dart'; @@ -48,10 +48,7 @@ class PlaylistDetailScreen extends StatefulWidget { } class _PlaylistDetailScreenState extends BaseMediaListDetailScreen - with - StandardItemLoader, - GridFocusNodeMixin, - FocusableDetailScreenMixin { + with GridFocusNodeMixin, FocusableDetailScreenMixin { static const int _pageSize = playlistItemsPageSize; @override @@ -95,24 +92,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen(); - final intent = service.beginPlayIntent(); - List tracks; - if (_isPlaylistFullyLoaded) { - tracks = items; - } else { - try { - tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id); - } catch (e, stackTrace) { - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title); - showErrorSnackBar(context, message); - return; - } - } - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle); + await playFetchedTracks( + context, + fetch: () async => _isPlaylistFullyLoaded ? items : await fetchAllPlaylistItems(mediaClient, widget.playlist.id), + playContext: _musicPlayContext, + onError: (e, stackTrace) => + showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title)), + startTrack: startTrack, + shuffle: shuffle, + ); } @override @@ -120,7 +108,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen((p) => p.hasSyncRule(ruleKey)); @@ -130,19 +118,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen().syncRuleKeyForClient( - mediaClient, - widget.playlist.id, - serverId: ServerId(serverId), - ); - } - - Future _managePlaylistSyncRule() => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _playlistSyncRuleKey()); - - Future _removePlaylistSyncRule() => removeSyncRuleAndSnack( - context, - downloadProvider: context.read(), - globalKey: _playlistSyncRuleKey(), - displayTitle: widget.playlist.title, - ); - // Focus management for regular (non-smart) reorderable lists final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list'); final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry'); @@ -231,11 +195,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen> fetchItems() async { - return fetchAllPlaylistItems(mediaClient, widget.playlist.id); - } - @override Future loadItems() async { if (mounted) { @@ -323,11 +282,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen { candidateSliver = SliverList( delegate: SliverChildBuilderDelegate((context, index) { final cand = candidates[index]; - // M3E connected-group geometry: large outer corners, small - // inner corners, hairline gaps between tiles. final tokensRef = tokens(context); - final tileRadii = BorderRadius.vertical( - top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(index == candidates.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); + final tileRadii = groupItemRadii(context, index, candidates.length); return Padding( padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0), child: FocusableWrapper( @@ -299,6 +294,8 @@ class _BorrowConnectionScreenState extends State { final parentId = cand.source.parentConnectionId; final homeUuid = cand.source.plexHomeUserUuid; if (parentId == null || homeUuid == null) return false; + // Built before the await: capturing the prompt needs a live element. + final promptForPin = dialogPinPrompt(context, cand.source.displayName); final parent = await context.read().getPlexAccount(parentId); if (parent == null) { if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount); @@ -308,10 +305,7 @@ class _BorrowConnectionScreenState extends State { account: parent, homeUserUuid: homeUuid, requiresPin: true, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, + promptForPin: promptForPin, logLabel: cand.source.displayName, ); if (!result.succeeded) { @@ -330,10 +324,7 @@ class _BorrowConnectionScreenState extends State { account: account, homeUserUuid: cand.pc.userIdentifier, requiresPin: cand.source.plexProtected, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, + promptForPin: dialogPinPrompt(context, cand.source.displayName), persistTo: pcRegistry, persistProfileId: widget.targetProfile.id, logLabel: cand.source.displayName, @@ -344,14 +335,7 @@ class _BorrowConnectionScreenState extends State { } return; } - if (mounted) { - unawaited(context.read().rebindIfActive(widget.targetProfile.id)); - if (widget.popOnSuccess) { - Navigator.of(context).pop(true); - return; - } - showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); - } + _finishBorrow(); } Future _borrowJellyfin(_BorrowCandidate cand) async { @@ -366,14 +350,19 @@ class _BorrowConnectionScreenState extends State { tokenAcquiredAt: DateTime.now(), ), ); - if (mounted) { - unawaited(context.read().rebindIfActive(widget.targetProfile.id)); - if (widget.popOnSuccess) { - Navigator.of(context).pop(true); - return; - } - showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); + _finishBorrow(); + } + + /// Shared tail of every successful borrow: rebind the target profile when + /// it is the active one, then pop with the result or confirm in place. + void _finishBorrow() { + if (!mounted) return; + unawaited(context.read().rebindIfActive(widget.targetProfile.id)); + if (widget.popOnSuccess) { + Navigator.of(context).pop(true); + return; } + showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); } } diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index fd9a693c..5eee1f67 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -8,6 +8,7 @@ import '../../focus/key_event_utils.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; +import '../../profiles/plex_home_switch.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/clickable_cursor.dart'; @@ -698,6 +699,14 @@ Future showPinEntryDialog(BuildContext context, String userName, {Strin ); } +/// The [PlexHomeSwitchPinPrompt] every UI-side `mintPlexHomeUserToken` caller +/// needs: show [showPinEntryDialog] for [displayName], or cancel the switch +/// once [context] is gone. Only the *use* is guarded — build it before the +/// caller's first await, while [context] is still live. +PlexHomeSwitchPinPrompt dialogPinPrompt(BuildContext context, String displayName) => + ({String? errorMessage}) async => + context.mounted ? showPinEntryDialog(context, displayName, errorMessage: errorMessage) : null; + /// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null /// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch] /// (or no-op if not provided) and returns null — the helper keeps the UX diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index 39c04303..bfae71ee 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -10,21 +10,13 @@ import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/plex/plex_home_user.dart'; import '../../profiles/active_profile_binder.dart'; -import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_avatar.dart'; -import '../../profiles/profile_connection_cleanup.dart'; import '../../profiles/profile_connection.dart'; import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../profiles/profiles_view.dart'; -import '../../providers/download_provider.dart'; -import '../../providers/discover_provider.dart'; -import '../../providers/hidden_libraries_provider.dart'; -import '../../providers/multi_server_provider.dart'; -import '../../services/storage_service.dart'; -import '../../services/system_shelf_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/app_icon.dart'; @@ -167,20 +159,11 @@ class _ProfileDetailScreenState extends State with Controll isDestructive: true, ); if (!confirmed || !mounted) return; - final downloads = context.read(); - final pcRegistry = context.read(); - final connRegistry = context.read(); - final storage = context.read(); - final multiServer = context.read(); - final hiddenLibraries = context.read(); - final discover = context.read(); - final binder = context.read(); - final active = context.read(); - final shelf = SystemShelfService(); - final endedOwner = active.activeId == _profile.id ? _profile.id : null; + final scope = SessionTeardownScope.of(context); + final endedOwner = scope.active.activeId == _profile.id ? _profile.id : null; if (endedOwner != null) { - await shelf.endProfileSession(endedOwner); + await scope.shelf.endProfileSession(endedOwner); } try { @@ -189,38 +172,26 @@ class _ProfileDetailScreenState extends State with Controll // Plex account sharing the server, another Jellyfin user). final retainedServerIds = await _retainedServerIds( excludingConnectionId: conn.id, - profileConnections: pcRegistry, - connections: connRegistry, + profileConnections: scope.profileConnections, + connections: scope.connections, ); - await downloads.releaseDownloadsForProfileServers( + await scope.downloads.releaseDownloadsForProfileServers( _profile.id, _serverIdsForConnection(conn).difference(retainedServerIds), ); - await removeProfileConnectionAndCleanup( - profileId: _profile.id, - connection: conn, - profileConnections: pcRegistry, - connections: connRegistry, - storage: storage, - serverManager: multiServer.serverManager, - ); - await hiddenLibraries?.refresh(); - await binder.rebindIfActive(_profile.id); - if (endedOwner != null && active.activeId == endedOwner) { - shelf.beginProfileSession(endedOwner); - if (multiServer.hasConnectedServers) await discover?.load(); + await scope.cleanup.removeProfileConnection(profileId: _profile.id, connection: conn); + await scope.hiddenLibraries?.refresh(); + // Deliberately not `resumeFreshSystemShelf`: a rebind failure on the + // success path must reach the catch below so the recovery attempt — + // and the rethrow — still run. + await scope.binder.rebindIfActive(_profile.id); + if (endedOwner != null && scope.active.activeId == endedOwner) { + scope.shelf.beginProfileSession(endedOwner); + if (scope.multiServer.hasConnectedServers) await scope.discover?.load(); } } catch (_) { - if (endedOwner != null && active.activeId == endedOwner) { - try { - await binder.rebindIfActive(endedOwner); - if (active.activeId == endedOwner) { - shelf.beginProfileSession(endedOwner); - if (multiServer.hasConnectedServers) await discover?.load(); - } - } catch (_) { - // Keep the shelf empty when the surviving profile cannot be rebound. - } + if (endedOwner != null) { + await resumeFreshSystemShelf(scope, endedOwner); } rethrow; } @@ -228,7 +199,10 @@ class _ProfileDetailScreenState extends State with Controll /// Server ids the profile keeps after removing [excludingConnectionId]: /// its other join rows plus, for Plex Home profiles, the implicit parent - /// account. + /// account. Raw ids, matching the download keys this is differenced + /// against; `_serverIdsForProfile` in profile_connection_cleanup.dart is + /// ServerId-typed and ignores the parent, so the two are not the same + /// projection. Future> _retainedServerIds({ required String excludingConnectionId, required ProfileConnectionRegistry profileConnections, @@ -260,6 +234,9 @@ class _ProfileDetailScreenState extends State with Controll unawaited(context.read().rebindIfActive(_profile.id)); } + // Raw machine ids rather than the ServerId-typed twin in + // profile_connection_cleanup.dart: these are differenced against retained + // ids and matched to download global keys, which carry the unparsed id. Set _serverIdsForConnection(Connection conn) { return switch (conn) { PlexAccountConnection(:final servers) => servers.map((s) => s.clientIdentifier).toSet(), diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index b4abf2c2..56424695 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -224,13 +224,8 @@ class _ProfileSwitchScreenState extends State with MountedS delegate: SliverChildBuilderDelegate((context, index) { final profile = profiles[index]; final isActive = profile.id == activeId; - // M3E connected-group geometry: large outer corners, small inner - // corners, hairline gaps between tiles. final tokensRef = tokens(context); - final tileRadii = BorderRadius.vertical( - top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(index == profiles.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); + final tileRadii = groupItemRadii(context, index, profiles.length); final isFirstSelectable = autofocusFirst && index == 0; final profileFocusNode = _profileFocusNode(profile); final menuFocusNode = _profileMenuFocusNode(profile); diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart index 52b4a70d..e1d2df77 100644 --- a/lib/screens/profile/profile_teardown.dart +++ b/lib/screens/profile/profile_teardown.dart @@ -49,6 +49,13 @@ class SessionTeardownScope { MultiServerManager get serverManager => multiServer.serverManager; + ProfileConnectionCleanup get cleanup => ProfileConnectionCleanup( + profileConnections: profileConnections, + connections: connections, + storage: storage, + serverManager: serverManager, + ); + SessionTeardownScope.of(BuildContext context) : active = context.read(), binder = context.read(), @@ -80,13 +87,9 @@ Future settleSessionAfterRemoval( bool rebindIfActiveKept = false, String? endedShelfOwner, }) async { - final result = await resolvePostRemovalState( + final result = await scope.cleanup.resolvePostRemovalState( profileRegistry: scope.profileRegistry, - profileConnections: scope.profileConnections, - connections: scope.connections, plexHomeUsers: scope.plexHome.current, - storage: scope.storage, - serverManager: scope.serverManager, ); if (result.route == PostRemovalRoute.signedOut) { @@ -199,13 +202,7 @@ Future deleteProfile(BuildContext context, Profile profile) async { await scope.downloads.deleteDownloadsForProfile(profile.id); await scope.database.deleteSyncRulesForProfile(profile.id); await scope.database.deleteWatchActionsForProfile(profile.id); - await removeAllProfileConnectionsAndCleanup( - profileId: profile.id, - profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, - ); + await scope.cleanup.removeAllProfileConnections(profile.id); await scope.profileRegistry.remove(profile.id); await scope.storage.clearProfileLastUsed(profile.id); await scope.storage.clearUserScopedPreferencesForProfile(profile.id); @@ -263,14 +260,7 @@ Future confirmAndSignOutPlexAccount(BuildContext context, {required String await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds); } - await removePlexAccountConnectionAndCleanup( - account: account, - profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, - plannedRemoval: removal, - ); + await scope.cleanup.removePlexAccountConnection(account, plannedRemoval: removal); for (final profileId in removal.removedVirtualProfileIds) { await scope.database.deleteSyncRulesForProfile(profileId); await scope.database.deleteWatchActionsForProfile(profileId); diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 676446dd..31d79524 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -1,11 +1,9 @@ import 'package:flutter/material.dart'; -import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../exceptions/media_server_exceptions.dart'; import '../focus/focusable_text_field.dart'; -import '../focus/focusable_button.dart'; import '../i18n/strings.g.dart'; import '../media/ids.dart'; import '../media/media_item.dart'; @@ -20,7 +18,7 @@ import '../utils/snackbar_helper.dart'; import '../utils/media_server_http_client.dart'; import '../widgets/desktop_app_bar.dart'; import '../widgets/loading_indicator_box.dart'; -import '../widgets/pill_input_decoration.dart'; +import '../widgets/search_input_field.dart'; import '../widgets/focusable_media_card.dart'; import '../utils/focus_utils.dart'; import 'libraries/state_messages.dart'; @@ -37,7 +35,6 @@ class _SearchScreenState extends State with Refreshable, FullRefreshable, SearchInputFocusable, FocusableTab, MountedSetStateMixin, DebouncedMediaSearch { String? _focusResultsForQuery; final _tvTextInputController = TvTextInputController(); - final _clearFocusNode = FocusNode(debugLabel: 'Search.clear'); AbortController? _activeSearchAbort; ({String query, SearchAggregationResult result})? _pendingSearchOutcome; @@ -47,17 +44,6 @@ class _SearchScreenState extends State FocusUtils.requestFocusAfterBuild(this, searchFocusNode); } - @override - void dispose() { - _clearFocusNode.dispose(); - super.dispose(); - } - - void _clearSearch() { - searchController.clear(); - searchFocusNode.requestFocus(); - } - @override String get searchDebugLabel => 'Search'; @@ -223,31 +209,21 @@ class _SearchScreenState extends State Widget _buildResultsList(BuildContext context) { final multiServer = context.watch(); final showServerName = multiServer.totalServerCount > 1; - return SliverPadding( - padding: const EdgeInsets.all(16), - sliver: SliverList( - delegate: SliverChildBuilderDelegate( - (context, index) { - final item = searchResults[index]; - return FocusableMediaCard( - key: Key(item.globalKey), - item: item, - forceListMode: true, - disableScale: true, - focusNode: index == 0 ? firstResultFocusNode : null, - onRefresh: updateItem, - onListRefresh: refresh, - onNavigateLeft: _navigateToSidebar, - onNavigateUp: index == 0 ? focusSearchInput : null, - showServerName: showServerName, - ); - }, - childCount: searchResults.length, - addAutomaticKeepAlives: false, - addSemanticIndexes: false, - ), - ), - ); + return buildResultsSliver((context, index) { + final item = searchResults[index]; + return FocusableMediaCard( + key: Key(item.globalKey), + item: item, + forceListMode: true, + disableScale: true, + focusNode: index == 0 ? firstResultFocusNode : null, + onRefresh: updateItem, + onListRefresh: refresh, + onNavigateLeft: _navigateToSidebar, + onNavigateUp: index == 0 ? focusSearchInput : null, + showServerName: showServerName, + ); + }); } @override @@ -259,49 +235,22 @@ class _SearchScreenState extends State slivers: [ DesktopSliverAppBar(title: Text(t.common.search), floating: true), SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), - child: Stack( - alignment: Alignment.centerRight, - children: [ - FocusableTextField( - controller: searchController, - focusNode: searchFocusNode, - tvTextInputController: _tvTextInputController, - textInputAction: TextInputAction.search, - onNavigateLeft: _navigateToSidebar, - onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null, - onNavigateDown: searchResults.isNotEmpty && !isSearching - ? firstResultFocusNode.requestFocus - : null, - onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, - onBack: () { - if (searchController.text.isNotEmpty) { - searchController.clear(); - } else { - _navigateToSidebar(); - } - }, - decoration: pillInputDecoration( - context, - hintText: t.search.hint, - prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), - suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null, - ), - ), - if (searchController.text.isNotEmpty) - FocusableButton( - focusNode: _clearFocusNode, - onPressed: _clearSearch, - onNavigateLeft: searchFocusNode.requestFocus, - onNavigateDown: searchResults.isNotEmpty && !isSearching - ? firstResultFocusNode.requestFocus - : null, - autoScroll: false, - child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch), - ), - ], - ), + child: SearchInputField( + controller: searchController, + focusNode: searchFocusNode, + debugLabel: searchDebugLabel, + hintText: t.search.hint, + tvTextInputController: _tvTextInputController, + onNavigateLeft: _navigateToSidebar, + onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null, + onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null, + onBack: () { + if (searchController.text.isNotEmpty) { + searchController.clear(); + } else { + _navigateToSidebar(); + } + }, ), ), if (isSearching) diff --git a/lib/screens/settings/add_connection_screen.dart b/lib/screens/settings/add_connection_screen.dart index 9feeed0a..5d9e5d84 100644 --- a/lib/screens/settings/add_connection_screen.dart +++ b/lib/screens/settings/add_connection_screen.dart @@ -55,12 +55,6 @@ class AddConnectionScreen extends StatelessWidget { ), ]; final tokensRef = tokens(context); - // M3E connected-group geometry: large outer corners, small inner corners, - // hairline gaps. - BorderRadius radiiFor(int i) => BorderRadius.vertical( - top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(i == options.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); return FocusedScrollScaffold( title: Text( scoped @@ -75,7 +69,7 @@ class AddConnectionScreen extends StatelessWidget { for (var i = 0; i < options.length; i++) ...[ if (i > 0) SizedBox(height: tokensRef.groupGap), _BackendCard( - borderRadius: radiiFor(i), + borderRadius: groupItemRadii(context, i, options.length), leading: options[i].backend != null ? BackendBadge(backend: options[i].backend!, size: 28) : const AppIcon(Symbols.share_rounded, fill: 1, size: 28), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 239e1349..3c0f7a2b 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -360,13 +360,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _discoveredServerFocusNodes[_localServers.last.id]?.requestFocus(); } - List _enteredUrls() { - return _urlController.text - .split(RegExp(r'[\n,]+')) - .map((url) => url.trim()) - .where((url) => url.isNotEmpty) - .toList(growable: false); - } + List _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlController.text); /// Shared persistence path for both username/password and Quick Connect: /// atomically provision the optional first-run profile, connection, and @@ -663,12 +657,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta if (_localServers.isEmpty) return const []; final tokensRef = tokens(context); - // M3E connected-group geometry: large outer corners, small inner corners, - // hairline gaps between tiles. - BorderRadius radiiFor(int i) => BorderRadius.vertical( - top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(i == _localServers.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); return [ const SizedBox(height: 16), Text(t.addServer.localServers, style: theme.textTheme.titleSmall), @@ -677,7 +665,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta if (i > 0) SizedBox(height: tokensRef.groupGap), _DiscoveredJellyfinServerTile( server: server, - borderRadius: radiiFor(i), + borderRadius: groupItemRadii(context, i, _localServers.length), focusNode: _discoveredServerFocusNodes[server.id], onNavigateUp: () { final index = _localServers.indexOf(server); diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index 0207fd97..404a3fd2 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -86,12 +86,11 @@ class _AddPlexAccountScreenState extends State with AsyncF // to the profile, remove it again so a cancelled attach doesn't // leave a global account behind. if (!registration.existedBefore) { - await removePlexAccountConnectionAndCleanup( - account: connection, + await ProfileConnectionCleanup( profileConnections: pcRegistry, connections: connRegistry, storage: storage, - ); + ).removePlexAccountConnection(connection); } if (mounted) Navigator.of(context).pop(false); return true; diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 95d7723b..4dc430d5 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -212,14 +212,12 @@ class AppearanceSettingsScreen extends StatelessWidget { Widget _themeSelector() { return Consumer( builder: (context, themeProvider, _) { - return SettingSelectionTile( + return SettingSelectionTile( pref: SettingsService.themeMode, icon: themeProvider.themeModeIcon, title: t.settings.theme, subtitleBuilder: themeModeLabel, options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(), - decode: (v) => v, - encode: (v) => v, ); }, ); @@ -293,7 +291,7 @@ class AppearanceSettingsScreen extends StatelessWidget { ); } - Widget _viewModeSelector() => SettingSegmentedTile( + Widget _viewModeSelector() => SettingSegmentedTile( pref: SettingsService.viewMode, icon: Symbols.view_list_rounded, title: t.settings.viewMode, @@ -301,11 +299,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: ViewMode.grid, label: Text(t.settings.gridView)), ButtonSegment(value: ViewMode.list, label: Text(t.settings.listView)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _episodePosterModeSelector() => SettingSegmentedTile( + Widget _episodePosterModeSelector() => SettingSegmentedTile( pref: SettingsService.episodePosterMode, icon: Symbols.image_rounded, title: t.settings.episodePosterMode, @@ -314,11 +310,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)), ButtonSegment(value: EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _continueWatchingActionSelector() => SettingSegmentedTile( + Widget _continueWatchingActionSelector() => SettingSegmentedTile( pref: SettingsService.continueWatchingAction, icon: Symbols.play_circle_rounded, title: t.settings.continueWatchingAction, @@ -326,11 +320,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: ContinueWatchingAction.play, label: Text(t.settings.continueWatchingPlay)), ButtonSegment(value: ContinueWatchingAction.details, label: Text(t.settings.continueWatchingDetails)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _episodeActionSelector() => SettingSegmentedTile( + Widget _episodeActionSelector() => SettingSegmentedTile( pref: SettingsService.episodeAction, icon: Symbols.tv_rounded, title: t.settings.episodeAction, @@ -338,8 +330,6 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: EpisodeAction.play, label: Text(t.settings.episodePlay)), ButtonSegment(value: EpisodeAction.details, label: Text(t.settings.episodeDetails)), ], - decode: (v) => v, - encode: (v) => v, ); // Sections offered as a startup destination, in display order. Live TV is @@ -353,14 +343,12 @@ class AppearanceSettingsScreen extends StatelessWidget { String _startupSectionLabel(NavigationTabId id) => allNavigationTabs.firstWhere((t) => t.id == id).getLabel(); - Widget _startupSectionSelector() => SettingSelectionTile( + Widget _startupSectionSelector() => SettingSelectionTile( pref: SettingsService.startupSection, icon: Symbols.start_rounded, title: t.settings.startupSection, subtitleBuilder: _startupSectionLabel, options: _startupSectionOptions.map((id) => DialogOption(value: id, title: _startupSectionLabel(id))).toList(), - decode: (v) => v, - encode: (v) => v, ); String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) { @@ -369,32 +357,29 @@ class AppearanceSettingsScreen extends StatelessWidget { VisualEffectsSetting.reduced => t.settings.visualEffectsReduced, }; - Widget _visualEffectsSelector(BuildContext context) => - SettingSelectionTile( - pref: SettingsService.visualEffects, - icon: Symbols.animation_rounded, - title: t.settings.visualEffects, - subtitleBuilder: _visualEffectsLabel, - options: [ - DialogOption( - value: VisualEffectsSetting.auto, - title: t.settings.visualEffectsAuto, - subtitle: t.settings.visualEffectsAutoDescription, - ), - DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull), - DialogOption( - value: VisualEffectsSetting.reduced, - title: t.settings.visualEffectsReduced, - subtitle: t.settings.visualEffectsReducedDescription, - ), - ], - decode: (v) => v, - encode: (v) => v, - onAfterWrite: (value) { - DevicePerformance.setOverrideSync(value); - _restartApp(context); - }, - ); + Widget _visualEffectsSelector(BuildContext context) => SettingSelectionTile( + pref: SettingsService.visualEffects, + icon: Symbols.animation_rounded, + title: t.settings.visualEffects, + subtitleBuilder: _visualEffectsLabel, + options: [ + DialogOption( + value: VisualEffectsSetting.auto, + title: t.settings.visualEffectsAuto, + subtitle: t.settings.visualEffectsAutoDescription, + ), + DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull), + DialogOption( + value: VisualEffectsSetting.reduced, + title: t.settings.visualEffectsReduced, + subtitle: t.settings.visualEffectsReducedDescription, + ), + ], + onAfterWrite: (value) { + DevicePerformance.setOverrideSync(value); + _restartApp(context); + }, + ); String _getLanguageDisplayName(AppLocale locale) { switch (locale) { diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 511baef6..008af827 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -69,13 +69,7 @@ class _EditJellyfinConnectionScreenState extends State _enteredUrls() { - return _urlsController.text - .split(RegExp(r'[\n,]+')) - .map((url) => url.trim()) - .where((url) => url.isNotEmpty) - .toList(growable: false); - } + List _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlsController.text); @override Widget build(BuildContext context) { diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index ab34ccc1..786e711c 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -5,7 +5,7 @@ import '../../i18n/strings.g.dart'; import '../../models/hotkey_model.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../utils/app_logger.dart'; -import '../../services/shader_service.dart'; +import '../../services/shortcut_action.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; @@ -26,9 +26,7 @@ class KeyboardShortcutsScreen extends StatelessWidget { listenable: keyboardService, builder: (context, _) { final hotkeys = keyboardService.hotkeys; - final actions = hotkeys.keys - .where((action) => action != 'shader_toggle' || ShaderService.isPlatformSupported) - .toList(); + final actions = hotkeys.keys.where((action) => ShortcutAction.fromId(action)?.isSupported ?? true).toList(); return FocusedScrollScaffold( title: Text(t.settings.keyboardShortcuts), slivers: [ diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index fc6f6c41..3f7fd680 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -266,7 +266,7 @@ class _PlaybackSettingsScreenState extends State { ], ); - Widget _playerBackendSelector() => SettingSegmentedTile( + Widget _playerBackendSelector() => SettingSegmentedTile( pref: SettingsService.useExoPlayer, icon: Symbols.play_circle_rounded, title: t.settings.playerBackend, @@ -274,8 +274,6 @@ class _PlaybackSettingsScreenState extends State { ButtonSegment(value: true, label: Text(t.settings.exoPlayer)), ButtonSegment(value: false, label: Text(t.settings.mpv)), ], - decode: (s) => s, - encode: (s) => s, ); Widget _externalPlayerTile() => SettingsBuilder( @@ -391,7 +389,7 @@ class _PlaybackSettingsScreenState extends State { subtitle: t.settings.tunneledPlaybackDescription, ); - Widget _dvConversionModeTile() => SettingSelectionTile( + Widget _dvConversionModeTile() => SettingSelectionTile( pref: SettingsService.dvConversionMode, icon: Symbols.hdr_strong_rounded, title: t.settings.dvConversionMode, @@ -399,8 +397,6 @@ class _PlaybackSettingsScreenState extends State { options: DvConversionModePreference.values .map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m))) .toList(), - decode: (m) => m, - encode: (m) => m, ); String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) { @@ -412,7 +408,7 @@ class _PlaybackSettingsScreenState extends State { Widget _bufferSizeTile() { final bufferOptions = const [0, 64, 128, 256, 512, 1024]; - return SettingSelectionTile( + return SettingSelectionTile( pref: SettingsService.bufferSize, icon: Symbols.memory_rounded, title: t.settings.bufferSize, @@ -420,8 +416,6 @@ class _PlaybackSettingsScreenState extends State { options: bufferOptions .map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB')) .toList(), - decode: (s) => s, - encode: (s) => s, onAfterWrite: (value) async { if (Platform.isAndroid && value > 0) { final heapMB = await PlayerAndroid.getHeapSize(); @@ -433,7 +427,7 @@ class _PlaybackSettingsScreenState extends State { ); } - Widget _defaultQualityTile() => SettingSelectionTile( + Widget _defaultQualityTile() => SettingSelectionTile( pref: SettingsService.defaultQualityPreset, icon: Symbols.high_quality_rounded, title: t.settings.defaultQualityTitle, @@ -441,18 +435,14 @@ class _PlaybackSettingsScreenState extends State { options: TranscodeQualityPreset.displayOrder .map((p) => DialogOption(value: p, title: qualityPresetLabel(p))) .toList(), - decode: (p) => p, - encode: (p) => p, ); - Widget _musicQualityTile() => SettingSelectionTile( + Widget _musicQualityTile() => SettingSelectionTile( pref: SettingsService.musicQualityPreset, icon: Symbols.music_note_rounded, title: t.settings.musicQualityTitle, subtitleBuilder: _musicQualityLabel, options: AudioQualityPreset.values.map((p) => DialogOption(value: p, title: _musicQualityLabel(p))).toList(), - decode: (p) => p, - encode: (p) => p, ); String _musicQualityLabel(AudioQualityPreset preset) => diff --git a/lib/screens/settings/services_settings_screen.dart b/lib/screens/settings/services_settings_screen.dart index 0db542aa..835749ab 100644 --- a/lib/screens/settings/services_settings_screen.dart +++ b/lib/screens/settings/services_settings_screen.dart @@ -3,9 +3,8 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; +import '../../models/catalog/catalog_item.dart'; import '../../providers/seerr_account_provider.dart'; -import '../../providers/trackers_provider.dart'; -import '../../providers/trakt_account_provider.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/catalog_source_logo.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -13,8 +12,7 @@ import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_section.dart'; import 'seerr_connect_screen.dart'; import 'seerr_settings_screen.dart'; -import 'tracker_settings_screen.dart'; -import 'trakt_settings_screen.dart'; +import 'tracker_service_info.dart'; /// Unified hub for all connected services: the watch-progress trackers /// (Trakt, MyAnimeList, AniList, Simkl) and the Seerr request server. Each @@ -38,7 +36,7 @@ class ServicesSettingsScreen extends StatelessWidget { ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), - SettingsGroup(children: [_trakt(), _mal(), _anilist(), _simkl(), _seerr()]), + SettingsGroup(children: [for (final info in TrackerServiceInfo.all) _TrackerHubRow(info), _seerr()]), const SizedBox(height: 24), ]), ), @@ -46,78 +44,9 @@ class ServicesSettingsScreen extends StatelessWidget { ); } - Widget _trakt() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/trakt_circlemark.svg', size: 24), - title: t.trakt.title, - username: account.isConnected ? account.username : null, - onTap: () { - if (account.isConnected) { - Navigator.push(context, MaterialPageRoute(builder: (_) => const TraktSettingsScreen())); - } else { - startTraktConnection(context); - } - }, - ), - ); - - Widget _mal() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/mal_mark.svg', size: 24), - title: t.services.names.mal, - username: account.isMalConnected ? account.malUsername : null, - onTap: () { - if (account.isMalConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.mal())), - ); - } else { - startMalConnection(context); - } - }, - ), - ); - - Widget _anilist() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/anilist_mark.svg', size: 24), - title: t.services.names.anilist, - username: account.isAnilistConnected ? account.anilistUsername : null, - onTap: () { - if (account.isAnilistConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.anilist())), - ); - } else { - startAnilistConnection(context); - } - }, - ), - ); - - Widget _simkl() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/simkl_mark.svg', size: 24), - title: t.services.names.simkl, - username: account.isSimklConnected ? account.simklUsername : null, - onTap: () { - if (account.isSimklConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.simkl())), - ); - } else { - startSimklConnection(context); - } - }, - ), - ); - Widget _seerr() => Consumer( builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/seerr_mark.svg', size: 24), + leading: const CatalogSourceLogo(CatalogSourceId.seerr, size: 24), title: t.services.names.seerr, username: account.isConnected ? account.displayName : null, onTap: () { @@ -132,6 +61,31 @@ class ServicesSettingsScreen extends StatelessWidget { ); } +/// Hub row for a watch tracker. Owns the `watch` on that service's account +/// provider so only this row rebuilds when the connection state changes. +class _TrackerHubRow extends StatelessWidget { + final TrackerServiceInfo info; + + const _TrackerHubRow(this.info); + + @override + Widget build(BuildContext context) { + final connected = info.isConnected(context); + return _ServiceHubRow( + leading: CatalogSourceLogo(info.logoSource, size: 24), + title: info.displayName, + username: connected ? info.username(context) : null, + onTap: () { + if (connected) { + Navigator.push(context, MaterialPageRoute(builder: (_) => info.buildSettingsScreen())); + } else { + info.startConnection(context); + } + }, + ); + } +} + class _ServiceHubRow extends StatelessWidget { final Widget leading; final String title; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 09161776..7755e6db 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -26,14 +26,11 @@ import '../../services/saf_storage_service.dart'; import '../../services/settings_export_service.dart'; import '../../providers/theme_provider.dart'; import '../../providers/seerr_account_provider.dart'; -import '../../providers/trackers_provider.dart'; -import '../../providers/trakt_account_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/background_work_diagnostics_service.dart'; import '../../services/settings_service.dart' as settings; import '../../widgets/background_download_warning_banner.dart'; import '../../services/update_service.dart'; -import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; @@ -57,6 +54,7 @@ import 'playback_settings_screen.dart'; import '../profile/profile_switch_screen.dart'; import 'services_settings_screen.dart'; import 'settings_utils.dart'; +import 'tracker_service_info.dart'; import '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { @@ -271,13 +269,12 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildServicesTile() { - return Consumer3( - builder: (context, trakt, trackers, seerr, _) { + // The tracker account providers are watched through [TrackerServiceInfo]. + return Consumer( + builder: (context, seerr, _) { final connectedNames = [ - if (trakt.isConnected) t.trakt.title, - if (trackers.isMalConnected) t.services.names.mal, - if (trackers.isAnilistConnected) t.services.names.anilist, - if (trackers.isSimklConnected) t.services.names.simkl, + for (final info in TrackerServiceInfo.all) + if (info.isConnected(context)) info.displayName, if (seerr.isConnected) t.services.names.seerr, ]; final subtitle = connectedNames.isEmpty ? t.settings.servicesDescription : connectedNames.join(' · '); @@ -668,65 +665,50 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _selectDownloadLocation() async { - try { - String? selectedPath; - String pathType = 'file'; + final changed = await guardSettingsOperation( + context, + operation: 'Download directory selection', + body: () async { + String? selectedPath; + String pathType = 'file'; - if (Platform.isAndroid) { - final safStorage = SafStorageService.instance; - if (!safStorage.supportsDirectoryPicker) { - showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable); - return false; + if (Platform.isAndroid) { + final safStorage = SafStorageService.instance; + if (!safStorage.supportsDirectoryPicker) { + showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable); + return false; + } + selectedPath = await safStorage.pickDirectory(); + if (!mounted) return false; + if (selectedPath != null) pathType = 'saf'; + } else { + selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); + if (!mounted) return false; } - selectedPath = await safStorage.pickDirectory(); - if (!mounted) return false; - if (selectedPath != null) pathType = 'saf'; - } else { - selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); - if (!mounted) return false; - } - if (selectedPath == null) return false; + if (selectedPath == null) return false; - if (pathType == 'file') { - final dir = Directory(selectedPath); - final isWritable = - await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)(dir); - if (!mounted) return false; - if (!isWritable) { - showErrorSnackBar(context, t.settings.downloadLocationInvalid); - return false; + if (pathType == 'file') { + final dir = Directory(selectedPath); + final writableChecker = + widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable; + final isWritable = await writableChecker(dir); + if (!mounted) return false; + if (!isWritable) { + showErrorSnackBar(context, t.settings.downloadLocationInvalid); + return false; + } } - } - await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); - if (!mounted) return false; + await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); + if (!mounted) return false; - // ignore: no-empty-block - setState triggers rebuild to reflect new download path - setState(() {}); - showSuccessSnackBar(context, t.settings.downloadLocationChanged); - return true; - } on DownloadStorageException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } + // ignore: no-empty-block - setState triggers rebuild to reflect new download path + setState(() {}); + showSuccessSnackBar(context, t.settings.downloadLocationChanged); + return true; + }, + ); + return changed ?? false; } Future _resetDownloadLocation() async { @@ -774,29 +756,15 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _handleExportSettings() async { - try { - final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); - if (!mounted || path == null) return; - showSuccessSnackBar(context, t.settings.exportSettingsSuccess); - } on SettingsExportException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } + await guardSettingsOperation( + context, + operation: 'Settings export', + body: () async { + final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); + if (!mounted || path == null) return; + showSuccessSnackBar(context, t.settings.exportSettingsSuccess); + }, + ); } Future _showImportSettingsDialog() async { @@ -811,51 +779,41 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _handleImportSettings() async { - try { - final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); - if (!mounted) return; - if (result == null) return; // user cancelled file picker + await guardSettingsOperation( + context, + operation: 'Settings import', + body: () async { + // The two typed import failures carry their own message, so they are + // handled here instead of falling through to the generic guard. + try { + final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); + if (!mounted) return; + if (result == null) return; // user cancelled file picker - final themeProvider = context.read(); - final hiddenLibrariesProvider = context.read(); - final librariesProvider = context.read(); + final themeProvider = context.read(); + final hiddenLibrariesProvider = context.read(); + final librariesProvider = context.read(); - // Import wrote directly to SharedPreferences, bypassing `write`. Push - // fresh values into active listenables before providers re-read settings. - _settingsService.refreshListenables(); - unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); - await Future.wait([ - themeProvider.reload(), - hiddenLibrariesProvider.refresh(), - if (_keyboardService != null) _keyboardService!.refreshFromStorage(), - ]); - unawaited(librariesProvider.refresh()); + // Import wrote directly to SharedPreferences, bypassing `write`. Push + // fresh values into active listenables before providers re-read settings. + _settingsService.refreshListenables(); + unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); + await Future.wait([ + themeProvider.reload(), + hiddenLibrariesProvider.refresh(), + if (_keyboardService != null) _keyboardService!.refreshFromStorage(), + ]); + unawaited(librariesProvider.refresh()); - if (!mounted) return; - showSuccessSnackBar(context, t.settings.importSettingsSuccess); - } on NoUserSignedInException { - if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); - } on InvalidExportFileException { - if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile); - } on SettingsExportException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } + if (!mounted) return; + showSuccessSnackBar(context, t.settings.importSettingsSuccess); + } on NoUserSignedInException { + if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); + } on InvalidExportFileException { + if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile); + } + }, + ); } Future _checkForUpdates() async { diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 8443923d..7004f78e 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -56,6 +56,32 @@ void showSettingsFailure( if (context.mounted) showErrorSnackBar(context, t.settings.saveFailed); } +/// Runs [body] and reports the recoverable failures that every settings +/// file/platform operation shares — [PlatformException], [FileSystemException] +/// and the site-specific domain exception [E] — through [showSettingsFailure]. +/// Any other exception type is rethrown so programming errors are not swallowed. +/// +/// [context] is resolved before [body] starts, so a failure that lands after the +/// caller was disposed is still logged; only the snackbar is skipped. Returns +/// `null` when the operation failed. +Future guardSettingsOperation( + BuildContext context, { + required String operation, + required Future Function() body, +}) async { + try { + return await body(); + } on Object catch (error, stackTrace) { + if (error is! E && error is! PlatformException && error is! FileSystemException) rethrow; + if (context.mounted) { + showSettingsFailure(context, operation: operation, error: error, stackTrace: stackTrace); + } else { + appLogger.e('$operation failed', error: error, stackTrace: stackTrace); + } + return null; + } +} + void _showSettingsInputDialog({ required BuildContext context, required String title, diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 6562e064..2ae4c00a 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -48,18 +48,16 @@ class SubtitleStylingScreen extends StatelessWidget { SettingsGroup( title: t.subtitlingStyling.text, children: [ - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subAssOverride, icon: Symbols.subtitles_rounded, title: t.subtitlingStyling.assOverride, subtitleBuilder: _assOverrideLabel, options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), // iOS/tvOS avfoundation VO: screen vs video-resolution basis. if (Platform.isIOS) - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subtitleRenderResolution, icon: Symbols.aspect_ratio_rounded, title: t.subtitlingStyling.renderResolution, @@ -68,13 +66,11 @@ class SubtitleStylingScreen extends StatelessWidget { SubtitleRenderResolution.screen, SubtitleRenderResolution.video, ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), // Android libass overlay: full or a fractional render scale (perf knob for // render-bound low-end TVs; heavy/animated signs raster faster at < 1). if (Platform.isAndroid) - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subtitleRenderResolution, icon: Symbols.aspect_ratio_rounded, title: t.subtitlingStyling.renderResolution, @@ -86,8 +82,6 @@ class SubtitleStylingScreen extends StatelessWidget { SubtitleRenderResolution.third, SubtitleRenderResolution.quarter, ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), SettingNumberTile( pref: SettingsService.subtitleFontSize, diff --git a/lib/screens/settings/tracker_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index e16a4344..88ba1082 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -86,7 +86,7 @@ class TrackerLibraryFilterScreen extends StatelessWidget { ), SettingsGroup( children: [ - SettingSegmentedTile( + SettingSegmentedTile( pref: modePref, icon: Symbols.filter_list_rounded, title: t.services.libraryFilter.mode, @@ -100,8 +100,6 @@ class TrackerLibraryFilterScreen extends StatelessWidget { label: Text(t.services.libraryFilter.modeWhitelist), ), ], - decode: (v) => v, - encode: (v) => v, ), ], ), diff --git a/lib/screens/settings/tracker_service_info.dart b/lib/screens/settings/tracker_service_info.dart new file mode 100644 index 00000000..63c81222 --- /dev/null +++ b/lib/screens/settings/tracker_service_info.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../i18n/strings.g.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../providers/trackers_provider.dart'; +import '../../providers/trakt_account_provider.dart'; +import '../../services/trackers/anilist/anilist_tracker.dart'; +import '../../services/trackers/mal/mal_tracker.dart'; +import '../../services/trackers/simkl/simkl_tracker.dart'; +import '../../services/trackers/tracker.dart'; +import '../../services/trackers/tracker_constants.dart'; +import '../../services/trakt/trakt_scrobble_service.dart'; +import 'tracker_settings_screen.dart'; +import 'trakt_settings_screen.dart'; + +/// One watch tracker, described once for every place that lists services: the +/// services hub, the rating sheet, and the settings summary line. +/// +/// [isConnected] and [username] take a [BuildContext] because each service +/// keeps its account state on a different provider; they read it with `watch`, +/// so the calling element rebuilds exactly like the per-service `Consumer` +/// these entries replaced. +class TrackerServiceInfo { + final TrackerService service; + final String displayName; + + /// Which brand mark to draw; the asset path itself lives only in + /// `CatalogSourceLogo`. + final CatalogSourceId logoSource; + + final TrackerRatingSource ratingSource; + final bool Function(BuildContext) isConnected; + final String? Function(BuildContext) username; + final Future Function(BuildContext) startConnection; + final Widget Function() buildSettingsScreen; + + const TrackerServiceInfo({ + required this.service, + required this.displayName, + required this.logoSource, + required this.ratingSource, + required this.isConnected, + required this.username, + required this.startConnection, + required this.buildSettingsScreen, + }); + + /// Entry for a service that shares [TrackerSettingsScreen]: [config] already + /// carries the name and the [TrackersProvider] accessors. + TrackerServiceInfo.shared( + TrackerConfig config, { + required this.logoSource, + required this.ratingSource, + required this.startConnection, + }) : service = config.service, + displayName = config.displayName, + isConnected = ((context) => config.isConnected(context.watch())), + username = ((context) => config.username(context.watch())), + buildSettingsScreen = (() => TrackerSettingsScreen(config: config)); + + /// Display order shared by every list. Built per call because [displayName] + /// reads the active locale. + static List get all => [ + TrackerServiceInfo( + service: TrackerService.trakt, + displayName: t.trakt.title, + logoSource: CatalogSourceId.trakt, + ratingSource: TraktScrobbleService.instance, + isConnected: (context) => context.watch().isConnected, + username: (context) => context.watch().username, + startConnection: startTraktConnection, + buildSettingsScreen: () => const TraktSettingsScreen(), + ), + TrackerServiceInfo.shared( + TrackerConfig.mal(), + logoSource: CatalogSourceId.mal, + ratingSource: MalTracker.instance, + startConnection: startMalConnection, + ), + TrackerServiceInfo.shared( + TrackerConfig.anilist(), + logoSource: CatalogSourceId.anilist, + ratingSource: AnilistTracker.instance, + startConnection: startAnilistConnection, + ), + TrackerServiceInfo.shared( + TrackerConfig.simkl(), + logoSource: CatalogSourceId.simkl, + ratingSource: SimklTracker.instance, + startConnection: startSimklConnection, + ), + ]; +} diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index 3802dffc..659f3b15 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -67,7 +67,6 @@ class TrackerConfig { final String displayName; final bool Function(TrackersProvider) isConnected; final String? Function(TrackersProvider) username; - final Pref scrobblePref; final Future Function(bool) onScrobbleChanged; final Future Function(TrackersProvider) disconnect; @@ -76,17 +75,17 @@ class TrackerConfig { required this.displayName, required this.isConnected, required this.username, - required this.scrobblePref, required this.onScrobbleChanged, required this.disconnect, }); + Pref get scrobblePref => SettingsService.scrobblePref(service); + static TrackerConfig mal() => TrackerConfig( service: TrackerService.mal, displayName: t.services.names.mal, isConnected: (a) => a.isMalConnected, username: (a) => a.malUsername, - scrobblePref: SettingsService.enableMalScrobble, onScrobbleChanged: MalTracker.instance.setEnabled, disconnect: (a) => a.disconnectMal(), ); @@ -96,7 +95,6 @@ class TrackerConfig { displayName: t.services.names.anilist, isConnected: (a) => a.isAnilistConnected, username: (a) => a.anilistUsername, - scrobblePref: SettingsService.enableAnilistScrobble, onScrobbleChanged: AnilistTracker.instance.setEnabled, disconnect: (a) => a.disconnectAnilist(), ); @@ -106,7 +104,6 @@ class TrackerConfig { displayName: t.services.names.simkl, isConnected: (a) => a.isSimklConnected, username: (a) => a.simklUsername, - scrobblePref: SettingsService.enableSimklScrobble, onScrobbleChanged: SimklTracker.instance.setEnabled, disconnect: (a) => a.disconnectSimkl(), ); diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index 2cbe95db..e1aab594 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -71,7 +71,7 @@ class TraktSettingsScreen extends StatelessWidget { service: TrackerService.trakt, toggles: [ TrackerSettingsToggle( - pref: SettingsService.enableTraktScrobble, + pref: SettingsService.scrobblePref(TrackerService.trakt), icon: Symbols.auto_timer_rounded, title: t.trakt.scrobble, subtitle: t.trakt.scrobbleDescription, diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 72cd34cf..303b454f 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -42,7 +42,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { _lastVideoLayoutSize = pendingSize; _lastVideoLayoutPlayer = currentPlayer; _videoFilterManager?.updatePlayerSize(pendingSize); - _videoPIPManager?.updatePlayerSize(pendingSize); _updateAmbientLightingOnResize(pendingSize); unawaited(currentPlayer.updateFrame()); }); diff --git a/lib/screens/video_player/parts/display_matching.dart b/lib/screens/video_player/parts/display_matching.dart index 35d45795..5e2255fd 100644 --- a/lib/screens/video_player/parts/display_matching.dart +++ b/lib/screens/video_player/parts/display_matching.dart @@ -143,7 +143,9 @@ extension _VideoPlayerDisplayMatchingMethods on VideoPlayerScreenState { } } - /// Restore Windows display mode to original state. + /// Restore Windows display mode to original state. Fullscreen-exit only: + /// `dispose()` runs its own fire-and-forget variant because it cannot await + /// the HDR settle below. Future _restoreWindowsDisplayMode() async { if (_displayModeService == null || !_displayModeService!.anyChangeApplied) return; diff --git a/lib/screens/video_player/parts/episode_navigation.dart b/lib/screens/video_player/parts/episode_navigation.dart index a4e88b73..abc3be90 100644 --- a/lib/screens/video_player/parts/episode_navigation.dart +++ b/lib/screens/video_player/parts/episode_navigation.dart @@ -568,16 +568,18 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { final playbackResolver = PlaybackSourceResolver(serverManager: serverManager, database: database); final playbackContext = await playbackResolver.resolve( - metadata: metadata, - selectedMediaIndex: targetMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, + PlaybackInitializationOptions( + metadata: metadata, + selectedMediaIndex: targetMediaIndex, + selectedMediaSourceId: selectedMediaSourceId, + preferredVersionSignature: preferredVersionSignature, + qualityPreset: targetQualityPreset, + selectedAudioStreamId: targetAudioStreamId, + preferredSubtitleTrack: initializationSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: _offlineLibraryMode, - qualityPreset: targetQualityPreset, - selectedAudioStreamId: targetAudioStreamId, - preferredSubtitleTrack: initializationSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); if (!isCurrentReload()) return _MediaReloadOutcome.superseded; final result = playbackContext.result; @@ -817,7 +819,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState { if (!isCurrentReload()) return _MediaReloadOutcome.superseded; if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: currentPlayer.state.playing)); + unawaited(_updateAutoPipState(isPlaying: currentPlayer.state.playing)); } return _MediaReloadOutcome.opened; } catch (e) { diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index 9d3ec897..f52a1dfc 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -19,12 +19,12 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { _autoPipEnteringCallback = null; } - /// Initialize VideoFilterManager and VideoPIPManager if not already set up. + /// Initialize VideoFilterManager and the PiP methods if not already set up. /// Called from both live TV and VOD playback paths. Future _initVideoFilterAndPip() async { final currentPlayer = player; if (!mounted || currentPlayer == null) return; - if (_videoFilterManager != null && _videoPIPManager != null) { + if (_videoFilterManager != null && _pipInitialized) { _attachPipStateListener(); return; } @@ -44,17 +44,91 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { unawaited(_videoFilterManager!.updateVideoFilter()); } - _videoPIPManager ??= VideoPIPManager(player: currentPlayer, initialPlayerSize: initialPlayerSize); - _videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry; + _pipInitialized = true; _attachPipStateListener(); } Future _togglePIPMode() async { - final result = await _videoPIPManager?.togglePIP(); - if (result != null && !result.$1 && mounted) { - _restorePipFiltersAfterExit(); - showErrorSnackBar(context, result.$2 ?? t.videoControls.pipFailed); + if (!_pipInitialized) return; + + final supported = await PipService.isSupported(); + if (!supported) { + _onPipRequestFailed('PiP not supported on this device'); + return; } + + // If PiP is already active, exit it + if (PipService().isPipActive.value) { + await PipService.exit(); + return; + } + + // Reset video filter to contain mode before entering PiP. Android, iOS, + // and macOS all reuse the inline video surface/layer for PiP. + if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { + if (_pipInitialized) _preparePipFiltersForEntry(); + // Wait a frame for the filter change to take effect + await Future.delayed(const Duration(milliseconds: 50)); + } + + final dims = await _getVideoDimensions(); + final result = await PipService.enter(width: dims.$1, height: dims.$2); + if (!result.$1) _onPipRequestFailed(result.$2); + } + + void _onPipRequestFailed(String? error) { + if (!mounted) return; + _restorePipFiltersAfterExit(); + showErrorSnackBar(context, error ?? t.videoControls.pipFailed); + } + + Future _updateAutoPipState({required bool isPlaying}) async { + if (!_pipInitialized) return; + + if (!isPlaying) { + await PipService.setAutoPipReady(ready: false); + return; + } + + final dims = await _getVideoDimensions(); + await PipService.setAutoPipReady(ready: true, width: dims.$1, height: dims.$2); + } + + /// Get current video dimensions (display or storage or fallback to viewport) + Future<(int? width, int? height)> _getVideoDimensions() async { + final currentPlayer = player; + int? width; + int? height; + + try { + final dwidth = await currentPlayer?.getProperty('dwidth'); + final dheight = await currentPlayer?.getProperty('dheight'); + if (dwidth != null && dheight != null) { + width = int.tryParse(dwidth); + height = int.tryParse(dheight); + } + } catch (e) { + appLogger.d('PiP: dwidth/dheight unavailable', error: e); + } + + if (width == null || height == null) { + try { + final videoWidth = await currentPlayer?.getProperty('width'); + final videoHeight = await currentPlayer?.getProperty('height'); + if (videoWidth != null && videoHeight != null) { + width = int.tryParse(videoWidth); + height = int.tryParse(videoHeight); + } + } catch (e) { + appLogger.d('PiP: width/height unavailable', error: e); + } + } + + final viewport = _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null; + width ??= viewport?.width.toInt(); + height ??= viewport?.height.toInt(); + + return (width, height); } void _preparePipFiltersForEntry() { @@ -92,11 +166,11 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { return; } - final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value; + final isInPip = PipService().isPipActive.value; _setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed'); _recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited'); - if (_videoPIPManager == null || _videoFilterManager == null) return; + if (!_pipInitialized || _videoFilterManager == null) return; if (isInPip) { _preparePipFiltersForEntry(); diff --git a/lib/screens/video_player/parts/playback_prompts.dart b/lib/screens/video_player/parts/playback_prompts.dart index 59d45cba..d4d04b38 100644 --- a/lib/screens/video_player/parts/playback_prompts.dart +++ b/lib/screens/video_player/parts/playback_prompts.dart @@ -24,7 +24,7 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState { unawaited(DiscordRPCService.instance.pausePlayback()); unawaited(TraktScrobbleService.instance.pausePlayback()); if (_autoPipEnabled) { - unawaited(_videoPIPManager?.updateAutoPipState(isPlaying: false)); + unawaited(_updateAutoPipState(isPlaying: false)); } // End-of-video sleep timer takes precedence over autoplay / next-episode diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 4b327042..967b17ea 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -61,6 +61,9 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { required SettingsService settingsService, required bool useExoPlayer, }) async { + // Re-wire scope: exactly the nine subscriptions re-created below. The + // media-controls listeners belong to _setupMediaControls and the + // sleep-timer/Apple TV ones to initState; both outlive a re-wire. await Future.wait([ if (_playingSubscription != null) _playingSubscription!.cancel(), if (_completedSubscription != null) _completedSubscription!.cancel(), @@ -190,10 +193,21 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { }); } + /// Roll the screen back to a re-runnable state after a failed player + /// attempt. The player is gone but the screen stays mounted and + /// [_retryPlayerInitialization] may run again, so every collaborator is + /// released *and* nulled so it can be built once more. Kept separate from + /// `dispose()`, which instead destroys the notifiers, focus nodes and + /// player, and cannot await any of this. Future _tearDownFailedPlayerAttempt(Player attemptPlayer) async { final activePlayer = player; if (activePlayer != null && !identical(activePlayer, attemptPlayer)) return; + // Rollback scope: the nine player streams plus the five media-controls + // ones. _sleepTimerSubscription and _appleTvPlayPauseSubscription are + // initState-owned and never re-created — cancelling them here would kill + // the sleep-timer prompt and the Apple TV remote for the rest of the + // screen's life. final cancellationFutures = >[ if (_playingSubscription != null) _playingSubscription!.cancel(), if (_completedSubscription != null) _completedSubscription!.cancel(), @@ -253,12 +267,11 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { _stopLiveTimelineUpdates(); _detachPipStateListener(); _clearAutoPipEnteringCallback(); - final videoPipManager = _videoPIPManager; - _videoPIPManager = null; - if (videoPipManager != null) { - videoPipManager.onBeforeEnterPip = null; + final pipInitialized = _pipInitialized; + _pipInitialized = false; + if (pipInitialized) { try { - await videoPipManager.disableAutoPip(); + await PipService.setAutoPipReady(ready: false); } catch (e, st) { appLogger.w('Failed to disable auto-PiP during initialization rollback', error: e, stackTrace: st); } @@ -454,7 +467,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { final mediaControlsManager = MediaControlsManager(); _mediaControlsManager = mediaControlsManager; - final mediaControlRouter = VideoPlayerMediaControlRouter( + final mediaControlRouter = MediaControlRouter( canControlPlayback: _canControlPlayback, canNavigateMediaItems: _canNavigateMediaItems, onPlay: () { @@ -618,7 +631,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { // Update auto-PiP readiness if (_autoPipEnabled) { - _videoPIPManager?.updateAutoPipState(isPlaying: isPlaying); + unawaited(_updateAutoPipState(isPlaying: isPlaying)); } } diff --git a/lib/screens/video_player/parts/playback_start.dart b/lib/screens/video_player/parts/playback_start.dart index 4ece6685..e0c24563 100644 --- a/lib/screens/video_player/parts/playback_start.dart +++ b/lib/screens/video_player/parts/playback_start.dart @@ -110,15 +110,17 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { database: context.read(), ); playbackContext = await playbackResolver.resolve( - metadata: _currentMetadata, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: _requestedMediaSourceId, + PlaybackInitializationOptions( + metadata: _currentMetadata, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: _requestedMediaSourceId, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + preferredSubtitleTrack: _preferredSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: true, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - preferredSubtitleTrack: _preferredSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); if (playbackContext.result.videoUrl == null) { throw PlaybackException(t.messages.fileInfoNotAvailable); @@ -305,9 +307,8 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState { _autoPipEnteringCallback = autoPipEnteringCallback; PipService.onAutoPipEntering = autoPipEnteringCallback; - final pipManager = _videoPIPManager; - if (currentPlayer.state.playing && pipManager != null) { - unawaited(pipManager.updateAutoPipState(isPlaying: true)); + if (currentPlayer.state.playing) { + unawaited(_updateAutoPipState(isPlaying: true)); } } diff --git a/lib/screens/video_player/parts/shader.dart b/lib/screens/video_player/parts/shader.dart index 2f2ec96a..4361102c 100644 --- a/lib/screens/video_player/parts/shader.dart +++ b/lib/screens/video_player/parts/shader.dart @@ -22,6 +22,36 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { } } + /// Enable ambient lighting for the current video/player geometry. + /// Returns false when the aspect ratios cannot be determined yet. + Future _enableAmbientLighting(AmbientLightingService ambientLighting, ShaderProvider shaderProvider) async { + // Get video display aspect ratio + final dwidth = await player?.getProperty('dwidth'); + final dheight = await player?.getProperty('dheight'); + if (dwidth == null || dheight == null) return false; + final w = double.tryParse(dwidth); + final h = double.tryParse(dheight); + if (w == null || h == null || h == 0) return false; + final videoAspect = w / h; + + // Get player widget aspect ratio + final playerSize = _videoFilterManager?.playerSize; + if (playerSize == null || playerSize.height == 0) return false; + final outputAspect = playerSize.width / playerSize.height; + + // Clear shaders — ambient lighting and shaders are mutually exclusive + if (shaderProvider.isShaderEnabled) { + await _shaderService!.applyPreset(ShaderPreset.none); + shaderProvider.setCurrentPreset(ShaderPreset.none); + } + + // Force contain mode when enabling ambient lighting + _videoFilterManager?.resetToContain(); + + await ambientLighting.enable(videoAspect, outputAspect); + return true; + } + /// Restore ambient lighting from persisted setting Future _restoreAmbientLighting() async { if (!mounted) return; @@ -34,27 +64,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { final ambientLighting = _ambientLightingService; if (ambientLighting == null || !ambientLighting.isSupported) return; - // Same enable logic as _toggleAmbientLighting - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - _videoFilterManager?.resetToContain(); - await ambientLighting.enable(videoAspect, outputAspect); + if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return; if (mounted) _setPlayerState(() {}); } @@ -117,30 +127,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { await ambientLighting.disable(); unawaited(_videoFilterManager?.updateVideoFilter()); } else { - // Get video display aspect ratio - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - // Get player widget aspect ratio - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - // Force contain mode when enabling ambient lighting - _videoFilterManager?.resetToContain(); - - await ambientLighting.enable(videoAspect, outputAspect); + if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return; } // Persist ambient lighting state diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index 05ebbf50..698aa541 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -188,85 +188,31 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: PipService().isPipActive, - builder: (context, isInPip, child) { - final episode = nextEpisode; - if (isInPip || !visible || episode == null) { - return const SizedBox.shrink(); - } - return _VideoPlayerPromptPosition( - chromeController: chromeController, - child: _VideoPlayerPromptInteractionHold( - chromeController: chromeController, - focusNodes: [cancelFocusNode, confirmFocusNode], - child: _VideoPlayerPromptCard( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - _PlayNextEpisodeHeader(episode: episode), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: cancelFocusNode, - onPressed: onCancel, - autoScroll: false, - onNavigateRight: () => confirmFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onCancel, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.common.cancel), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: confirmFocusNode, - onPressed: onPlayNext, - autoScroll: false, - onNavigateLeft: () => cancelFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - useBackgroundFocus: true, - child: FilledButton( - onPressed: onPlayNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - if (autoPlayCountdown > 0) ...[ - Text('$autoPlayCountdown'), - const SizedBox(width: 4), - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), - ] else - Text(t.videoControls.playNext), - ], - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - }, + final episode = nextEpisode; + if (episode == null) return const SizedBox.shrink(); + return _VideoPlayerPromptShell( + visible: visible, + chromeController: chromeController, + focusNodes: [cancelFocusNode, confirmFocusNode], + children: [ + _PlayNextEpisodeHeader(episode: episode), + const SizedBox(height: 12), + _VideoPlayerPromptActions( + cancelLabel: t.common.cancel, + cancelFocusNode: cancelFocusNode, + onCancel: onCancel, + confirmFocusNode: confirmFocusNode, + onConfirm: onPlayNext, + confirmChildren: [ + if (autoPlayCountdown > 0) ...[ + Text('$autoPlayCountdown'), + const SizedBox(width: 4), + const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + ] else + Text(t.videoControls.playNext), + ], + ), + ], ); } } @@ -344,6 +290,49 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { required this.onContinue, }); + @override + Widget build(BuildContext context) { + return _VideoPlayerPromptShell( + visible: visible, + chromeController: chromeController, + focusNodes: [pauseFocusNode, continueFocusNode], + children: [ + Text( + t.videoControls.stillWatching, + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), + ), + const SizedBox(height: 4), + Text( + t.videoControls.pausingIn(seconds: '$countdown'), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), + ), + const SizedBox(height: 12), + _VideoPlayerPromptActions( + cancelLabel: t.videoControls.pauseButton, + cancelFocusNode: pauseFocusNode, + onCancel: onPause, + confirmFocusNode: continueFocusNode, + onConfirm: onContinue, + confirmChildren: [Text('$countdown'), const SizedBox(width: 4), Text(t.videoControls.continueWatching)], + ), + ], + ); + } +} + +class _VideoPlayerPromptShell extends StatelessWidget { + final bool visible; + final PlayerChromeController chromeController; + final List focusNodes; + final List children; + + const _VideoPlayerPromptShell({ + required this.visible, + required this.chromeController, + required this.focusNodes, + required this.children, + }); + @override Widget build(BuildContext context) { return ValueListenableBuilder( @@ -356,75 +345,9 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { chromeController: chromeController, child: _VideoPlayerPromptInteractionHold( chromeController: chromeController, - focusNodes: [pauseFocusNode, continueFocusNode], + focusNodes: focusNodes, child: _VideoPlayerPromptCard( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - t.videoControls.stillWatching, - style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), - ), - const SizedBox(height: 4), - Text( - t.videoControls.pausingIn(seconds: '$countdown'), - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: pauseFocusNode, - onPressed: onPause, - autoScroll: false, - onNavigateRight: () => continueFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onPause, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.videoControls.pauseButton), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: continueFocusNode, - onPressed: onContinue, - autoScroll: false, - onNavigateLeft: () => pauseFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - useBackgroundFocus: true, - child: FilledButton( - onPressed: onContinue, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - Text('$countdown'), - const SizedBox(width: 4), - Text(t.videoControls.continueWatching), - ], - ), - ), - ), - ), - ], - ), - ], - ), + child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children), ), ), ); @@ -433,6 +356,72 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { } } +class _VideoPlayerPromptActions extends StatelessWidget { + final String cancelLabel; + final FocusNode cancelFocusNode; + final VoidCallback onCancel; + final FocusNode confirmFocusNode; + final VoidCallback onConfirm; + final List confirmChildren; + + const _VideoPlayerPromptActions({ + required this.cancelLabel, + required this.cancelFocusNode, + required this.onCancel, + required this.confirmFocusNode, + required this.onConfirm, + required this.confirmChildren, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FocusableButton( + focusNode: cancelFocusNode, + onPressed: onCancel, + autoScroll: false, + onNavigateRight: () => confirmFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: OutlinedButton( + onPressed: onCancel, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(cancelLabel), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FocusableButton( + focusNode: confirmFocusNode, + onPressed: onConfirm, + autoScroll: false, + onNavigateLeft: () => cancelFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + useBackgroundFocus: true, + child: FilledButton( + onPressed: onConfirm, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row(mainAxisAlignment: .center, children: confirmChildren), + ), + ), + ), + ], + ); + } +} + class _VideoPlayerPromptPosition extends StatelessWidget { final PlayerChromeController chromeController; final Widget child; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index ca18e7d8..f2833174 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -58,13 +58,13 @@ import '../services/playback_source_resolver.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/display_mode_service.dart'; +import '../services/media_control_router.dart'; import '../services/settings_service.dart'; import '../services/sleep_timer_service.dart'; import '../services/track_manager.dart'; import '../services/track_selection_service.dart'; import '../services/ambient_lighting_service.dart'; import '../services/video_filter_manager.dart'; -import '../services/video_pip_manager.dart'; import '../services/video_volume_controller.dart'; import '../services/pip_service.dart'; import '../models/shader_preset.dart'; @@ -87,7 +87,6 @@ import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; import 'video_player/live_stream_retry.dart'; import 'video_player/live_timeline_report.dart'; -import 'video_player/media_control_router.dart'; import 'video_player/wakelock_controller.dart'; import 'video_player/live_tv_session_args.dart'; import 'video_player/live_tv_session_state.dart'; @@ -527,7 +526,7 @@ class VideoPlayerScreenState extends State with WidgetsBindin ({bool canControlPlayback, bool canNavigateMediaItems})? _lastMediaControlAuthority; PlaybackProgressTracker? _progressTracker; VideoFilterManager? _videoFilterManager; - VideoPIPManager? _videoPIPManager; + bool _pipInitialized = false; ShaderService? _shaderService; AmbientLightingService? _ambientLightingService; bool _fullscreenListenerAttached = false; @@ -1063,16 +1062,18 @@ class VideoPlayerScreenState extends State with WidgetsBindin database: context.read(), ); _playbackDataFuture = playbackResolver.resolve( - metadata: _currentMetadata, - selectedMediaIndex: _effectiveSelectedMediaIndex, - selectedMediaSourceId: _requestedMediaSourceId, - preferredVersionSignature: widget.preferredVersionSignature, + PlaybackInitializationOptions( + metadata: _currentMetadata, + selectedMediaIndex: _effectiveSelectedMediaIndex, + selectedMediaSourceId: _requestedMediaSourceId, + preferredVersionSignature: widget.preferredVersionSignature, + qualityPreset: _selectedQualityPreset, + selectedAudioStreamId: _selectedAudioStreamId, + preferredSubtitleTrack: _preferredSubtitleTrack, + sessionIdentifier: _playbackSessionIdentifier, + transcodeSessionId: _playbackTranscodeSessionId, + ), offlineLibraryMode: false, - qualityPreset: _selectedQualityPreset, - selectedAudioStreamId: _selectedAudioStreamId, - preferredSubtitleTrack: _preferredSubtitleTrack, - sessionIdentifier: _playbackSessionIdentifier, - transcodeSessionId: _playbackTranscodeSessionId, ); // If MPV setup below throws before `_startPlayback` awaits this, // tell Dart we've "handled" the future so it's not reported as an @@ -1370,6 +1371,22 @@ class VideoPlayerScreenState extends State with WidgetsBindin return exitPosition; } + /// Pause/hide the player, flush stopped progress, restore system UI and + /// orientation, then leave the player route. No-op when the route cannot pop. + Future _exitPlayerRoute({required bool navigateHome}) async { + final navigator = Navigator.of(context); + if (!navigator.canPop()) return; + + _isExiting.value = true; + final exitPosition = await _pauseAndHidePlayerForRouteExit(); + if (!mounted) return; + await _sendStoppedProgressOnce(positionOverride: exitPosition); + if (!mounted) return; + await _restoreSystemUiAndOrientation(); + if (!mounted) return; + _finishPlayerNavigation(navigator, navigateHome: navigateHome); + } + /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation Future _handleBackButton({bool navigateHome = false}) async { @@ -1392,36 +1409,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (confirmed && mounted) { await _watchTogetherProvider!.leaveSession(); - if (mounted) { - final navigator = Navigator.of(context); - if (navigator.canPop()) { - _isExiting.value = true; - final exitPosition = await _pauseAndHidePlayerForRouteExit(); - if (!mounted) return; - await _sendStoppedProgressOnce(positionOverride: exitPosition); - if (!mounted) return; - await _restoreSystemUiAndOrientation(); - if (!mounted) return; - _finishPlayerNavigation(navigator, navigateHome: navigateHome); - } - } + if (mounted) await _exitPlayerRoute(navigateHome: navigateHome); } return; } // Default behavior for hosts or non-session users if (!mounted) return; - final navigator = Navigator.of(context); - if (navigator.canPop()) { - _isExiting.value = true; - final exitPosition = await _pauseAndHidePlayerForRouteExit(); - if (!mounted) return; - await _sendStoppedProgressOnce(positionOverride: exitPosition); - if (!mounted) return; - await _restoreSystemUiAndOrientation(); - if (!mounted) return; - _finishPlayerNavigation(navigator, navigateHome: navigateHome); - } + await _exitPlayerRoute(navigateHome: navigateHome); } finally { _isHandlingBack = false; } @@ -1511,6 +1506,11 @@ class VideoPlayerScreenState extends State with WidgetsBindin _chromeController.dispose(); _toastController.dispose(); + // The release sequence below mirrors _tearDownFailedPlayerAttempt but is + // deliberately separate: dispose() cannot await, and it destroys the + // notifiers, focus nodes and player that the rollback path keeps alive + // for a retry on a still-mounted screen. + // // Stop progress tracking and send final state. Normal back navigation // awaits this before popping; dispose keeps a fallback for externally // removed routes where dispose() cannot await. @@ -1520,11 +1520,10 @@ class VideoPlayerScreenState extends State with WidgetsBindin _stopLiveTimelineUpdates(); _detachPipStateListener(); - _videoPIPManager?.onBeforeEnterPip = null; - unawaited(_videoPIPManager?.disableAutoPip()); + if (_pipInitialized) unawaited(PipService.setAutoPipReady(ready: false)); _clearAutoPipEnteringCallback(); _videoFilterManager?.dispose(); - _videoPIPManager = null; + _pipInitialized = false; _videoFilterManager = null; _scrubPreviewSource?.dispose(); @@ -1533,6 +1532,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin SleepTimerService().markNeedsRestart(); } + // Teardown scope: every subscription the screen ever owns, including the + // initState-owned sleep-timer and Apple TV ones that the rollback path + // must leave alive. _playingSubscription?.cancel(); _completedSubscription?.cancel(); _errorSubscription?.cancel(); @@ -1579,6 +1581,9 @@ class VideoPlayerScreenState extends State with WidgetsBindin FullscreenStateManager().removeListener(_onFullscreenChanged); _fullscreenListenerAttached = false; } + // Not _restoreWindowsDisplayMode(): that helper waits 200ms after clearing + // the HDR hint before restoring, which dispose() cannot do. Fire the hint + // clear at the still-live player and restore immediately. if (!isReplacingWithVideo && Platform.isWindows && _displayModeService != null && diff --git a/lib/services/api_cache.dart b/lib/services/api_cache.dart index b49f27a6..bb721bf1 100644 --- a/lib/services/api_cache.dart +++ b/lib/services/api_cache.dart @@ -166,27 +166,6 @@ abstract class ApiCache { await (_db.delete(_db.apiCache)..where((t) => t.pinned.equals(false))).go(); } - /// Pin every row whose `cacheKey` matches the SQL `LIKE` [pattern]. Used by - /// backend subclasses that pin by item-shape rather than a single endpoint - /// (e.g. Jellyfin's per-user item rows where the user segment is a - /// wildcard). - Future pinByKeyPattern(String pattern) async { - await (_db.update( - _db.apiCache, - )..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(true))); - } - - Future unpinByKeyPattern(String pattern) async { - await (_db.update( - _db.apiCache, - )..where((t) => t.cacheKey.like(pattern))).write(const ApiCacheCompanion(pinned: Value(false))); - } - - Future hasPinnedMatching(String pattern) async { - final rows = await (_db.select(_db.apiCache)..where((t) => t.cacheKey.like(pattern) & t.pinned.equals(true))).get(); - return rows.isNotEmpty; - } - /// Pull pinned rows for [serverId] and extract the first capture group of /// [keyPattern] from each `cacheKey`. Returns the unique set of captured /// ids — backend subclasses use this to enumerate their pinned items diff --git a/lib/services/companion_remote/companion_remote_host_controller.dart b/lib/services/companion_remote/companion_remote_host_controller.dart index 822f592e..0c2f8882 100644 --- a/lib/services/companion_remote/companion_remote_host_controller.dart +++ b/lib/services/companion_remote/companion_remote_host_controller.dart @@ -9,33 +9,40 @@ import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../utils/app_logger.dart'; +/// Resolves the active profile's Plex identity and primes companion-remote +/// crypto with it, returning whether crypto ended up ready. +/// +/// Crypto is an app-level service, not bound to any one widget: everything the +/// bootstrap needs is captured up front, so an unmount mid-await must not abort +/// work the user asked for. Hence no `context.mounted` guards below. +Future ensureCompanionRemoteCryptoFromContext(BuildContext context) async { + final companionRemote = context.read(); + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); + return companionRemote.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); +} + Future startCompanionRemoteHost(BuildContext context) async { final companionRemote = context.read(); if (companionRemote.isHostServerRunning) return true; try { - // The host is an app-level service, not bound to this widget: everything - // it needs is captured up front, so an unmount mid-await must not abort a - // start the user asked for. Hence no `context.mounted` guards below. - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); - final ok = await companionRemote.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); - if (!ok) return false; + if (!await ensureCompanionRemoteCryptoFromContext(context)) return false; await companionRemote.startHostServer(); return companionRemote.isHostServerRunning; diff --git a/lib/services/companion_remote/remote_auth_service.dart b/lib/services/companion_remote/remote_auth_service.dart index 507f60a2..f4bf1d6d 100644 --- a/lib/services/companion_remote/remote_auth_service.dart +++ b/lib/services/companion_remote/remote_auth_service.dart @@ -388,10 +388,6 @@ class RemoteAuthService { _cachedSecret = null; _cachedSecretKey = null; } - - // Static direction constants for external use - static int get directionHost => _directionHost; - static int get directionClient => _directionClient; } /// Helper for building byte arrays. diff --git a/lib/services/data_aggregation_service.dart b/lib/services/data_aggregation_service.dart index 6f1910d7..060e9a80 100644 --- a/lib/services/data_aggregation_service.dart +++ b/lib/services/data_aggregation_service.dart @@ -32,6 +32,12 @@ typedef SearchAggregationResult = ({ Set cancelledServerIds, Set failedServerIds, }); +typedef _FanOutResult = ({ + List items, + Set succeededServerIds, + Set cancelledServerIds, + Set failedServerIds, +}); /// Whether [error] is a client-side abort (client teardown mid-request) /// rather than a genuine server failure. Aggregation reports these servers @@ -69,6 +75,47 @@ class DataAggregationService { }; } + /// Run [fetch] against every client in [clients] and concatenate the results + /// in client order. A per-server failure is swallowed — logged with + /// [failureMessage] and contributing nothing — so one bad server cannot sink + /// the pass; that server is simply absent from `succeededServerIds` and lands + /// in `failedServerIds`. A client-side abort is *not* a failure: it lands in + /// `cancelledServerIds` and is logged at debug level, so callers can tell a + /// disrupted pass from a settled one and torn-down requests do not spam the + /// error log. + Future<_FanOutResult> _fanOut( + Map clients, { + required String Function(String serverId) failureMessage, + required Future> Function(String serverId, MediaServerClient client) fetch, + }) async { + final cancelledServerIds = {}; + final failedServerIds = {}; + final futures = clients.entries.map((entry) async { + try { + return (serverId: entry.key, items: await fetch(entry.key, entry.value)); + } catch (e, stackTrace) { + if (_isCancellation(e)) { + cancelledServerIds.add(entry.key); + appLogger.d('Cancelled (client abort): ${failureMessage(entry.key)}'); + } else { + failedServerIds.add(entry.key); + appLogger.e(failureMessage(entry.key), error: e, stackTrace: stackTrace); + } + return (serverId: null, items: []); + } + }); + final results = await Future.wait(futures); + return ( + items: [for (final result in results) ...result.items], + succeededServerIds: { + for (final result in results) + if (result.serverId != null) result.serverId!, + }, + cancelledServerIds: cancelledServerIds, + failedServerIds: failedServerIds, + ); + } + /// Fetch libraries from all online clients regardless of backend, returning /// the merged neutral [MediaLibrary]s alongside the ids of the servers whose /// fetch actually succeeded. [serverIds] restricts the fan-out to those @@ -92,24 +139,15 @@ class DataAggregationService { cancelledServerIds: const {}, ); } - final succeededServerIds = {}; - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - try { - final libraries = await entry.value.fetchLibraries(); - succeededServerIds.add(entry.key); - return libraries; - } catch (e, stackTrace) { - if (_isCancellation(e)) cancelledServerIds.add(entry.key); - appLogger.e('Failed neutral library fetch from ${entry.key}', error: e, stackTrace: stackTrace); - return []; - } - }); - final results = await Future.wait(futures); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed neutral library fetch from $serverId', + fetch: (_, client) => client.fetchLibraries(), + ); return ( - libraries: [for (final list in results) ...list], - succeededServerIds: succeededServerIds, - cancelledServerIds: cancelledServerIds, + libraries: fetched.items, + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, ); } @@ -128,24 +166,12 @@ class DataAggregationService { return (items: const [], succeededServerIds: const {}, cancelledServerIds: const {}); } - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - final client = entry.value; - try { - final items = await client.fetchContinueWatching(count: limit); - return (serverId: entry.key, items: items); - } catch (e, st) { - if (_isCancellation(e)) cancelledServerIds.add(entry.key); - appLogger.e('Failed on-deck fetch from ${entry.key}', error: e, stackTrace: st); - return (serverId: null, items: []); - } - }); - final results = await Future.wait(futures); - final succeededServerIds = { - for (final result in results) - if (result.serverId != null) result.serverId!, - }; - final allOnDeck = results.expand((result) => result.items).toList(); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed on-deck fetch from $serverId', + fetch: (_, client) => client.fetchContinueWatching(count: limit), + ); + final allOnDeck = fetched.items; // Filter out items from hidden libraries List filteredOnDeck = allOnDeck; @@ -169,7 +195,11 @@ class DataAggregationService { appLogger.i('Fetched ${items.length} on deck items from all servers'); - return (items: items, succeededServerIds: succeededServerIds, cancelledServerIds: cancelledServerIds); + return ( + items: items, + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, + ); } /// Merge an [existing] Continue Watching list with [fresh] rows from @@ -397,11 +427,10 @@ class DataAggregationService { ? _groupLibrariesByServer((await getMediaLibrariesFromAllServers(serverIds: serverIds)).libraries) : null; - final cancelledServerIds = {}; - final futures = clients.entries.map((entry) async { - final serverId = entry.key; - final client = entry.value; - try { + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Failed to fetch hubs from server $serverId', + fetch: (serverId, client) async { final serverLibraries = libraries?[serverId]; final shouldUseGlobalHubs = useGlobalHubs && client.capabilities.richHubs; final hubItemLimit = limit ?? defaultHubPreviewLimit; @@ -428,28 +457,13 @@ class DataAggregationService { includePlaybackHubs: includePlaybackHubs, libraries: useGlobalHubs ? serverLibraries : null, ); - return ( - serverId: serverId, - hubs: _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys), - ); - } catch (e, stackTrace) { - if (_isCancellation(e)) cancelledServerIds.add(serverId); - appLogger.e('Failed to fetch hubs from server $serverId', error: e, stackTrace: stackTrace); - return (serverId: null, hubs: []); - } - }); + return _postProcessHubs(hubs, serverId: ServerId(serverId), hiddenLibraryKeys: hiddenLibraryKeys); + }, + ); - final results = await Future.wait(futures); - final succeededServerIds = { - for (final result in results) - if (result.serverId != null) result.serverId!, - }; - final all = []; - for (final result in results) { - all.addAll(result.hubs); - } + final all = fetched.items; final hubs = limit != null && limit < all.length ? all.sublist(0, limit) : all; - return (hubs: hubs, succeededServerIds: succeededServerIds, cancelledServerIds: cancelledServerIds); + return (hubs: hubs, succeededServerIds: fetched.succeededServerIds, cancelledServerIds: fetched.cancelledServerIds); } /// Per-library hub fetch for a single client. Filters to visible libraries @@ -546,47 +560,34 @@ class DataAggregationService { final resultLimit = limit ?? defaultMediaSearchLimit; final fetchLimit = resultLimit < defaultMediaSearchLimit ? defaultMediaSearchLimit : resultLimit; - final succeededServerIds = {}; - final cancelledServerIds = {}; - final failedServerIds = {}; - final futures = clients.entries.map((entry) async { - final stopwatch = Stopwatch()..start(); - try { - final items = await entry.value.searchItems(query, limit: fetchLimit, abort: abort); - succeededServerIds.add(entry.key); + final fetched = await _fanOut( + clients, + failureMessage: (serverId) => 'Search failed on $serverId', + fetch: (serverId, client) async { + final stopwatch = Stopwatch()..start(); + final items = await client.searchItems(query, limit: fetchLimit, abort: abort); appLogger.i( - 'Search completed on ${entry.key} in ${stopwatch.elapsedMilliseconds}ms: ' + 'Search completed on $serverId in ${stopwatch.elapsedMilliseconds}ms: ' '${items.length} results ${_searchKindCounts(items)}', ); return items; - } catch (e, st) { - if (_isCancellation(e)) { - cancelledServerIds.add(entry.key); - appLogger.d('Search cancelled on ${entry.key} after ${stopwatch.elapsedMilliseconds}ms'); - } else { - failedServerIds.add(entry.key); - appLogger.e('Search failed on ${entry.key}', error: e, stackTrace: st); - } - return []; - } - }); - - final allResults = (await Future.wait(futures)).expand((items) => items).toList(); + }, + ); abort?.throwIfAborted(); - final items = rankMediaSearchResults(allResults, query, limit: resultLimit); + final items = rankMediaSearchResults(fetched.items, query, limit: resultLimit); appLogger.i( 'Search aggregation completed: ${items.length} results ' - '(${succeededServerIds.length} succeeded, ${cancelledServerIds.length} cancelled, ' - '${failedServerIds.length} failed) ${_searchKindCounts(items)}', + '(${fetched.succeededServerIds.length} succeeded, ${fetched.cancelledServerIds.length} cancelled, ' + '${fetched.failedServerIds.length} failed) ${_searchKindCounts(items)}', ); return ( items: items, - succeededServerIds: succeededServerIds, - cancelledServerIds: cancelledServerIds, - failedServerIds: failedServerIds, + succeededServerIds: fetched.succeededServerIds, + cancelledServerIds: fetched.cancelledServerIds, + failedServerIds: fetched.failedServerIds, ); } diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart index 7b50dabc..3c5a4304 100644 --- a/lib/services/device_performance.dart +++ b/lib/services/device_performance.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; +import '../utils/async_singleton.dart'; +import '../utils/device_channel.dart'; import '../utils/platform_detector.dart'; /// User override for the visual-effects tier (stored by SettingsService). @@ -19,11 +21,9 @@ enum VisualEffectsSetting { auto, full, reduced } class DevicePerformance { DevicePerformance._(); - static DevicePerformance? _instance; - static Future? _initialization; + static final AsyncSingleton _singleton = AsyncSingleton(); @visibleForTesting - static Future? debugDetectionGate; - static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device'); + static set debugDetectionGate(Future? value) => _singleton.debugGate = value; /// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel /// reservations), below 3 GB Shield-class devices (~2.8 GiB). @@ -39,35 +39,13 @@ class DevicePerformance { /// Get the singleton, detecting hardware signals on first call. /// [override] is the persisted SettingsService.visualEffects value. - static Future getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async { - final existing = _instance; - if (existing != null) { - final initialization = _initialization; - if (initialization != null) await initialization; - return existing; - } - - final instance = DevicePerformance._().._override = override; - _instance = instance; - final initialization = instance._detect(); - _initialization = initialization; - try { - await initialization; - } catch (_) { - if (identical(_instance, instance)) _instance = null; - rethrow; - } finally { - if (identical(_initialization, initialization)) _initialization = null; - } - return instance; - } + static Future getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) => + _singleton.getInstance(() => DevicePerformance._().._override = override, (instance) => instance._detect()); Future _detect() async { - final gate = debugDetectionGate; - if (gate != null) await gate; if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier try { - final result = await _deviceChannel.invokeMapMethod('getPerformanceSignals'); + final result = await deviceChannel.invokeMapMethod('getPerformanceSignals'); if (result == null) return; _is64Bit = result['is64Bit'] == true; _isLowRam = result['isLowRamDevice'] == true; @@ -85,7 +63,7 @@ class DevicePerformance { /// Total device RAM as reported by the platform, or null off-Android / /// before init. Used to scale memory-watchdog thresholds to the device. - static int? get totalMemBytes => _instance?._totalMemBytes; + static int? get totalMemBytes => _singleton.instance?._totalMemBytes; /// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB), /// independent of the visual-effects override. Use this for decisions tied to @@ -93,11 +71,11 @@ class DevicePerformance { /// boxes lagging a GL subtitle overlay — where a user's effects preference is /// irrelevant. Safe before init (returns false). See [isReduced] for the /// effects-tier gate that the override can force. - static bool get isLowEndHardware => _instance?._autoReduced ?? false; + static bool get isLowEndHardware => _singleton.instance?._autoReduced ?? false; /// Primary gate for effect chokepoints. Safe before init (full tier). static bool get isReduced { - final instance = _instance; + final instance = _singleton.instance; if (instance == null) return false; return switch (instance._override) { VisualEffectsSetting.auto => instance._autoReduced, @@ -112,7 +90,7 @@ class DevicePerformance { /// Update the user override from the settings screen and re-apply the /// budgets that were computed at boot. static void setOverrideSync(VisualEffectsSetting value) { - _instance?._override = value; + _singleton.instance?._override = value; applyImageCacheBudget(); } @@ -142,7 +120,7 @@ class DevicePerformance { /// Raw signals are always included (even when the tier is forced) so an /// uploaded log answers "did the reduced tier engage, and why / why not". static String describeSync() { - final instance = _instance; + final instance = _singleton.instance; if (instance == null) return 'unknown'; final tier = isReduced ? 'reduced' : 'full'; final signals = [ @@ -158,14 +136,13 @@ class DevicePerformance { @visibleForTesting static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) { - _initialization = null; - debugDetectionGate = null; if (autoReduced == null && override == null) { - _instance = null; + _singleton.debugReset(); return; } - _instance ??= DevicePerformance._(); - if (autoReduced != null) _instance!._autoReduced = autoReduced; - if (override != null) _instance!._override = override; + final instance = _singleton.instance ?? DevicePerformance._(); + _singleton.debugReset(instance: instance); + if (autoReduced != null) instance._autoReduced = autoReduced; + if (override != null) instance._override = override; } } diff --git a/lib/services/download_artwork_service.dart b/lib/services/download_artwork_service.dart index b807779c..103991a5 100644 --- a/lib/services/download_artwork_service.dart +++ b/lib/services/download_artwork_service.dart @@ -44,13 +44,6 @@ class DownloadArtworkService { return isUsableArtworkFile(file); } - Future hasMissingArtwork(ServerId serverId, Iterable specs) async { - for (final spec in specs) { - if (!await existsUsable(serverId, spec.localKey)) return true; - } - return false; - } - Future ensureArtworkForMetadata(MediaItem metadata, MediaServerClient client) async { final serverId = metadata.serverId; if (serverId == null) return false; diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index 6fccc8eb..c7770c0a 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1328,46 +1328,23 @@ class DownloadManagerService { ); } - var artworkSettled = !queueItem.downloadArtwork; - if (queueItem.downloadArtwork) { - final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); - final chapterArtworkSettled = metadata.serverId == null - ? false - : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); - artworkSettled = itemArtworkSettled && chapterArtworkSettled; - } - - var subtitlesSettled = !queueItem.downloadSubtitles; - if (queueItem.downloadSubtitles) { - try { - final resolution = await client.resolveDownload( - metadata, - mediaIndex: record?.mediaIndex ?? 0, - mediaSourceId: record?.mediaSourceId, - ); - if (resolution.externalSubtitlesResolved) { - subtitlesSettled = await _downloadSubtitles( - globalKey, - metadata, - resolution.externalSubtitles, - client, - showYear: showYear, - ); - } else { - appLogger.d('Subtitle enrichment remains deferred for $globalKey'); - } - } catch (e, st) { - appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e, stackTrace: st); - } - } - if (artworkSettled && subtitlesSettled) { + final settled = await _runSupplementaryDownloads( + globalKey, + metadata, + client, + downloadArtwork: queueItem.downloadArtwork, + downloadSubtitles: queueItem.downloadSubtitles, + record: record, + showYear: showYear, + ); + if (settled.artwork && settled.subtitles) { await _database.removeFromQueue(globalKey); appLogger.i('Deferred supplementary downloads completed for $globalKey'); } else { await _database.updateSupplementaryQueueIntent( globalKey, - downloadSubtitles: !subtitlesSettled, - downloadArtwork: !artworkSettled, + downloadSubtitles: !settled.subtitles, + downloadArtwork: !settled.artwork, ); } } catch (e, st) { @@ -1596,17 +1573,8 @@ class DownloadManagerService { if (client != null) unawaited(_processQueue(client)); } - Future _cancelNativeTask(String globalKey, String taskId, {required String reason}) async { - if (!downloadsSupported || taskId.isEmpty) return; - try { - final cancelled = await FileDownloader().cancelTaskWithId(taskId); - if (cancelled) { - appLogger.d('Cancelled native task $taskId for $globalKey ($reason)'); - } - } catch (e) { - appLogger.w('Failed to cancel native task $taskId for $globalKey ($reason)', error: e); - } - } + Future _cancelNativeTask(String globalKey, String taskId, {required String reason}) => + _cancelNativeTaskIds(globalKey, [taskId], reason: reason); Future _cancelNativeTasksForGlobalKey( String globalKey, { @@ -1629,16 +1597,7 @@ class DownloadManagerService { appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e); } - if (taskIds.isEmpty) return; - - try { - final cancelled = await FileDownloader().cancelTasksWithIds(taskIds); - if (cancelled) { - appLogger.d('Cancelled ${taskIds.length} native task(s) for $globalKey ($reason): ${taskIds.join(', ')}'); - } - } catch (e) { - appLogger.w('Failed to cancel native tasks for $globalKey ($reason): ${taskIds.join(', ')}', error: e); - } + await _cancelNativeTaskIds(globalKey, taskIds, reason: reason); } Future _downloadForCurrentTaskSession( @@ -1692,6 +1651,20 @@ class DownloadManagerService { return true; } + /// Hand a prepared task to the native downloader, recording its id first so a + /// concurrent cancel can find it. Returns true if the download went inactive + /// while enqueueing and the task was dropped again. + Future _enqueuePreparedTask(String globalKey, Task task, String kind) async { + await _database.updateBgTaskId(globalKey, task.taskId); + final success = await FileDownloader().enqueue(task); + if (!success) throw Exception('Failed to enqueue $kind task'); + if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { + return true; + } + appLogger.i('Enqueued $kind task ${task.taskId} for $globalKey'); + return false; + } + /// Resolve metadata, video URL, and file path, then enqueue a background download task. /// Returns true if successfully enqueued, false if it failed immediately. Future _prepareAndEnqueueDownload( @@ -1799,38 +1772,30 @@ class DownloadManagerService { if (_queueBlockedByStorageFailure) return true; final metadata = resolvedMetadata; final safBaseUri = _storageService.safBaseUri; + final DownloadTask task; + final String filePath; + final String? safRootUri; if (_storageService.isUsingSaf && safBaseUri != null) { - final safRootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); - if (safRootUri == null) { + final rootUri = await _safStorage.resolvePersistedPermissionUri(safBaseUri); + if (rootUri == null) { throw StateError('Selected SAF root has no persisted permission'); } - await _replaceDownloadSafRootClaim(globalKey, safRootUri); + await _replaceDownloadSafRootClaim(globalKey, rootUri); // SAF mode: use UriDownloadTask (writes directly to content:// URI, // with no pause/resume support). - final List pathComponents; - final String safFileName; - if (metadata.isMovie) { - pathComponents = _storageService.getMovieSafPathComponents(metadata); - safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.isEpisode) { - pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); - safFileName = _storageService.getEpisodeSafFileName(metadata, ext); - } else { - pathComponents = [serverId, metadata.id]; - safFileName = 'video.$ext'; - } + final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: serverId); - final safDirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); + final safDirUri = await _safStorage.createNestedDirectories(rootUri, target.components); if (safDirUri == null) { throw Exception('Failed to create SAF directory'); } - await _cleanupSafTargetFile(safDirUri, safFileName); + await _cleanupSafTargetFile(safDirUri, target.fileName); - final task = UriDownloadTask( + task = UriDownloadTask( url: resolution.videoUrl!, - filename: safFileName, + filename: target.fileName, directoryUri: Uri.parse(safDirUri), group: _downloadGroup, updates: Updates.statusAndProgress, @@ -1840,82 +1805,60 @@ class DownloadManagerService { metaData: globalKey, displayName: displayName, ); - - _pendingDownloadContext[globalKey] = _DownloadContext( - metadata: metadata, - queueItem: queueItem, - filePath: safDirUri, - extension: ext, - client: client, - showYear: showYear, - isSafMode: true, - safRootUri: safRootUri, - subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, - ); - - await _database.updateBgTaskId(globalKey, task.taskId); - final success = await FileDownloader().enqueue(task); - if (!success) throw Exception('Failed to enqueue SAF download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { - return true; - } - appLogger.i('Enqueued SAF download task ${task.taskId} for $globalKey'); - return false; - } - - await _replaceDownloadSafRootClaim(globalKey, null); - - // Normal mode: use DownloadTask with pause/resume support. - String downloadFilePath; - if (metadata.isMovie) { - downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); - } else if (metadata.isEpisode) { - downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); + filePath = safDirUri; + safRootUri = rootUri; } else { - downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); + await _replaceDownloadSafRootClaim(globalKey, null); + + // Normal mode: use DownloadTask with pause/resume support. + final String downloadFilePath; + if (metadata.isMovie) { + downloadFilePath = await _storageService.getMovieVideoPath(metadata, ext); + } else if (metadata.isEpisode) { + downloadFilePath = await _storageService.getEpisodeVideoPath(metadata, ext, showYear: showYear); + } else { + downloadFilePath = await _storageService.getVideoFilePath(serverId, metadata.id, ext); + } + + // Clean up partial files from previous attempts to prevent + // background_downloader from creating numbered copies (File (1).mp4). + await Future.wait([ + _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), + _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), + ]); + + await File(downloadFilePath).parent.create(recursive: true); + + task = DownloadTask( + url: resolution.videoUrl!, + filename: path.basename(downloadFilePath), + directory: path.dirname(downloadFilePath), + baseDirectory: BaseDirectory.root, + group: _downloadGroup, + updates: Updates.statusAndProgress, + requiresWiFi: requiresWiFi, + retries: _nativeRetries, + allowPause: true, + metaData: globalKey, + displayName: displayName, + ); + filePath = downloadFilePath; + safRootUri = null; } - // Clean up partial files from previous attempts to prevent - // background_downloader from creating numbered copies (File (1).mp4). - await Future.wait([ - _deleteFileIfExists(File(downloadFilePath), 'stale video before re-download'), - _deleteFileIfExists(File('$downloadFilePath.part'), 'stale .part before re-download'), - ]); - - await File(downloadFilePath).parent.create(recursive: true); - - final task = DownloadTask( - url: resolution.videoUrl!, - filename: path.basename(downloadFilePath), - directory: path.dirname(downloadFilePath), - baseDirectory: BaseDirectory.root, - group: _downloadGroup, - updates: Updates.statusAndProgress, - requiresWiFi: requiresWiFi, - retries: _nativeRetries, - allowPause: true, - metaData: globalKey, - displayName: displayName, - ); - _pendingDownloadContext[globalKey] = _DownloadContext( metadata: metadata, queueItem: queueItem, - filePath: downloadFilePath, + filePath: filePath, extension: ext, client: client, showYear: showYear, + isSafMode: safRootUri != null, + safRootUri: safRootUri, subtitles: resolution.externalSubtitlesResolved ? resolution.externalSubtitles : null, ); - await _database.updateBgTaskId(globalKey, task.taskId); - final success = await FileDownloader().enqueue(task); - if (!success) throw Exception('Failed to enqueue download task'); - if (await _cancelEnqueuedTaskIfInactive(globalKey, task.taskId)) { - return true; - } - appLogger.i('Enqueued download task ${task.taskId} for $globalKey'); - return false; + return _enqueuePreparedTask(globalKey, task, safRootUri != null ? 'SAF download' : 'download'); }); if (becameInactive) return true; return true; @@ -2395,36 +2338,20 @@ class DownloadManagerService { try { final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey); final client = ctx?.client ?? await _getClientForDownloadKey(globalKey); - final showYear = ctx?.showYear; if (metadata != null && client != null) { - if (downloadArtwork) { - final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); - final chapterArtworkSettled = metadata.serverId == null - ? false - : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); - artworkSettled = itemArtworkSettled && chapterArtworkSettled; - } - if (downloadSubtitles) { - var subtitles = ctx?.subtitles; - if (subtitles == null) { - try { - final resolution = await client.resolveDownload( - metadata, - mediaIndex: existingCheck.mediaIndex, - mediaSourceId: existingCheck.mediaSourceId, - ); - if (resolution.externalSubtitlesResolved) { - subtitles = resolution.externalSubtitles; - } - } catch (e, st) { - appLogger.w('Could not re-resolve subtitles for $globalKey', error: e, stackTrace: st); - } - } - if (subtitles != null) { - subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); - } - } + final settled = await _runSupplementaryDownloads( + globalKey, + metadata, + client, + downloadArtwork: downloadArtwork, + downloadSubtitles: downloadSubtitles, + record: existingCheck, + showYear: ctx?.showYear, + preresolvedSubtitles: ctx?.subtitles, + ); + artworkSettled = settled.artwork; + subtitlesSettled = settled.subtitles; } } catch (e, st) { appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st); @@ -2479,23 +2406,12 @@ class DownloadManagerService { } Future _resolveSafStoredPath(MediaItem metadata, String ext, int? showYear, String safRootUri) async { - final List pathComponents; - final String safFileName; - if (metadata.isMovie) { - pathComponents = _storageService.getMovieSafPathComponents(metadata); - safFileName = _storageService.getMovieSafFileName(metadata, ext); - } else if (metadata.isEpisode) { - pathComponents = _storageService.getEpisodeSafPathComponents(metadata, showYear: showYear); - safFileName = _storageService.getEpisodeSafFileName(metadata, ext); - } else { - pathComponents = [metadata.serverId!, metadata.id]; - safFileName = 'video.$ext'; - } + final target = _storageService.safTarget(metadata, ext, showYear: showYear, serverId: metadata.serverId); - final dirUri = await _safStorage.createNestedDirectories(safRootUri, pathComponents); + final dirUri = await _safStorage.createNestedDirectories(safRootUri, target.components); if (dirUri == null) return null; - final child = await _safStorage.getChild(dirUri, [safFileName]); + final child = await _safStorage.getChild(dirUri, [target.fileName]); return child?.uri; } @@ -2521,6 +2437,58 @@ class DownloadManagerService { return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId); } + /// Best-effort supplementary work for an already-stored video (artwork, + /// chapter thumbnails, external subtitles); reports which half settled so the + /// caller can do its own queue-row bookkeeping. Shared by the completion path + /// and the deferred-repair path: [record] carries the media-source + /// coordinates for re-resolving subtitles, [preresolvedSubtitles] skips that + /// re-resolve, and [showYear] is caller-supplied because the paths differ. + Future<({bool artwork, bool subtitles})> _runSupplementaryDownloads( + String globalKey, + MediaItem metadata, + MediaServerClient client, { + required bool downloadArtwork, + required bool downloadSubtitles, + required DownloadedMediaItem? record, + required int? showYear, + List? preresolvedSubtitles, + }) async { + var artworkSettled = !downloadArtwork; + if (downloadArtwork) { + final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); + final chapterArtworkSettled = metadata.serverId == null + ? false + : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); + artworkSettled = itemArtworkSettled && chapterArtworkSettled; + } + + var subtitlesSettled = !downloadSubtitles; + if (downloadSubtitles) { + try { + var subtitles = preresolvedSubtitles; + if (subtitles == null) { + final resolution = await client.resolveDownload( + metadata, + mediaIndex: record?.mediaIndex ?? 0, + mediaSourceId: record?.mediaSourceId, + ); + if (resolution.externalSubtitlesResolved) { + subtitles = resolution.externalSubtitles; + } else { + appLogger.d('Subtitle enrichment remains deferred for $globalKey'); + } + } + if (subtitles != null) { + subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); + } + } catch (e, st) { + appLogger.w('Could not resolve subtitles for $globalKey', error: e, stackTrace: st); + } + } + + return (artwork: artworkSettled, subtitles: subtitlesSettled); + } + Future _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return false; @@ -3271,24 +3239,39 @@ class DownloadManagerService { } } - Future _deleteMovieStorageDirectory(MediaItem movie) async { + /// Delete one media directory and everything under it, on either storage backend. + /// [safComponents] and [fileDirectory] are thunks so only the branch that runs + /// resolves its path — the file-mode getters create the directory as a side effect. + Future _deleteStorageDirectory({ + required List Function() safComponents, + required Future Function() fileDirectory, + required String label, + }) async { if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; - final movieDir = await _safStorage.getChild(safBaseUri, _storageService.getMovieSafPathComponents(movie)); - if (movieDir != null) { - await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory'); + final dir = await _safStorage.getChild(safBaseUri, safComponents()); + if (dir != null) { + await _deleteSafDirRecursive(dir.uri, description: '$label directory'); } return; } - final movieDir = await _storageService.getMovieDirectory(movie); - if (await movieDir.exists()) { - await movieDir.delete(recursive: true); - appLogger.i('Deleted movie directory: ${movieDir.path}'); + final dir = await fileDirectory(); + if (await dir.exists()) { + await dir.delete(recursive: true); + appLogger.i('Deleted $label directory: ${dir.path}'); } } + Future _deleteMovieStorageDirectory(MediaItem movie) { + return _deleteStorageDirectory( + safComponents: () => _storageService.getMovieSafPathComponents(movie), + fileDirectory: () => _storageService.getMovieDirectory(movie), + label: 'movie', + ); + } + Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo( MediaItem episode, { required int? showYear, @@ -3335,50 +3318,32 @@ class DownloadManagerService { } Future _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async { + await _deleteStorageDirectory( + safComponents: () => _storageService.getSeasonSafPathComponents(season, showYear: showYear), + fileDirectory: () => _storageService.getSeasonDirectory(season, showYear: showYear), + label: 'season', + ); + + // Drop the parent show directory too if the deleted season left it empty. if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; - final seasonDir = await _safStorage.getChild( - safBaseUri, - _storageService.getSeasonSafPathComponents(season, showYear: showYear), - ); - if (seasonDir != null) { - await _deleteSafDirRecursive(seasonDir.uri, description: 'season directory'); - } final showDir = await _safStorage.getChild( safBaseUri, _storageService.getShowSafPathComponents(season, showYear: showYear), ); - if (showDir != null) { - await _deleteEmptySafDirsInOrder([showDir.uri]); - } + await _deleteEmptySafDirsInOrder([showDir?.uri]); return; } - - final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear); - if (await seasonDir.exists()) { - await seasonDir.delete(recursive: true); - appLogger.i('Deleted season directory: ${seasonDir.path}'); - } await _cleanupShowDirectory(season, showYear); } - Future _deleteShowStorageDirectory(MediaItem show) async { - if (_storageService.isUsingSaf) { - final safBaseUri = _storageService.safBaseUri; - if (safBaseUri == null) return; - final showDir = await _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(show)); - if (showDir != null) { - await _deleteSafDirRecursive(showDir.uri, description: 'show directory'); - } - return; - } - - final showDir = await _storageService.getShowDirectory(show); - if (await showDir.exists()) { - await showDir.delete(recursive: true); - appLogger.i('Deleted show directory: ${showDir.path}'); - } + Future _deleteShowStorageDirectory(MediaItem show) { + return _deleteStorageDirectory( + safComponents: () => _storageService.getShowSafPathComponents(show), + fileDirectory: () => _storageService.getShowDirectory(show), + label: 'show', + ); } /// Safety net: after metadata-based deletion, verify the actual DB-recorded diff --git a/lib/services/download_storage_service.dart b/lib/services/download_storage_service.dart index a2deca0a..e7adb362 100644 --- a/lib/services/download_storage_service.dart +++ b/lib/services/download_storage_service.dart @@ -7,6 +7,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:path/path.dart' as path; import '../media/media_item.dart'; +import '../media/media_item_types.dart'; import '../utils/app_logger.dart'; import '../utils/formatters.dart'; import 'settings_service.dart'; @@ -477,6 +478,28 @@ class DownloadStorageService { /// Get the extension-less episode filename used for SAF lookups. String getEpisodeSafBaseName(MediaItem episode) => _formatEpisodeFileName(episode); + /// Directory components and file name of a SAF download target. Used by both + /// the enqueue path that writes the file and the completion path that looks + /// it back up, so the two stay on the same layout. [serverId] is only read + /// for kinds without a dedicated folder scheme. + ({List components, String fileName}) safTarget( + MediaItem metadata, + String extension, { + int? showYear, + required String? serverId, + }) { + if (metadata.isMovie) { + return (components: getMovieSafPathComponents(metadata), fileName: getMovieSafFileName(metadata, extension)); + } + if (metadata.isEpisode) { + return ( + components: getEpisodeSafPathComponents(metadata, showYear: showYear), + fileName: getEpisodeSafFileName(metadata, extension), + ); + } + return (components: [serverId!, metadata.id], fileName: 'video.$extension'); + } + bool isSafUri(String storedPath) { return storedPath.startsWith('content://'); } diff --git a/lib/services/downloaded_video_source.dart b/lib/services/downloaded_video_source.dart new file mode 100644 index 00000000..c343e2e3 --- /dev/null +++ b/lib/services/downloaded_video_source.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import '../database/app_database.dart'; +import '../models/download_models.dart'; +import '../utils/app_logger.dart'; +import '../utils/downloaded_version_match.dart'; +import 'download_storage_service.dart'; + +/// A downloaded copy resolved to a playable location, plus the version that is +/// actually on disk — which can differ from the requested one when +/// [resolveDownloadedVideoSource] was allowed to fall back. +typedef DownloadedVideoSource = ({String path, int mediaIndex, String? mediaSourceId}); + +/// Single source of truth for "where is the playable copy of this downloaded +/// row, and is it the version that was asked for". +/// +/// Returns null when the row cannot back playback: the download is not +/// complete, it holds a different version than requested (unless +/// [allowAnyDownloadedVersion]), it has no stored video path, or the stored +/// file is gone from disk. +/// +/// Version matching is strict by default so online flows keep streaming an +/// explicitly requested non-downloaded version (issue #1440). With +/// [allowAnyDownloadedVersion] the downloaded version is returned on mismatch +/// instead — for offline flows where the alternative is failing outright. +/// +/// Callers own their own preconditions (profile ownership, how the row was +/// looked up); this only judges the row itself. +Future resolveDownloadedVideoSource( + DownloadedMediaItem row, { + int? requestedMediaIndex, + String? requestedMediaSourceId, + bool allowAnyDownloadedVersion = false, +}) async { + if (row.status != DownloadStatus.completed.index) { + appLogger.d('Download not complete for ${row.globalKey}. Status: ${row.status}'); + return null; + } + + if (!downloadedVersionMatches( + row, + requestedMediaIndex: requestedMediaIndex, + requestedMediaSourceId: requestedMediaSourceId, + )) { + if (!allowAnyDownloadedVersion) { + appLogger.d( + '[VersionTrace] Downloaded copy of ${row.globalKey} is version ${row.mediaIndex} ' + '(source ${row.mediaSourceId}), but requested version $requestedMediaIndex ' + '(source ${requestedMediaSourceId?.trim()}) — skipping offline', + ); + return null; + } + appLogger.d( + '[VersionTrace] Requested version $requestedMediaIndex (source ${requestedMediaSourceId?.trim()}) ' + 'is not downloaded — falling back to downloaded version ${row.mediaIndex} ' + '(source ${row.mediaSourceId})', + ); + } + + final storedPath = row.videoFilePath; + if (storedPath == null) { + appLogger.d('Video file path is null for ${row.globalKey}'); + return null; + } + + final storageService = DownloadStorageService.instance; + // SAF URIs (content://) are already playable and come back untouched; file + // paths may be stored relative, so resolve them and confirm they still exist. + final readablePath = await storageService.getReadablePath(storedPath); + if (!storageService.isSafUri(storedPath) && !await File(readablePath).exists()) { + appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)'); + return null; + } + + appLogger.d('Found offline video: $readablePath'); + return (path: readablePath, mediaIndex: row.mediaIndex, mediaSourceId: row.mediaSourceId); +} diff --git a/lib/services/episode_navigation_service.dart b/lib/services/episode_navigation_service.dart index 26f5b190..b6997972 100644 --- a/lib/services/episode_navigation_service.dart +++ b/lib/services/episode_navigation_service.dart @@ -36,7 +36,6 @@ class AdjacentEpisodes { bool get hasNext => next != null; bool get hasPrevious => previous != null; bool get isEndConfirmed => nextStatus == QueueNavigationStatus.boundary; - bool get nextLoadFailed => nextStatus == QueueNavigationStatus.failed; } enum _EpisodeQueueAvailability { active, unavailable, failed } diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart index 3eec20be..60b044e3 100644 --- a/lib/services/fullscreen_state_manager.dart +++ b/lib/services/fullscreen_state_manager.dart @@ -30,70 +30,22 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { Future toggleFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - final isCurrentlyFullscreen = await MacOSWindowService.isFullscreen(); - if (isCurrentlyFullscreen) { - await MacOSWindowService.exitFullscreen(); - } else { - await MacOSWindowService.enterFullscreen(); - } - } else if (Platform.isWindows) { - // Route through the native Win32 runner, which restores to the monitor - // the window is currently on (window_manager 0.5.1 picks the wrong one - // on multi-monitor setups — see issue #880). The native code also - // preserves maximized state internally, so no unmaximize dance here. - final isCurrentlyFullscreen = await NativeWindowService.isFullScreen(); - await NativeWindowService.setFullScreen(!isCurrentlyFullscreen); - } else { - final isCurrentlyFullscreen = await windowManager.isFullScreen(); - if (isCurrentlyFullscreen) { - await windowManager.setFullScreen(false); - if (_wasMaximized) { - await windowManager.maximize(); - _wasMaximized = false; - } - } else { - _wasMaximized = await windowManager.isMaximized(); - if (_wasMaximized) { - await windowManager.unmaximize(); - } - await windowManager.setFullScreen(true); - } - } + final isCurrentlyFullscreen = await _platformIsFullscreen(); + await _platformSetFullscreen(!isCurrentlyFullscreen); } /// Enter fullscreen, preserving maximized state on Windows/Linux for restoration on exit. Future enterFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - await MacOSWindowService.enterFullscreen(); - } else if (Platform.isWindows) { - await NativeWindowService.setFullScreen(true); - } else { - _wasMaximized = await windowManager.isMaximized(); - if (_wasMaximized) { - await windowManager.unmaximize(); - } - await windowManager.setFullScreen(true); - } + await _platformSetFullscreen(true); } /// Exit fullscreen, restoring maximized state if needed Future exitFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - await MacOSWindowService.exitFullscreen(); - } else if (Platform.isWindows) { - await NativeWindowService.setFullScreen(false); - } else { - await windowManager.setFullScreen(false); - if (_wasMaximized) { - await windowManager.maximize(); - _wasMaximized = false; - } - } + await _platformSetFullscreen(false); } /// Exits fullscreen when the platform window is currently fullscreen. @@ -103,17 +55,47 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { Future exitFullscreenIfActive() async { if (!PlatformDetector.isDesktopOS()) return false; - final isActive = Platform.isMacOS - ? await MacOSWindowService.isFullscreen() - : Platform.isWindows - ? await NativeWindowService.isFullScreen() - : await windowManager.isFullScreen(); + final isActive = await _platformIsFullscreen(); if (!isActive) return false; - await exitFullscreen(); + await _platformSetFullscreen(false); return true; } + Future _platformIsFullscreen() { + if (Platform.isMacOS) return MacOSWindowService.isFullscreen(); + if (Platform.isWindows) return NativeWindowService.isFullScreen(); + return windowManager.isFullScreen(); + } + + Future _platformSetFullscreen(bool value) async { + if (Platform.isMacOS) { + if (value) { + await MacOSWindowService.enterFullscreen(); + } else { + await MacOSWindowService.exitFullscreen(); + } + } else if (Platform.isWindows) { + // Route through the native Win32 runner, which restores to the monitor + // the window is currently on (window_manager 0.5.1 picks the wrong one + // on multi-monitor setups — see issue #880). The native code also + // preserves maximized state internally, so no unmaximize dance here. + await NativeWindowService.setFullScreen(value); + } else if (value) { + _wasMaximized = await windowManager.isMaximized(); + if (_wasMaximized) { + await windowManager.unmaximize(); + } + await windowManager.setFullScreen(true); + } else { + await windowManager.setFullScreen(false); + if (_wasMaximized) { + await windowManager.maximize(); + _wasMaximized = false; + } + } + } + void startMonitoring() { if (!_shouldMonitor() || _isListening) return; diff --git a/lib/services/jellyfin_auth_service.dart b/lib/services/jellyfin_auth_service.dart index ba7c7020..2237e3f3 100644 --- a/lib/services/jellyfin_auth_service.dart +++ b/lib/services/jellyfin_auth_service.dart @@ -5,7 +5,6 @@ import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import '../connection/connection.dart'; -import '../connection/connection_auth_service.dart'; import '../exceptions/media_server_exceptions.dart'; import '../utils/app_logger.dart'; import '../utils/media_server_http_client.dart'; @@ -45,9 +44,9 @@ class _JellyfinAuthenticationResponse { /// 2. [authenticateByName] (or future Quick Connect equivalent) — exchanges /// credentials for a long-lived access token and returns a built /// [JellyfinConnection] ready to insert into [ConnectionRegistry]. -/// 3. (later) [validate] / [refresh] / [signOut] for the [ConnectionAuthService] -/// contract. -class JellyfinConnectionAuthService implements ConnectionAuthService { +/// 3. (later) [validate] / [refresh] / [signOut] to keep the stored +/// connection current. +class JellyfinConnectionAuthService { JellyfinConnectionAuthService({ required this.clientName, required this.clientVersion, @@ -323,7 +322,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { } } - @override + /// Best-effort check that an existing token still works. Returns false on + /// 401/403; throws on transport failures the caller should retry. Future validate(Connection connection) async { if (connection is! JellyfinConnection) return false; final client = _authenticatedClient(connection); @@ -338,7 +338,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { } } - @override + /// Re-check the stored token and return the connection with its status + /// updated accordingly. Future refresh(Connection connection) async { if (connection is! JellyfinConnection) return connection; final ok = await validate(connection); @@ -348,7 +349,8 @@ class JellyfinConnectionAuthService implements ConnectionAuthService { return connection.copyWith(status: ConnectionStatus.online, lastAuthenticatedAt: DateTime.now()); } - @override + /// Revoke the token server-side and forget local credentials. The caller + /// is responsible for removing the row from [ConnectionRegistry]. Future signOut(Connection connection) async { if (connection is! JellyfinConnection) return; final client = _authenticatedClient(connection); diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 9353ad80..a34205cf 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -76,6 +76,20 @@ part 'jellyfin_client/parts/live_tv.dart'; part 'jellyfin_client/parts/images_downloads.dart'; part 'jellyfin_client/parts/metadata_edit.dart'; +/// Canonical declarations of the [JellyfinClient] internals that the `part` +/// mixins call into. +/// +/// Every part mixin is `on _JellyfinClientInternals`, so each shared member is +/// declared exactly once here instead of being re-declared per file. Members +/// used by a single part stay declared in that part. +mixin _JellyfinClientInternals on MediaServerCacheMixin { + JellyfinConnection get connection; + FailoverHttpClient get _http; + MediaItem? _mapItem(Map json); + List _mapItems(Iterable> items); + String? _absolutizeImagePath(String? path); +} + /// [MediaServerClient] over a Jellyfin server. /// /// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the @@ -85,6 +99,7 @@ part 'jellyfin_client/parts/metadata_edit.dart'; class JellyfinClient with MediaServerCacheMixin, + _JellyfinClientInternals, _JellyfinBrowseMethods, _JellyfinMusicMethods, _JellyfinPlaybackMethods, diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index 5a9ac2a2..cbcc10d7 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -30,6 +30,27 @@ List> _itemsArray(Object? data) { return const []; } +/// Builds a [LibraryPage] from an `/Items`-shaped response: the `Items` array +/// run through [map], plus the server's `TotalRecordCount` when it reports one. +/// Responses that omit it (or return a non-int) fall back to +/// [fallbackPageTotal], whose full-page sentinel keeps pagination enabled; +/// [singlePage] endpoints return everything at once, so a full page there means +/// the end of the list, not "there may be more". +LibraryPage _pagedItems( + Object? data, { + required int offset, + required List Function(List>) map, + int? requestedSize, + bool singlePage = false, +}) { + final rawItems = _itemsArray(data); + final rawTotal = data is Map ? data['TotalRecordCount'] : null; + final fallbackTotal = singlePage + ? offset + rawItems.length + : fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); + return LibraryPage(items: map(rawItems), totalCount: rawTotal is int ? rawTotal : fallbackTotal, offset: offset); +} + /// Slim field set for grid/list browsing — what the card UI actually /// renders (title, year, watched badge, episode count for series). /// @@ -145,12 +166,7 @@ const _detailFields = // any extra round-trip. 'ProviderIds'; -mixin _JellyfinBrowseMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - MediaItem? _mapItem(Map json); - List _mapItems(Iterable> items); - +mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // Endpoint conventions follow what the official Jellyfin Kotlin SDK // generates (cross-checked against the Findroid client). The SDK mixes // `/Users/{userId}/...` for "user library" / "views" / "latest" / "single @@ -701,7 +717,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final items = _itemsArray(data); final rawTotal = data is Map ? data['TotalRecordCount'] : null; if (items.isNotEmpty || (rawTotal is int && rawTotal > 0)) { - return _pagedMediaItems(data, offset: offset, requestedSize: pageSize); + return _pagedItems(data, offset: offset, requestedSize: pageSize, map: _mapItems); } } } on MediaServerHttpException { @@ -723,7 +739,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } Future> fetchSeasonEpisodesPage( @@ -755,7 +771,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } /// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query @@ -926,27 +942,19 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { required String includeItemTypes, bool byAlbumArtist = false, AbortController? abort, - }) async { - final all = []; - var start = 0; - while (true) { - abort?.throwIfAborted(); - final page = await _fetchPlayableDescendantsPage( + }) { + return drainPages( + (start, size) => _fetchPlayableDescendantsPage( parentId, start: start, - size: _pagedListPageSize, + size: size, abort: abort, includeItemTypes: includeItemTypes, byAlbumArtist: byAlbumArtist, - ); - abort?.throwIfAborted(); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - abort?.throwIfAborted(); - return all; + ), + pageSize: _pagedListPageSize, + abort: abort, + ); } @override @@ -992,7 +1000,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } /// All episodes of a series in the app's **aired watch order** — primarily by @@ -1148,18 +1156,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } @override - Future> fetchPersonMedia(String personId) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPersonMediaPage(personId, start: start, size: _pagedListPageSize); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchPersonMedia(String personId) => drainPages( + (start, size) => fetchPersonMediaPage(personId, start: start, size: size), + pageSize: _pagedListPageSize, + ); @override Future> fetchPersonMediaPage( @@ -1188,28 +1188,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); - } - - @override - Future> fetchRecentlyAdded({int limit = 50}) async { - // Matches userLibraryApi.getLatestMedia in the Jellyfin SDK. - final response = await _http.get( - '/Users/${_segment(connection.userId)}/Items/Latest', - queryParameters: { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }, - ); - throwIfHttpError(response); - final data = response.data; - // Latest returns a bare array, not an Items wrapper. - if (data is List) { - return _mapItems(data.whereType>()); - } - return _mapItems(_itemsArray(data)); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } @override @@ -1246,82 +1225,17 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { // Jellyfin doesn't expose a single "hubs" endpoint, so we synthesise the // home rows from Latest plus optional playback rows. The richer Plex Discover surface // is intentionally left untranslated — see ServerCapabilities.richHubs. - final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'IncludeItemTypes': 'Movie,Series,Episode', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry); - - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: latest, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); - } - - final results = await Future.wait([ - latestFuture, - _safeFetchItemsArray('/UserItems/Resume', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'MediaTypes': 'Video', - 'Recursive': 'true', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry), - _safeFetchItemsArray('/Shows/NextUp', { - 'userId': connection.userId, - 'Limit': limit.toString(), - 'Fields': _browseFields, - 'EnableResumable': 'false', - 'EnableTotalRecordCount': 'false', - ...jellyfinImageQueryParameters, - }, retry: _homeHubRetry), - ]); - - return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.continue', - title: t.discover.continueWatching, - type: 'mixed', - items: results[1], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.nextup', - title: t.discover.nextUp, - type: 'episode', - items: results[2], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'home.recent', - title: t.discover.recentlyAdded, - type: 'mixed', - items: results.first, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); + return _playbackHubSet( + idPrefix: 'home', + limit: limit, + includePlaybackHubs: includePlaybackHubs, + includeNextUp: true, + retry: _homeHubRetry, + latestItemTypes: 'Movie,Series,Episode', + continueTitle: t.discover.continueWatching, + nextUpTitle: t.discover.nextUp, + recentTitle: t.discover.recentlyAdded, + ); } @override @@ -1353,86 +1267,92 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { // Issued in parallel so the recommended tab loads in one round-trip. // When the caller knows the library kind, skip NextUp for movie libraries; // Jellyfin can otherwise spend time scanning TV state only to return []. + return _playbackHubSet( + parentId: libraryId, + idPrefix: 'library.$libraryId', + limit: limit, + includePlaybackHubs: includePlaybackHubs, + includeNextUp: libraryKind == null || libraryKind == MediaKind.show, + retry: _libraryHubRetry, + continueTitle: t.discover.continueWatchingIn(library: libraryName), + nextUpTitle: t.discover.nextUpIn(library: libraryName), + recentTitle: t.discover.recentlyAddedIn(library: libraryName), + ); + } + + /// Latest + Continue Watching + Next Up row set shared by the home and + /// per-library surfaces. Both scopes issue the same three requests in the + /// same order and synthesise the same three rows; they differ only in + /// [parentId], the row identifier prefix, the titles, and the transport + /// policy. The Latest request fires before the [includePlaybackHubs] + /// short-circuit so callers that only want Recently Added still get it in + /// one round-trip. + Future> _playbackHubSet({ + required String idPrefix, + required int limit, + required bool includePlaybackHubs, + required bool includeNextUp, + required _HubRetryPolicy retry, + required String continueTitle, + required String nextUpTitle, + required String recentTitle, + String? parentId, + String? latestItemTypes, + }) async { final latestFuture = _safeFetchItemsArray('/Users/${_segment(connection.userId)}/Items/Latest', { 'Limit': limit.toString(), - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Fields': _browseFields, + 'IncludeItemTypes': ?latestItemTypes, ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry); + }, retry: retry); - if (!includePlaybackHubs) { - final latest = await latestFuture; - return [ + MediaHub hub(String suffix, String title, String type, List> items) => JellyfinMappers.syntheticHub( mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: latest, + identifier: '$idPrefix.$suffix', + title: title, + type: type, + items: items, previewLimit: limit, serverId: serverId, serverName: serverName, - ), - ].where((h) => h.items.isNotEmpty).toList(); + ); + + if (!includePlaybackHubs) { + final latest = await latestFuture; + return [hub('recent', recentTitle, 'mixed', latest)].where((h) => h.items.isNotEmpty).toList(); } - final includeNextUp = libraryKind == null || libraryKind == MediaKind.show; final results = await Future.wait([ latestFuture, _safeFetchItemsArray('/UserItems/Resume', { 'userId': connection.userId, - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Limit': limit.toString(), 'Fields': _browseFields, 'MediaTypes': 'Video', 'Recursive': 'true', 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry), + }, retry: retry), includeNextUp ? _safeFetchItemsArray('/Shows/NextUp', { 'userId': connection.userId, - 'ParentId': libraryId, + 'ParentId': ?parentId, 'Limit': limit.toString(), 'Fields': _browseFields, 'EnableResumable': 'false', 'EnableTotalRecordCount': 'false', ...jellyfinImageQueryParameters, - }, retry: _libraryHubRetry) + }, retry: retry) : Future.value(const >[]), ]); return [ - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.continue', - title: t.discover.continueWatchingIn(library: libraryName), - type: 'mixed', - items: results[1], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.nextup', - title: t.discover.nextUpIn(library: libraryName), - type: 'episode', - items: results[2], - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), - JellyfinMappers.syntheticHub( - mapItem: _mapItem, - identifier: 'library.$libraryId.recent', - title: t.discover.recentlyAddedIn(library: libraryName), - type: 'mixed', - items: results.first, - previewLimit: limit, - serverId: serverId, - serverName: serverName, - ), + hub('continue', continueTitle, 'mixed', results[1]), + hub('nextup', nextUpTitle, 'episode', results[2]), + hub('recent', recentTitle, 'mixed', results.first), ].where((h) => h.items.isNotEmpty).toList(); } @@ -1660,16 +1580,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { try { final response = await _http.get(path, queryParameters: queryParameters, abort: abort); throwIfHttpError(response); - final data = response.data; - final rawItems = data is List ? data.whereType>().toList() : _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = singlePage - ? offset + rawItems.length - : fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - return LibraryPage( - items: _mapItems(rawItems), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, + return _pagedItems( + response.data, offset: offset, + requestedSize: requestedSize, + singlePage: singlePage, + map: _mapItems, ); } catch (e, st) { appLogger.w('JellyfinClient: $path failed', error: e, stackTrace: st); @@ -1677,17 +1593,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } } - LibraryPage _pagedMediaItems(Object? data, {required int offset, required int requestedSize}) { - final rawItems = _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - return LibraryPage( - items: _mapItems(rawItems), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, - offset: offset, - ); - } - @override Future> fetchRelatedHubs(String id, {int count = 10}) async { final response = await _http.get( diff --git a/lib/services/jellyfin_client/parts/collections.dart b/lib/services/jellyfin_client/parts/collections.dart index bdfa44c2..8e3f8f29 100644 --- a/lib/services/jellyfin_client/parts/collections.dart +++ b/lib/services/jellyfin_client/parts/collections.dart @@ -1,27 +1,15 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinCollectionMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - List _mapItems(Iterable> items); - +mixin _JellyfinCollectionMethods on _JellyfinClientInternals { static const int _collectionsPageSize = 36; String? _boxSetsViewId; @override - Future> fetchCollections(String libraryId) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchCollectionsPage(libraryId, start: start, size: _collectionsPageSize); - all.addAll(page.items); - if (page.items.isEmpty) break; - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchCollections(String libraryId) => drainPages( + (start, size) => fetchCollectionsPage(libraryId, start: start, size: size), + pageSize: _collectionsPageSize, + ); @override Future> fetchCollectionsPage( @@ -54,7 +42,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _itemsPage(response.data, offset: s, requestedSize: pageSize); + return _pagedItems(response.data, offset: s, requestedSize: pageSize, map: _mapItems); } Future _fetchBoxSetsViewId({AbortController? abort}) async { @@ -73,14 +61,6 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { return null; } - LibraryPage _itemsPage(Object? data, {required int offset, int? requestedSize}) { - final rawItems = _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - final total = rawTotal is int ? rawTotal : fallbackTotal; - return LibraryPage(items: _mapItems(rawItems), totalCount: total, offset: offset); - } - @override Future> fetchCollectionPage( String collectionId, { @@ -104,7 +84,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _itemsPage(response.data, offset: s, requestedSize: size); + return _pagedItems(response.data, offset: s, requestedSize: size, map: _mapItems); } @override diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart index 0360cc64..77d738b5 100644 --- a/lib/services/jellyfin_client/parts/file_info.dart +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -1,6 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { +mixin _JellyfinFileInfoMethods on _JellyfinClientInternals { @override Future getFileInfo(MediaItem item) async { // Lightweight browse responses omit `MediaSources`; detail and some cached diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 5035a055..06f89269 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -1,7 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { - JellyfinConnection get connection; +mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals { Future fetchPlaybackBundle( String itemId, { int sourceIndex = 0, diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 2c132aaf..fa31ce41 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinLiveTvMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - String? _absolutizeImagePath(String? path); +mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { Future>> _safeFetchItemsArray( String path, Map queryParameters, { diff --git a/lib/services/jellyfin_client/parts/metadata_edit.dart b/lib/services/jellyfin_client/parts/metadata_edit.dart index 5afd2356..5e8a29d2 100644 --- a/lib/services/jellyfin_client/parts/metadata_edit.dart +++ b/lib/services/jellyfin_client/parts/metadata_edit.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals { Future?> fetchEditableMetadataItem(String itemId) async { if (isOfflineMode) return null; final response = await _http.get('/Users/${_segment(connection.userId)}/Items/${_segment(itemId)}'); @@ -43,13 +40,6 @@ mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin { return data is Map ? data : const {}; } - Future>> getItemImageInfos(String itemId) async { - final response = await _http.get('/Items/${_segment(itemId)}/Images'); - throwIfHttpError(response); - final data = response.data; - return data is List ? data.whereType>().toList() : const >[]; - } - Future downloadRemoteImage(String itemId, {required String imageType, required String imageUrl}) async { final response = await _http.post( '/Items/${_segment(itemId)}/RemoteImages/Download', diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart index ec556d94..81f293ac 100644 --- a/lib/services/jellyfin_client/parts/music.dart +++ b/lib/services/jellyfin_client/parts/music.dart @@ -4,11 +4,7 @@ part of '../../jellyfin_client.dart'; /// listings, instant mix, and lyrics. Endpoint conventions follow the /// Jellyfin web client's music surface (cross-checked against the Kotlin /// SDK), mirroring the style notes at the top of `browse.dart`. -mixin _JellyfinMusicMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - List _mapItems(Iterable> items); - +mixin _JellyfinMusicMethods on _JellyfinClientInternals { /// Albums credited to [artist], newest first. Queries `AlbumArtistIds` /// rather than `ParentId` because Jellyfin links albums to artists via /// tags — an artist's albums are usually not its folder children. diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 861588bc..060ae2b9 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -9,36 +9,7 @@ bool _canUseJellyfinStaticStreamFallback(Object error) { return true; } -PlaybackException _classifyJellyfinPlaybackFailure(Object error) { - if (error is MediaServerAuthException || - error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { - return PlaybackException( - t.messages.playbackAuthenticationRequired, - reason: PlaybackFailureReason.authenticationRequired, - ); - } - if (error is MediaServerHttpException) { - if (error.isCancellation) { - return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); - } - final status = error.statusCode; - if (error.isTransient || status != null && status >= 500) { - return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); - } - if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - } - if (error is FormatException || error is TypeError) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - return PlaybackException(t.messages.playbackFailed); -} - -mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters /// at the item level (`raw['Chapters']`) and native skip segments through a /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort @@ -230,7 +201,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId); } catch (error, stackTrace) { if (!_canUseJellyfinStaticStreamFallback(error)) { - Error.throwWithStackTrace(_classifyJellyfinPlaybackFailure(error), stackTrace); + Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace); } appLogger.w( 'Jellyfin playback negotiation unavailable; using the static stream', @@ -750,20 +721,16 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { @override Map get streamHeaders => const {}; - /// Tell the server the user has started playing [itemId]. Body shape - /// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the - /// same fields, and Jellyfin's session tracker drops events that omit - /// `PlayMethod` because it has no way to associate progress with an - /// active session row. - /// - /// [duration] is accepted for interface symmetry with Plex but ignored — - /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes - /// are still sent so the active session reflects the chosen tracks. - @override - Future reportPlaybackStarted({ + /// Shared body for the `/Sessions/Playing[/Progress]` pair — only [path] and + /// [isPaused] differ between start and progress. Shape mirrors the Jellyfin + /// SDK's `PlaybackStartInfo`/`PlaybackProgressInfo`: Findroid sends the same + /// fields, and Jellyfin's session tracker drops events that omit `PlayMethod` + /// because it has no way to associate progress with an active session row. + Future _postPlayingState( + String path, { required String itemId, required Duration position, - Duration? duration, + required bool isPaused, String? playSessionId, String? playMethod, String? liveStreamId, @@ -772,44 +739,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { int? subtitleStreamIndex, }) async { final response = await _http.post( - '/Sessions/Playing', - body: { - 'ItemId': itemId, - 'MediaSourceId': ?mediaSourceId, - 'AudioStreamIndex': ?audioStreamIndex, - 'SubtitleStreamIndex': ?subtitleStreamIndex, - 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), - 'CanSeek': true, - 'IsPaused': false, - 'IsMuted': false, - 'PlayMethod': playMethod ?? 'DirectPlay', - 'RepeatMode': 'RepeatNone', - 'PlaybackOrder': 'Default', - 'PlaySessionId': ?playSessionId, - 'LiveStreamId': ?liveStreamId, - }, - ); - throwIfHttpError(response); - } - - /// Periodic progress ping (5–10s cadence is typical). Server uses this to - /// drive the resume position, detect idle sessions, and save remembered - /// audio/subtitle stream indexes when enabled in Jellyfin user settings. - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final response = await _http.post( - '/Sessions/Playing/Progress', + path, body: { 'ItemId': itemId, 'MediaSourceId': ?mediaSourceId, @@ -829,6 +759,63 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { throwIfHttpError(response); } + /// Tell the server the user has started playing [itemId]. + /// + /// [duration] is accepted for interface symmetry with Plex but ignored — + /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes + /// are still sent so the active session reflects the chosen tracks. + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => _postPlayingState( + '/Sessions/Playing', + itemId: itemId, + position: position, + isPaused: false, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + + /// Periodic progress ping (5–10s cadence is typical). Server uses this to + /// drive the resume position, detect idle sessions, and save remembered + /// audio/subtitle stream indexes when enabled in Jellyfin user settings. + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => _postPlayingState( + '/Sessions/Playing/Progress', + itemId: itemId, + position: position, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + /// End-of-playback signal. Final position becomes the resume bookmark. /// [duration] is accepted for interface symmetry with Plex but ignored. @override diff --git a/lib/services/jellyfin_client/parts/playlists.dart b/lib/services/jellyfin_client/parts/playlists.dart index 614cd82f..0e82d83d 100644 --- a/lib/services/jellyfin_client/parts/playlists.dart +++ b/lib/services/jellyfin_client/parts/playlists.dart @@ -1,31 +1,13 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - String? _absolutizeImagePath(String? path); - List _mapItems(Iterable> items); - +mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { static const int _playlistsPageSize = 200; @override - Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPlaylistsPage( - playlistType: playlistType, - smart: smart, - start: start, - size: _playlistsPageSize, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchPlaylists({String playlistType = 'video', bool? smart}) => drainPages( + (start, size) => fetchPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size), + pageSize: _playlistsPageSize, + ); @override Future> fetchPlaylistsPage({ @@ -70,15 +52,11 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - final items = _itemsArray(response.data).map(_playlistFromJson).toList(); - final rawTotal = response.data is Map - ? (response.data as Map)['TotalRecordCount'] - : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); - return LibraryPage( - items: items, - totalCount: rawTotal is int ? rawTotal : fallbackTotal, + return _pagedItems( + response.data, offset: offset, + requestedSize: pageSize, + map: (raw) => raw.map(_playlistFromJson).toList(), ); } @@ -125,16 +103,7 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - final items = _itemsArray(response.data); - final rawTotal = response.data is Map - ? (response.data as Map)['TotalRecordCount'] - : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); - return LibraryPage( - items: _mapItems(items), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, - offset: offset, - ); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } @override diff --git a/lib/services/jellyfin_client/parts/watch_state.dart b/lib/services/jellyfin_client/parts/watch_state.dart index 2c1e4daa..eb362295 100644 --- a/lib/services/jellyfin_client/parts/watch_state.dart +++ b/lib/services/jellyfin_client/parts/watch_state.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinWatchStateMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinWatchStateMethods on _JellyfinClientInternals { @override Future markWatched(MediaItem item) async { final response = await _http.post( diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 89469811..f0960a59 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -455,6 +455,16 @@ class JellyfinEndpointDiscovery { return List.unmodifiable(result); } + /// Splits a raw add/edit form field into the individual URLs the user typed. + /// Entries are separated by newlines and/or commas; blanks are dropped. + static List parseUserEnteredUrls(String raw) { + return raw + .split(RegExp(r'[\n,]+')) + .map((url) => url.trim()) + .where((url) => url.isNotEmpty) + .toList(growable: false); + } + static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable input) { final probeBaseUrls = []; final explicitBaseUrls = []; diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index 250d9326..a15e62cf 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -64,24 +64,18 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } - final abort = AbortController(); - - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: shuffle ? t.common.shuffle : t.common.play, - abort: abort, - execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(ServerId(serverId)); - if (client == null) { - return _missingClientError(serverId, dismissLoading); - } - + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:${facts.id}', + contextKey: facts.id, + shuffle: shuffle, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { // Playlists go through the dedicated `/Playlists/{id}/Items` endpoint // so playlist-defined order is preserved; collections fall back to // recursive descendant expansion (which skips unplayable Series // containers and surfaces Movies + Episodes flat). - List items; + final List items; if (facts.isPlaylist) { items = await fetchAllPlaylistItems(client, facts.id, abort: abort); } else if (client is JellyfinClient) { @@ -92,44 +86,15 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { items = await client.fetchPlayableDescendants(facts.id); } abort.throwIfAborted(); - - if (items.isEmpty) return const PlayQueueEmpty(); - - abort.throwIfAborted(); - if (shuffle) { - items = List.of(items)..shuffle(Random()); - } - - abort.throwIfAborted(); - // When a startItem is given (and we're not shuffling), keep the full - // original order and move the local queue cursor to that item. - var startIndex = 0; - if (!shuffle && startItem != null) { - startIndex = items.indexWhere((it) => it.id == startItem.id); - if (startIndex < 0) startIndex = 0; - } - - await dismissLoading(); - abort.throwIfAborted(); - if (!context.mounted && navigateForTesting == null) { - return const PlayQueueError('Context not mounted'); - } - - abort.throwIfAborted(); - final playbackState = playbackStateForTesting ?? context.read(); - return launchLocalQueuePlayback( - context: context, - playbackState: playbackState, - queue: LocalPlayQueue( - id: 'jellyfin:${facts.id}', - items: items, - currentIndex: startIndex, - shuffled: shuffle, - backendId: client.backend.id, - ), - contextKey: facts.id, - navigateForTesting: navigateForTesting, - ); + return items; + }, + // When a startItem is given (and we're not shuffling), keep the full + // original order and move the local queue cursor to that item. + resolveStartIndex: (items) { + final start = startItem; + if (shuffle || start == null) return 0; + final index = items.indexWhere((it) => it.id == start.id); + return index < 0 ? 0 : index; }, ); } @@ -137,6 +102,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { /// Launch playback from a Jellyfin folder row. Jellyfin has no server-side /// queue resource, so folders use the same local queue path as collections. /// The client query is video-only; music-only folders return [PlayQueueEmpty]. + @override Future launchFromFolder({ required MediaItem folder, required bool shuffle, @@ -147,24 +113,18 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return PlayQueueError(Exception('Item is missing serverId')); } - final abort = AbortController(); - - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: shuffle ? t.common.shuffle : t.common.play, - abort: abort, - execute: (dismissLoading) async { - final client = clientForTesting ?? _resolveClient(ServerId(serverId)); - if (client == null) { - return _missingClientError(serverId, dismissLoading); - } - + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:folder:${folder.id}', + contextKey: folder.id, + shuffle: shuffle, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { final fetched = client is JellyfinClient ? await client.fetchPlayableFolderDescendants(folder.id, abort: abort) : await client.fetchPlayableDescendants(folder.id); abort.throwIfAborted(); - var items = fetched.where((item) => item.kind.isVideo).map((item) { + return fetched.where((item) => item.kind.isVideo).map((item) { return item.copyWith( serverId: item.serverId ?? serverId, serverName: item.serverName ?? folder.serverName, @@ -172,35 +132,6 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { libraryTitle: item.libraryTitle ?? folder.libraryTitle, ); }).toList(); - - if (items.isEmpty) return const PlayQueueEmpty(); - - abort.throwIfAborted(); - if (shuffle) { - items = List.of(items)..shuffle(Random()); - } - - await dismissLoading(); - abort.throwIfAborted(); - if (!context.mounted && navigateForTesting == null) { - return const PlayQueueError('Context not mounted'); - } - - abort.throwIfAborted(); - final playbackState = playbackStateForTesting ?? context.read(); - return launchLocalQueuePlayback( - context: context, - playbackState: playbackState, - queue: LocalPlayQueue( - id: 'jellyfin:folder:${folder.id}', - items: items, - currentIndex: 0, - shuffled: shuffle, - backendId: client.backend.id, - ), - contextKey: folder.id, - navigateForTesting: navigateForTesting, - ); }, ); } @@ -226,12 +157,43 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { seriesId = parent; } + return _launchLocalQueue( + serverId: serverId, + queueId: 'jellyfin:$seriesId', + contextKey: seriesId, + shuffle: true, + showLoadingIndicator: showLoadingIndicator, + fetchItems: (client, abort) async { + final raw = client is JellyfinClient + ? await client.fetchClientSideEpisodeQueue(seriesId, abort: abort) + : await client.fetchClientSideEpisodeQueue(seriesId); + abort.throwIfAborted(); + if (raw == null) return const []; + return raw + .map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName)) + .toList(); + }, + ); + } + + /// Fetch, shuffle, and publish a local queue behind the cancellable loading + /// dialog. [fetchItems] carries the only per-entry-point difference: which + /// client call produces the items and how they're normalized. + Future _launchLocalQueue({ + required String serverId, + required String queueId, + required String contextKey, + required bool shuffle, + required bool showLoadingIndicator, + required Future> Function(MediaServerClient client, AbortController abort) fetchItems, + int Function(List items)? resolveStartIndex, + }) async { final abort = AbortController(); return executeWithLoading( context: context, showLoading: showLoadingIndicator, - actionLabel: t.common.shuffle, + actionLabel: shuffle ? t.common.shuffle : t.common.play, abort: abort, execute: (dismissLoading) async { final client = clientForTesting ?? _resolveClient(ServerId(serverId)); @@ -239,18 +201,16 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { return _missingClientError(serverId, dismissLoading); } - final raw = client is JellyfinClient - ? await client.fetchClientSideEpisodeQueue(seriesId, abort: abort) - : await client.fetchClientSideEpisodeQueue(seriesId); - abort.throwIfAborted(); - if (raw == null || raw.isEmpty) return const PlayQueueEmpty(); + var items = await fetchItems(client, abort); + if (items.isEmpty) return const PlayQueueEmpty(); abort.throwIfAborted(); - final shuffled = List.of(raw)..shuffle(Random()); + if (shuffle) { + items = List.of(items)..shuffle(Random()); + } + abort.throwIfAborted(); - final items = shuffled - .map((e) => e.copyWith(serverId: serverId, serverName: metadata.serverName ?? e.serverName)) - .toList(); + final startIndex = resolveStartIndex?.call(items) ?? 0; await dismissLoading(); abort.throwIfAborted(); @@ -264,13 +224,13 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { context: context, playbackState: playbackState, queue: LocalPlayQueue( - id: 'jellyfin:$seriesId', + id: queueId, items: items, - currentIndex: 0, - shuffled: true, + currentIndex: startIndex, + shuffled: shuffle, backendId: client.backend.id, ), - contextKey: seriesId, + contextKey: contextKey, navigateForTesting: navigateForTesting, ); }, diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 06d53d0d..8c6a2be9 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -9,12 +9,11 @@ import '../i18n/strings.g.dart'; import '../mpv/mpv.dart'; import 'settings_binding_owner.dart'; import 'settings_service.dart'; +import 'shortcut_action.dart'; import '../utils/platform_detector.dart'; import '../utils/player_utils.dart'; class KeyboardShortcutsService extends ChangeNotifier { - static const Set _repeatableVideoActions = {'zoom_in', 'zoom_out'}; - static KeyboardShortcutsService? _instance; static Future? _initialization; late final SettingsBindingOwner _settingsBinding; @@ -105,6 +104,7 @@ class KeyboardShortcutsService extends ChangeNotifier { Map get hotkeys => Map.from(_hotkeys); + @visibleForTesting HotKey? getHotkey(String action) { return _hotkeys[action]; } @@ -204,8 +204,6 @@ class KeyboardShortcutsService extends ChangeNotifier { VoidCallback? onVolumeUp, VoidCallback? onVolumeDown, VoidCallback? onToggleMute, - int? currentPositionEpoch, - ValueChanged? onLiveSeek, ValueChanged? onLiveSeekBy, Future Function(Duration position)? onSeekRequested, }) { @@ -219,12 +217,15 @@ class KeyboardShortcutsService extends ChangeNotifier { final isMetaPressed = HardwareKeyboard.instance.isMetaPressed; for (final entry in _hotkeys.entries) { - final action = entry.key; final hotkey = entry.value; if (hotkey == null) continue; if (physicalKey != hotkey.key) continue; + // Null for an id this build does not know: the event is still consumed so + // a stale binding never leaks through to another handler. + final action = ShortcutAction.fromId(entry.key); + final requiredModifiers = hotkey.modifiers ?? []; bool modifiersMatch = true; @@ -266,59 +267,88 @@ class KeyboardShortcutsService extends ChangeNotifier { continue; } - if (isRepeat && !_repeatableVideoActions.contains(action)) { + if (isRepeat && !(action?.repeatable ?? false)) { return KeyEventResult.handled; } - const playbackControlledActions = { - 'play_pause', - 'seek_forward', - 'seek_backward', - 'seek_forward_large', - 'seek_backward_large', - 'audio_track_next', - 'subtitle_track_next', - 'chapter_next', - 'chapter_previous', - 'speed_increase', - 'speed_decrease', - 'speed_reset', - 'sub_seek_next', - 'sub_seek_prev', - 'skip_marker', - }; - const mediaItemActions = {'episode_next', 'episode_previous'}; - if ((playbackControlledActions.contains(action) && !canControlPlayback) || - (mediaItemActions.contains(action) && !canNavigateMediaItems)) { + if (action == null || + (action.requiresPlayback && !canControlPlayback) || + (action.requiresMediaNavigation && !canNavigateMediaItems)) { return KeyEventResult.handled; } - _executeAction( - action, - player, - onToggleFullscreen, - onToggleSubtitles, - onNextAudioTrack, - onNextSubtitleTrack, - onNextChapter, - onPreviousChapter, - onPlayPause: onPlayPause, - onToggleShader: onToggleShader, - onSkipMarker: onSkipMarker, - onNextEpisode: onNextEpisode, - onPreviousEpisode: onPreviousEpisode, - onScreenshot: onScreenshot, - onZoomIn: onZoomIn, - onZoomOut: onZoomOut, - onZoomReset: onZoomReset, - onVolumeUp: onVolumeUp, - onVolumeDown: onVolumeDown, - onToggleMute: onToggleMute, - currentPositionEpoch: currentPositionEpoch, - onLiveSeek: onLiveSeek, - onLiveSeekBy: onLiveSeekBy, - onSeekRequested: onSeekRequested, - ); + void performSeek(int offsetSeconds) { + // Relative live-TV skip: route through the parent accumulator, which + // coalesces a rapid burst into one transcode re-open (#1253). + if (onLiveSeekBy != null) { + onLiveSeekBy(offsetSeconds); + } else { + final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds)); + unawaited((onSeekRequested ?? player.seek)(target)); + } + } + + switch (action) { + case ShortcutAction.playPause: + (onPlayPause ?? player.playOrPause).call(); + case ShortcutAction.volumeUp: + onVolumeUp?.call(); + case ShortcutAction.volumeDown: + onVolumeDown?.call(); + case ShortcutAction.seekForward: + performSeek(_seekTimeSmall); + case ShortcutAction.seekBackward: + performSeek(-_seekTimeSmall); + case ShortcutAction.seekForwardLarge: + performSeek(_seekTimeLarge); + case ShortcutAction.seekBackwardLarge: + performSeek(-_seekTimeLarge); + case ShortcutAction.fullscreenToggle: + onToggleFullscreen?.call(); + case ShortcutAction.muteToggle: + onToggleMute?.call(); + case ShortcutAction.subtitleToggle: + onToggleSubtitles?.call(); + case ShortcutAction.audioTrackNext: + onNextAudioTrack?.call(); + case ShortcutAction.subtitleTrackNext: + onNextSubtitleTrack?.call(); + case ShortcutAction.chapterNext: + onNextChapter?.call(); + case ShortcutAction.chapterPrevious: + onPreviousChapter?.call(); + case ShortcutAction.episodeNext: + onNextEpisode?.call(); + case ShortcutAction.episodePrevious: + onPreviousEpisode?.call(); + case ShortcutAction.speedIncrease: + final newRateUp = (player.state.rate + 0.25).clamp(minimumPlaybackRate, maximumPlaybackRate); + player.setRate(newRateUp); + _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); + case ShortcutAction.speedDecrease: + final newRateDown = (player.state.rate - 0.25).clamp(minimumPlaybackRate, maximumPlaybackRate); + player.setRate(newRateDown); + _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); + case ShortcutAction.speedReset: + player.setRate(1.0); + _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); + case ShortcutAction.subSeekNext: + player.command(['sub-seek', '1']); + case ShortcutAction.subSeekPrev: + player.command(['sub-seek', '-1']); + case ShortcutAction.shaderToggle: + onToggleShader?.call(); + case ShortcutAction.skipMarker: + onSkipMarker?.call(); + case ShortcutAction.screenshot: + unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); + case ShortcutAction.zoomIn: + onZoomIn?.call(); + case ShortcutAction.zoomOut: + onZoomOut?.call(); + case ShortcutAction.zoomReset: + onZoomReset?.call(); + } return KeyEventResult.handled; } } @@ -326,192 +356,10 @@ class KeyboardShortcutsService extends ChangeNotifier { return KeyEventResult.ignored; } - void _executeAction( - String action, - Player player, - VoidCallback? onToggleFullscreen, - VoidCallback? onToggleSubtitles, - VoidCallback? onNextAudioTrack, - VoidCallback? onNextSubtitleTrack, - VoidCallback? onNextChapter, - VoidCallback? onPreviousChapter, { - VoidCallback? onPlayPause, - VoidCallback? onToggleShader, - VoidCallback? onSkipMarker, - VoidCallback? onNextEpisode, - VoidCallback? onPreviousEpisode, - VoidCallback? onScreenshot, - VoidCallback? onZoomIn, - VoidCallback? onZoomOut, - VoidCallback? onZoomReset, - VoidCallback? onVolumeUp, - VoidCallback? onVolumeDown, - VoidCallback? onToggleMute, - int? currentPositionEpoch, - ValueChanged? onLiveSeek, - ValueChanged? onLiveSeekBy, - Future Function(Duration position)? onSeekRequested, - }) { - void performSeek(int offsetSeconds) { - // Relative live-TV skip: route through the parent accumulator, which - // coalesces a rapid burst into one transcode re-open (#1253). - if (onLiveSeekBy != null) { - onLiveSeekBy(offsetSeconds); - } else { - final target = clampSeekPosition(player, player.state.position + Duration(seconds: offsetSeconds)); - unawaited((onSeekRequested ?? player.seek)(target)); - } - } - - switch (action) { - case 'play_pause': - (onPlayPause ?? player.playOrPause).call(); - break; - case 'volume_up': - onVolumeUp?.call(); - break; - case 'volume_down': - onVolumeDown?.call(); - break; - case 'seek_forward': - performSeek(_seekTimeSmall); - break; - case 'seek_backward': - performSeek(-_seekTimeSmall); - break; - case 'seek_forward_large': - performSeek(_seekTimeLarge); - break; - case 'seek_backward_large': - performSeek(-_seekTimeLarge); - break; - case 'fullscreen_toggle': - onToggleFullscreen?.call(); - break; - case 'mute_toggle': - onToggleMute?.call(); - break; - case 'subtitle_toggle': - onToggleSubtitles?.call(); - break; - case 'audio_track_next': - onNextAudioTrack?.call(); - break; - case 'subtitle_track_next': - onNextSubtitleTrack?.call(); - break; - case 'chapter_next': - onNextChapter?.call(); - break; - case 'chapter_previous': - onPreviousChapter?.call(); - break; - case 'episode_next': - onNextEpisode?.call(); - break; - case 'episode_previous': - onPreviousEpisode?.call(); - break; - case 'speed_increase': - final newRateUp = (player.state.rate + 0.25).clamp(minimumPlaybackRate, maximumPlaybackRate); - player.setRate(newRateUp); - _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); - break; - case 'speed_decrease': - final newRateDown = (player.state.rate - 0.25).clamp(minimumPlaybackRate, maximumPlaybackRate); - player.setRate(newRateDown); - _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); - break; - case 'speed_reset': - player.setRate(1.0); - _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); - break; - case 'sub_seek_next': - player.command(['sub-seek', '1']); - break; - case 'sub_seek_prev': - player.command(['sub-seek', '-1']); - break; - case 'shader_toggle': - onToggleShader?.call(); - break; - case 'skip_marker': - onSkipMarker?.call(); - break; - case 'screenshot': - unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); - break; - case 'zoom_in': - onZoomIn?.call(); - break; - case 'zoom_out': - onZoomOut?.call(); - break; - case 'zoom_reset': - onZoomReset?.call(); - break; - } - } - String getActionDisplayName(String action) { - switch (action) { - case 'play_pause': - return t.hotkeys.actions.playPause; - case 'volume_up': - return t.hotkeys.actions.volumeUp; - case 'volume_down': - return t.hotkeys.actions.volumeDown; - case 'seek_forward': - return t.hotkeys.actions.seekForward(seconds: _seekTimeSmall); - case 'seek_backward': - return t.hotkeys.actions.seekBackward(seconds: _seekTimeSmall); - case 'seek_forward_large': - return t.hotkeys.actions.seekForward(seconds: _seekTimeLarge); - case 'seek_backward_large': - return t.hotkeys.actions.seekBackward(seconds: _seekTimeLarge); - case 'fullscreen_toggle': - return t.hotkeys.actions.fullscreenToggle; - case 'mute_toggle': - return t.hotkeys.actions.muteToggle; - case 'subtitle_toggle': - return t.hotkeys.actions.subtitleToggle; - case 'audio_track_next': - return t.hotkeys.actions.audioTrackNext; - case 'subtitle_track_next': - return t.hotkeys.actions.subtitleTrackNext; - case 'chapter_next': - return t.hotkeys.actions.chapterNext; - case 'chapter_previous': - return t.hotkeys.actions.chapterPrevious; - case 'episode_next': - return t.hotkeys.actions.episodeNext; - case 'episode_previous': - return t.hotkeys.actions.episodePrevious; - case 'speed_increase': - return t.hotkeys.actions.speedIncrease; - case 'speed_decrease': - return t.hotkeys.actions.speedDecrease; - case 'speed_reset': - return t.hotkeys.actions.speedReset; - case 'sub_seek_next': - return t.hotkeys.actions.subSeekNext; - case 'sub_seek_prev': - return t.hotkeys.actions.subSeekPrev; - case 'shader_toggle': - return t.hotkeys.actions.shaderToggle; - case 'skip_marker': - return t.hotkeys.actions.skipMarker; - case 'screenshot': - return t.hotkeys.actions.screenshot; - case 'zoom_in': - return t.hotkeys.actions.zoomIn; - case 'zoom_out': - return t.hotkeys.actions.zoomOut; - case 'zoom_reset': - return t.hotkeys.actions.zoomReset; - default: - return action; - } + final shortcut = ShortcutAction.fromId(action); + if (shortcut == null) return action; + return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge); } // Check if a hotkey is already assigned to another action diff --git a/lib/services/macos_window_service.dart b/lib/services/macos_window_service.dart index 701525eb..bc338289 100644 --- a/lib/services/macos_window_service.dart +++ b/lib/services/macos_window_service.dart @@ -2,27 +2,6 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; import 'fullscreen_state_manager.dart'; -/// Abstract class for receiving macOS window delegate callbacks. -/// Extend this class and register with [MacOSWindowService] to receive -/// fullscreen transition events. -abstract class MacOSWindowDelegate { - /// Called when the window is about to enter fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillEnterFullScreen() {} - - /// Called when the window has entered fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidEnterFullScreen() {} - - /// Called when the window is about to exit fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillExitFullScreen() {} - - /// Called when the window has exited fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidExitFullScreen() {} -} - /// Service for manipulating macOS window properties. /// This is a native implementation replacing the macos_window_utils package. /// @@ -31,35 +10,25 @@ abstract class MacOSWindowDelegate { /// This service only exposes what's needed externally: /// - Traffic light visibility (for video controls) /// - Fullscreen enter/exit (for video controls) -/// - Delegate registration (for FullscreenStateManager updates) +/// - Fullscreen state tracking (for FullscreenStateManager updates) class MacOSWindowService { static const _channel = MethodChannel('com.plezy/window_utils'); static bool _initialized = false; static bool _delegateEnabled = false; - static final List _delegates = []; - static final MacOSWindowDelegate _fullscreenDelegate = _FullscreenWindowDelegate(); static Future _invoke(String method, [Map? args]) async { if (!Platform.isMacOS) return; await _channel.invokeMethod(method, args); } - static void _notifyDelegates(void Function(MacOSWindowDelegate) callback) { - for (final delegate in _delegates) { - callback(delegate); - } - } - + /// Window manipulation (toolbar, titlebar, traffic lights) is handled directly + /// in Swift's WindowDelegate; this only mirrors the transition into Dart state. static Future _handleMethodCall(MethodCall call) async { switch (call.method) { case 'windowWillEnterFullScreen': - _notifyDelegates((d) => d.windowWillEnterFullScreen()); - case 'windowDidEnterFullScreen': - _notifyDelegates((d) => d.windowDidEnterFullScreen()); - case 'windowWillExitFullScreen': - _notifyDelegates((d) => d.windowWillExitFullScreen()); + FullscreenStateManager().setFullscreen(true); case 'windowDidExitFullScreen': - _notifyDelegates((d) => d.windowDidExitFullScreen()); + FullscreenStateManager().setFullscreen(false); } } @@ -81,7 +50,6 @@ class MacOSWindowService { } await initialize(enableWindowDelegate: true); - addWindowDelegate(_fullscreenDelegate); await syncWindowChrome(); FullscreenStateManager().setFullscreen(await isFullscreen()); } @@ -104,16 +72,6 @@ class MacOSWindowService { } } - static void addWindowDelegate(MacOSWindowDelegate delegate) { - if (!_delegates.contains(delegate)) { - _delegates.add(delegate); - } - } - - static void removeWindowDelegate(MacOSWindowDelegate delegate) { - _delegates.remove(delegate); - } - static Future setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible}); static Future syncWindowChrome() => _invoke('syncWindowChrome'); @@ -127,18 +85,3 @@ class MacOSWindowService { return await _channel.invokeMethod('isFullscreen') ?? false; } } - -/// Internal window delegate that manages fullscreen state. -/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled -/// directly in Swift's WindowDelegate. This class only updates Dart-side state. -class _FullscreenWindowDelegate extends MacOSWindowDelegate { - @override - void windowWillEnterFullScreen() { - FullscreenStateManager().setFullscreen(true); - } - - @override - void windowDidExitFullScreen() { - FullscreenStateManager().setFullscreen(false); - } -} diff --git a/lib/screens/video_player/media_control_router.dart b/lib/services/media_control_router.dart similarity index 80% rename from lib/screens/video_player/media_control_router.dart rename to lib/services/media_control_router.dart index 9e657f35..100eb3b8 100644 --- a/lib/screens/video_player/media_control_router.dart +++ b/lib/services/media_control_router.dart @@ -1,12 +1,14 @@ import 'package:os_media_controls/os_media_controls.dart'; -/// Screen-owned authorization boundary for user-originated OS media commands. +/// Authorization boundary for user-originated OS media commands, owned by +/// whoever holds the transport (the video screen, the music session). /// -/// Lifecycle/audio-route events are handled before this router. Recognized -/// commands are consumed even when denied so they cannot reach a background -/// route or stale player owner. -final class VideoPlayerMediaControlRouter { - const VideoPlayerMediaControlRouter({ +/// Lifecycle/audio-route events are handled before this router: [route] +/// reports `false` for what it does not recognize. Recognized commands are +/// consumed even when denied so they cannot reach a background route or stale +/// player owner. Both gates stay required — every owner states its policy. +final class MediaControlRouter { + const MediaControlRouter({ required this.canControlPlayback, required this.canNavigateMediaItems, required this.onPlay, diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart index effba026..09e3b069 100644 --- a/lib/services/media_list_playback_launcher.dart +++ b/lib/services/media_list_playback_launcher.dart @@ -73,6 +73,17 @@ abstract class MediaListPlaybackLauncher { /// queue from `EpisodeNavigationService`). Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}); + /// Launch playback from a folder row of the library tree. Everything each + /// backend needs is stamped onto [folder]: Plex builds a server-side + /// `/playQueues` from [MediaItem.backendFolderKey] (returning a + /// [PlayQueueError] when the row carries none), Jellyfin fetches the + /// folder's playable descendants and publishes a local queue. + Future launchFromFolder({ + required MediaItem folder, + required bool shuffle, + bool showLoadingIndicator = true, + }); + /// Pick the right implementation for [item]. Reads /// [MediaItem.backend] / [MediaPlaylist.backend]. static MediaListPlaybackLauncher forItem(BuildContext context, Object item) { diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 5714618d..2235c380 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -77,6 +77,10 @@ class MultiServerManager { Stream> get statusStream => _statusController.stream; + /// Publish a snapshot of the per-server online map — subscribers must never + /// receive the live [_serverStatus] instance. + void _emitStatus() => _statusController.add(Map.from(_serverStatus)); + /// Per-server connect progress during a bind. Unlike [statusStream] — whose /// first emission means "the binder's first connect pass finished" and which /// triggers libraries/live-tv work per emission — this fires as each @@ -97,15 +101,35 @@ class MultiServerManager { /// Map of serverId to active optimization futures final Map> _activeOptimizations = {}; - /// Per-server clientIdentifier. Plex servers added via [addPlexAccount] - /// register their owning account's clientIdentifier here so reconnects + - /// endpoint optimization use the right identity (each account has its own - /// device row on plex.tv). + /// Per-server clientIdentifier. Plex servers added via + /// [refreshTokensForProfile] register their owning account's + /// clientIdentifier here so reconnects + endpoint optimization use the + /// right identity (each account has its own device row on plex.tv). final Map _clientIdByServer = {}; final Map _plexScopeByServer = {}; String? _resolveClientIdentifier(ServerId serverId) => _clientIdByServer[serverId]; + /// Record the Plex identity a server is bound under — the single writer for + /// all three per-server Plex registrations. A null [scope] (only + /// [markPlexConnectionAuthError], which has no profile yet) leaves any + /// previously recorded scope in place. + void _registerPlexServer( + String serverId, + PlexServer server, { + required String clientIdentifier, + PlexProfileScopeId? scope, + }) { + _clientIdByServer[serverId] = clientIdentifier; + _plexServers[serverId] = server; + if (scope != null) _plexScopeByServer[serverId] = scope; + } + + /// Whether [compoundId] is still the client bound as the active user for + /// [machineId]. Async Jellyfin work must re-check this before publishing a + /// result — a profile switch can rebind the machine mid-probe. + bool _isActiveJellyfin(String machineId, String compoundId) => _activeJellyfinMachine[machineId] == compoundId; + /// All Jellyfin clients ever added, keyed by the compound connection id /// (`{serverMachineId}/{userId}`). Lets two users on the same Jellyfin /// server coexist — adding the second user's client won't tear down the @@ -231,7 +255,7 @@ class MultiServerManager { void debugMarkAuthErrorForTesting(ServerId serverId) { _serverStatus[serverId] = false; _authErrorServers.add(serverId); - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } /// Mark every cached Plex server on [connection] as auth-rejected without @@ -240,20 +264,13 @@ class MultiServerManager { void markPlexConnectionAuthError(PlexAccountConnection connection) { for (final server in connection.servers) { final id = server.clientIdentifier; - _clientIdByServer[id] = connection.clientIdentifier; - _plexServers[id] = server; + _registerPlexServer(id, server, clientIdentifier: connection.clientIdentifier); _serverStatus[id] = false; _authErrorServers.add(id); } - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } - /// Plex-specific server config (name, machineId, connection candidates, - /// `owned` flag). Returns `null` for Jellyfin server ids — Jellyfin has no - /// `PlexServer` analogue. For "is this server registered?" use - /// [getClient] (works for both backends). - PlexServer? getPlexServer(ServerId serverId) => _plexServers[serverId]; - String serverDisplayName(ServerId serverId) => _clients[serverId]?.serverName ?? _plexServers[serverId]?.name ?? serverId; @@ -288,12 +305,6 @@ class MultiServerManager { return result; } - /// Plex servers known to the manager. Jellyfin servers are NOT included - /// here — they have no `PlexServer` analogue (single-URL connections, - /// not connection-raced multi-endpoint structs). For an all-backends - /// view of online servers use [serverIds] or [onlineClients]. - Map get plexServers => Map.unmodifiable(_plexServers); - /// Check if a server is online bool isServerOnline(ServerId serverId) => _serverStatus[serverId] ?? false; @@ -457,37 +468,43 @@ class MultiServerManager { .where((entry) => entry.value.connection.serverMachineId == serverId) .map((entry) => entry.key) .toList(); + final activeClient = _forgetServer(serverId); if (jellyfinCompoundIds.isNotEmpty) { final closed = {}; - _clients.remove(serverId); - _activeJellyfinMachine.remove(serverId); for (final compoundId in jellyfinCompoundIds) { final client = _jellyfinByCompoundId.remove(compoundId); _jellyfinHealthByCompoundId.remove(compoundId); if (client != null && closed.add(client)) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); } } - } else { - final client = _clients.remove(serverId); - if (client != null) _closeClient(client); + } else if (activeClient != null) { + // Jellyfin's clients were all closed above. + unawaited(_closeClientGracefully(activeClient)); } - _plexServers.remove(serverId); - _plexScopeByServer.remove(serverId); - _serverStatus.remove(serverId); - _authErrorServers.remove(serverId); - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); appLogger.i('Removed server: $serverId'); } - void _closeClient(MediaServerClient client) { - if (client case final GracefullyCloseable graceful) { - unawaited(graceful.closeGracefully()); - } else { - client.close(); - } + /// Drop every registration keyed by [serverId], cancel its pending exhaustion + /// retry, and return the client that was bound (the caller closes it). The + /// single teardown for both removal paths, so they cannot drift apart again. + /// The in-flight guards ([_activeOptimizations], [_endpointHealthChecks]) are + /// deliberately left alone — they are owned by the futures that set them. + MediaServerClient? _forgetServer(String serverId) { + _reconnectDebounce.remove(serverId)?.cancel(); + final client = _clients.remove(serverId); + _activeJellyfinMachine.remove(serverId); + _plexServers.remove(serverId); + _clientIdByServer.remove(serverId); + _plexScopeByServer.remove(serverId); + _serverStatus.remove(serverId); + _authErrorServers.remove(serverId); + return client; } + /// Close [client], draining in-flight requests when it supports it. Callers + /// that do not need to wait wrap the call in `unawaited(...)`. Future _closeClientGracefully( MediaServerClient client, { Duration drainTimeout = const Duration(seconds: 2), @@ -499,57 +516,6 @@ class MultiServerManager { } } - /// Connect every server attached to a Plex account in parallel. Each - /// account has its own `clientIdentifier` (registered as a separate - /// device on plex.tv), and we keep that mapping per-server in - /// [_clientIdByServer] so subsequent reconnects + endpoint optimization - /// race connections from the right identity. - Future addPlexAccount( - PlexAccountConnection connection, { - required String profileId, - Duration timeout = MediaServerTimeouts.perServerConnect, - Function(ServerId serverId, bool success)? onServerStatus, - }) async { - if (connection.servers.isEmpty) return 0; - appLogger.i( - 'Connecting Plex account ${connection.accountLabel} ' - '(${connection.servers.length} server${connection.servers.length == 1 ? '' : 's'})', - ); - - int connected = 0; - final futures = connection.servers.map((server) async { - final serverId = server.clientIdentifier; - final profileScopeId = buildPlexProfileScopeId(serverId: ServerId(serverId), profileId: profileId); - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; - try { - final client = await _createClientForServer( - server: server, - clientIdentifier: connection.clientIdentifier, - profileScopeId: profileScopeId, - ).namedTimeout(timeout, operation: 'connect to ${server.name}'); - final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); - _clients[serverId] = client; - _serverStatus[serverId] = true; - onServerStatus?.call(ServerId(serverId), true); - connected++; - } catch (e, stackTrace) { - appLogger.e('Failed to connect ${server.name}', error: e, stackTrace: stackTrace); - _serverStatus[serverId] = false; - onServerStatus?.call(ServerId(serverId), false); - } - }); - - await Future.wait(futures); - _statusController.add(Map.from(_serverStatus)); - if (connected > 0 && _connectivitySubscription == null) { - _startNetworkMonitoring(); - } - return connected; - } - /// Apply a freshly-fetched [PlexAccountConnection] to the manager, /// rotating per-server access tokens in place when possible. /// @@ -590,9 +556,7 @@ class MultiServerManager { ); if (!applied || isStale() || !identical(_clients[serverId], existing)) return; - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; + _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId); _authErrorServers.remove(serverId); _serverStatus[serverId] = true; bound.add(serverId); @@ -607,9 +571,7 @@ class MultiServerManager { return; } - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; + _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId); try { final client = await _createClientForServer( server: server, @@ -617,11 +579,11 @@ class MultiServerManager { profileScopeId: profileScopeId, ).namedTimeout(timeout, operation: 'connect to ${server.name}'); if (isStale() || !identical(_plexServers[serverId], server)) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); return; } final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _clients[serverId] = client; _serverStatus[serverId] = true; _authErrorServers.remove(serverId); @@ -637,7 +599,7 @@ class MultiServerManager { }); await Future.wait(futures); if (isStale()) return const {}; - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); if (bound.isNotEmpty && _connectivitySubscription == null) { _startNetworkMonitoring(); } @@ -733,7 +695,7 @@ class MultiServerManager { // the connection materially changed (token refresh, URL-set edit); an // unchanged re-add was already handled by the reuse branch above. final oldClient = _jellyfinByCompoundId[compoundId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _jellyfinByCompoundId[compoundId] = client; // Bind this user as the active client for its machine. A previously @@ -790,13 +752,13 @@ class MultiServerManager { Future _reuseJellyfinClient(JellyfinClient client) async { final compoundId = client.connection.id; final machineId = client.connection.serverMachineId; - final rebound = _activeJellyfinMachine[machineId] != compoundId; + final rebound = !_isActiveJellyfin(machineId, compoundId); _clients[machineId] = client; _activeJellyfinMachine[machineId] = compoundId; final health = await client.checkHealth(); _jellyfinHealthByCompoundId[compoundId] = health; - if (_activeJellyfinMachine[machineId] != compoundId) { + if (!_isActiveJellyfin(machineId, compoundId)) { // A concurrent remove/re-add won while the probe was in flight. appLogger.d('Ignoring stale Jellyfin reuse result for ${client.connection.serverName}'); return health == HealthStatus.online; @@ -805,7 +767,7 @@ class MultiServerManager { if (rebound) { // The machine's active user changed even if its online status didn't; // client-map consumers need to observe the swap. - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } final healthy = health == HealthStatus.online; appLogger.i( @@ -835,7 +797,7 @@ class MultiServerManager { appLogger.w('Failed to persist Jellyfin connection update', error: e, stackTrace: st); } } - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); }; } @@ -853,13 +815,10 @@ class MultiServerManager { final machineId = connection.serverMachineId; final client = _jellyfinByCompoundId.remove(compoundId); _jellyfinHealthByCompoundId.remove(compoundId); - if (client != null) _closeClient(client); - if (_activeJellyfinMachine[machineId] == compoundId) { - _activeJellyfinMachine.remove(machineId); - _clients.remove(machineId); - _serverStatus.remove(machineId); - _authErrorServers.remove(machineId); - _statusController.add(Map.from(_serverStatus)); + if (client != null) unawaited(_closeClientGracefully(client)); + if (_isActiveJellyfin(machineId, compoundId)) { + _forgetServer(machineId); + _emitStatus(); } } @@ -867,15 +826,8 @@ class MultiServerManager { /// /// Clears the auth-error flag — callers that observed an auth failure /// should use [_applyHealth] instead. - void updateServerStatus(ServerId serverId, bool isOnline) { - final prevOnline = _serverStatus[serverId]; - final hadAuthError = _authErrorServers.remove(serverId); - if (prevOnline != isOnline || hadAuthError) { - _serverStatus[serverId] = isOnline; - _statusController.add(Map.from(_serverStatus)); - appLogger.d('Server $serverId status changed to: $isOnline'); - } - } + void updateServerStatus(ServerId serverId, bool isOnline) => + _applyHealth(serverId, isOnline ? HealthStatus.online : HealthStatus.offline); /// Apply a health-probe outcome to both online state and auth-error /// tracking. Used by the manager's own health checks; external callers @@ -895,7 +847,7 @@ class MultiServerManager { final changed = prevOnline != isOnline || hadAuthError != isAuthError; if (changed) { - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); if (isAuthError) { appLogger.w('Server $serverId auth rejected — token expired or revoked'); } else { @@ -931,7 +883,7 @@ class MultiServerManager { if (client is JellyfinClient) { final compoundId = expectedJellyfinCompoundId ?? client.connection.id; _jellyfinHealthByCompoundId[compoundId] = status; - if (_activeJellyfinMachine[serverId] != compoundId) { + if (!_isActiveJellyfin(serverId, compoundId)) { appLogger.d('Ignoring stale Jellyfin health result for ${client.connection.serverName}'); return; } @@ -1000,6 +952,33 @@ class MultiServerManager { appLogger.i('Stopped network monitoring'); } + /// Run [taskBuilder] as the single in-flight optimize/reconnect task for + /// [serverId] — the sole owner of the [_activeOptimizations] invariant. + /// + /// While an entry exists the builder is never invoked and a completed future + /// is returned, so a caller awaiting a batch never waits on work it did not + /// start. The registered future always clears its own entry. [timeout] bounds + /// the task, logging ` timed out for ` when it fires. + Future _runServerTask( + String serverId, + Future Function() taskBuilder, { + Duration? timeout, + String? timeoutLabel, + }) { + if (_activeOptimizations.containsKey(serverId)) return Future.value(); + + var task = taskBuilder(); + if (timeout != null) { + task = task.timeout(timeout, onTimeout: () => appLogger.d('$timeoutLabel timed out for $serverId')); + } + // Must not *return* the removed entry — whenComplete would then await this very future. + final registered = task.whenComplete(() { + _activeOptimizations.remove(serverId); + }); + _activeOptimizations[serverId] = registered; + return registered; + } + /// Re-optimize all connected servers and attempt reconnection for offline ones void _reoptimizeAllServers({required String reason}) { for (final entry in _plexServers.entries) { @@ -1012,33 +991,27 @@ class MultiServerManager { continue; } - if (!isServerOnline(ServerId(serverId))) { - // Attempt reconnection for offline servers - _activeOptimizations[serverId] = _reconnectServer(ServerId(serverId), server).whenComplete(() { - _activeOptimizations.remove(serverId); - }); - } else { - // Re-optimize online servers - _activeOptimizations[serverId] = _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason) - .whenComplete(() { - _activeOptimizations.remove(serverId); - }); - } + // Online servers get their endpoints re-raced; offline ones a full reconnect. + unawaited( + _runServerTask( + serverId, + () => isServerOnline(ServerId(serverId)) + ? _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason) + : _reconnectServer(ServerId(serverId), server), + ), + ); } // Jellyfin re-probes offline servers here. Online clients keep their current // endpoint and can still fail over per request through JellyfinClient. for (final entry in _activeJellyfinMachine.entries) { final serverId = entry.key; - if (_activeOptimizations.containsKey(serverId)) continue; if (isServerOnline(ServerId(serverId))) continue; final client = _jellyfinByCompoundId[entry.value]; if (client == null) continue; - _activeOptimizations[serverId] = _reconnectJellyfinServer(serverId, client).whenComplete(() { - _activeOptimizations.remove(serverId); - }); + unawaited(_runServerTask(serverId, () => _reconnectJellyfinServer(serverId, client))); } } @@ -1115,13 +1088,13 @@ class MultiServerManager { if (!identical(_plexServers[serverId], server) || _resolveClientIdentifier(serverId) != clientId || _plexScopeByServer[serverId] != profileScopeId) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); appLogger.d('Ignoring stale reconnection result for ${server.name}'); return; } final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _clients[serverId] = client; updateServerStatus(serverId, true); appLogger.i('Successfully reconnected to ${server.name}'); @@ -1144,7 +1117,7 @@ class MultiServerManager { appLogger.d('Attempting reconnection for Jellyfin server ${client.connection.serverName}'); final status = await client.checkHealth(); _jellyfinHealthByCompoundId[expectedCompoundId] = status; - if (_activeJellyfinMachine[machineId] != expectedCompoundId) { + if (!_isActiveJellyfin(machineId, expectedCompoundId)) { appLogger.d('Ignoring stale Jellyfin reconnection result for ${client.connection.serverName}'); return; } @@ -1195,22 +1168,14 @@ class MultiServerManager { } final futures = offline.map((serverId) { - // Skip if already running - if (_activeOptimizations.containsKey(serverId)) return Future.value(); - final server = _plexServers[serverId]; if (server != null) { - final future = _reconnectServer(ServerId(serverId), server) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - appLogger.d('Reconnection timed out for $serverId'); - }, - ) - .whenComplete(() => _activeOptimizations.remove(serverId)); - - _activeOptimizations[serverId] = future; - return future; + return _runServerTask( + serverId, + () => _reconnectServer(ServerId(serverId), server), + timeout: const Duration(seconds: 15), + timeoutLabel: 'Reconnection', + ); } // Jellyfin offline path — no `_plexServers` entry, but the active @@ -1218,21 +1183,14 @@ class MultiServerManager { // `_activeJellyfinMachine`. Run the same auth probe used at add time. final activeCompoundId = _activeJellyfinMachine[serverId]; final jellyfinClient = activeCompoundId != null ? _jellyfinByCompoundId[activeCompoundId] : null; - if (jellyfinClient != null) { - final future = _reconnectJellyfinServer(serverId, jellyfinClient) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - appLogger.d('Jellyfin reconnection timed out for $serverId'); - }, - ) - .whenComplete(() => _activeOptimizations.remove(serverId)); + if (jellyfinClient == null) return Future.value(); - _activeOptimizations[serverId] = future; - return future; - } - - return Future.value(); + return _runServerTask( + serverId, + () => _reconnectJellyfinServer(serverId, jellyfinClient), + timeout: const Duration(seconds: 15), + timeoutLabel: 'Jellyfin reconnection', + ); }); await Future.wait(futures); @@ -1283,13 +1241,14 @@ class MultiServerManager { appLogger.i('Health probe confirmed $serverId offline, triggering reconnection'); - if (_activeOptimizations.containsKey(serverId)) return; - final reconnect = plexServer != null - ? _reconnectServer(serverId, plexServer) - : _reconnectJellyfinServer(serverId, jellyfinClient!); - _activeOptimizations[serverId] = reconnect.whenComplete(() { - _activeOptimizations.remove(serverId); - }); + unawaited( + _runServerTask( + serverId, + () => plexServer != null + ? _reconnectServer(serverId, plexServer) + : _reconnectJellyfinServer(serverId, jellyfinClient!), + ), + ); } finally { _endpointHealthChecks.remove(serverId); } @@ -1299,7 +1258,7 @@ class MultiServerManager { /// client stays in [_jellyfinByCompoundId]); only the currently bound /// client's exhaustion may verify and flip the machine's status. void _onJellyfinEndpointsExhausted(String machineId, String compoundId) { - if (_activeJellyfinMachine[machineId] != compoundId) { + if (!_isActiveJellyfin(machineId, compoundId)) { appLogger.d('Ignoring endpoint exhaustion from inactive Jellyfin client', error: compoundId); return; } @@ -1315,13 +1274,12 @@ class MultiServerManager { @visibleForTesting void debugTriggerEndpointsExhaustedForTesting(ServerId serverId) => _onServerEndpointsExhausted(serverId); - /// Disconnect all servers + /// Disconnect all servers, fire-and-forget. + /// + /// Registrations are dropped synchronously ([_detachAllClients] runs before + /// the first await); only the socket drain is left running in the background. void disconnectAll() { - appLogger.i('Disconnecting all servers'); - final clients = _detachAllClients(); - for (final client in clients) { - _closeClient(client); - } + unawaited(disconnectAllGracefully(drainTimeout: const Duration(seconds: 2))); } Future disconnectAllGracefully({Duration drainTimeout = const Duration(seconds: 5)}) async { diff --git a/lib/services/music/music_playback_service.dart b/lib/services/music/music_playback_service.dart index 6b28526b..dcba1352 100644 --- a/lib/services/music/music_playback_service.dart +++ b/lib/services/music/music_playback_service.dart @@ -37,14 +37,7 @@ class MusicPlayContext { /// profile switch tears the session down. [notifyListeners] fires only on /// discrete changes (track, status, queue shape, modes) — progress bars /// subscribe to [positionStream] instead. -/// -/// [StubMusicPlaybackService] is registered until the playback engine lands; -/// UI gates transport affordances on [isAvailable]. abstract class MusicPlaybackService extends ChangeNotifier { - /// False on the stub — playback affordances should render disabled or - /// fall back to a "not supported yet" notice. - bool get isAvailable; - MediaItem? get currentTrack; MusicPlaybackStatus get status; bool get isPlaying => status == MusicPlaybackStatus.playing; @@ -155,15 +148,12 @@ abstract class MusicPlaybackService extends ChangeNotifier { Future fetchLyrics(MediaItem track); } -/// No-op placeholder bound while the playback engine is not wired yet (or -/// on platforms where it failed to initialize). Keeps every UI consumer -/// null-safe without per-call-site feature checks. +/// No-op base for test doubles, which override only the members under test. +/// Production always binds `MusicPlaybackServiceImpl`. class StubMusicPlaybackService extends MusicPlaybackService { final ValueNotifier _volumeNotifier = ValueNotifier(100); int _playIntentGeneration = 0; int _queueSessionRevision = 0; - @override - bool get isAvailable => false; @override MediaItem? get currentTrack => null; diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index 8062bd9e..a93a3e1d 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -14,6 +14,7 @@ import '../../mpv/player/player.dart'; import '../../utils/app_logger.dart'; import '../../utils/notification_permission.dart'; import '../../utils/platform_detector.dart'; +import '../media_control_router.dart'; import '../media_controls_manager.dart'; import '../multi_server_manager.dart'; import '../offline_watch_sync_service.dart'; @@ -172,9 +173,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO // Getters // --------------------------------------------------------------------- - @override - bool get isAvailable => true; - @override MediaItem? get currentTrack => _currentTrack; @@ -810,27 +808,32 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO ); } + /// OS transport commands. Music has no authorization gate: the session only + /// exists while a track is loaded, and that is checked in [_onControlEvent]. + late final _mediaControlRouter = MediaControlRouter( + canControlPlayback: () => true, + canNavigateMediaItems: () => true, + onPlay: () => unawaited(play()), + onPause: () => unawaited(pause()), + onTogglePlayPause: () => unawaited(togglePlayPause()), + onSeek: (position) => unawaited(seek(position)), + onNext: () => unawaited(next()), + onPrevious: () => unawaited(previous()), + onStop: () => unawaited(stop()), + onSkipForward: (interval) => unawaited(_seekRelative(interval ?? _defaultSkipInterval)), + onSkipBackward: (interval) => unawaited(_seekRelative(-(interval ?? _defaultSkipInterval))), + // Speed is deliberately ignored: music always plays at 1.0 and the control + // is not advertised — but Linux MPRIS exposes an always-writable Rate + // property, so the event can still arrive. The periodic playback-state + // update reasserts speed 1.0. + onSetSpeed: (_) {}, + ); + void _onControlEvent(MediaControlEvent event) { if (_disposed || _currentTrack == null) return; - if (event is PlayEvent) { - unawaited(play()); - } else if (event is PauseEvent) { - unawaited(pause()); - } else if (event is TogglePlayPauseEvent) { - unawaited(togglePlayPause()); - } else if (event is NextTrackEvent) { - unawaited(next()); - } else if (event is PreviousTrackEvent) { - unawaited(previous()); - } else if (event is SeekEvent) { - unawaited(seek(event.position)); - } else if (event is StopEvent) { - unawaited(stop()); - } else if (event is SkipForwardEvent) { - unawaited(_seekRelative(event.interval ?? _defaultSkipInterval)); - } else if (event is SkipBackwardEvent) { - unawaited(_seekRelative(-(event.interval ?? _defaultSkipInterval))); - } else if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) { + if (_mediaControlRouter.route(event)) return; + + if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) { // Remember whether we were playing so interruption-end/route-return // can resume. Unlike video, music resumes even while backgrounded — // background audio is the product. @@ -849,10 +852,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO unawaited(play()); } } - // SetSpeedEvent is deliberately unhandled: music always plays at 1.0 and - // the control is not advertised — but Linux MPRIS exposes an always- - // writable Rate property, so the event can still arrive. The periodic - // playback-state update reasserts speed 1.0. } static const _defaultSkipInterval = Duration(seconds: 15); @@ -1175,10 +1174,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _queueSessionRevision++; _generation++; _invalidateArmRequests(); - _completedConfirmTimer?.cancel(); - _completedConfirmTimer = null; - _cancelSleepTimer(); - _finalizeCurrentTrack(); + _cancelTimersAndFinalizeTrack(); _queue.clear(); _currentTrack = null; _currentSource = null; @@ -1188,27 +1184,56 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _resumeAfterInterruption = false; _setStatus(endStatus, forceNotify: true); - final player = _player; - _player = null; + await _teardownPlayerAndControls(awaitStop: true); + } + + /// Kills the completion/sleep timers and flushes the track's final progress + /// report — done before [_setStatus] so listeners never see a live timer. + void _cancelTimersAndFinalizeTrack() { + _completedConfirmTimer?.cancel(); + _completedConfirmTimer = null; + _cancelSleepTimer(); + _finalizeCurrentTrack(); + } + + /// Detaches the player streams, shuts the player down and drops the OS media + /// session — the teardown shared by [_stopSession] and [dispose]. + /// + /// [awaitStop] stops the player and awaits every step, so callers know the + /// audio core is gone once the future resolves. The `false` path must never + /// suspend: [dispose] is a synchronous override and needs the whole teardown + /// to run in the caller's turn, before `super.dispose()`. + Future _teardownPlayerAndControls({required bool awaitStop}) async { for (final sub in _playerSubs) { unawaited(sub.cancel()); } _playerSubs.clear(); + final player = _player; + _player = null; if (player != null && !player.disposed) { - try { - await player.stop(); - } catch (e) { - appLogger.d('Audio player stop failed during session teardown', error: e); - } - try { - await player.abandonAudioFocus(); - } catch (e) { - appLogger.d('Audio focus abandon failed during session teardown', error: e); - } - try { - await player.dispose(); - } catch (e) { - appLogger.w('Audio player dispose failed during session teardown', error: e); + if (awaitStop) { + try { + await player.stop(); + } catch (e) { + appLogger.d('Audio player stop failed during session teardown', error: e); + } + try { + await player.abandonAudioFocus(); + } catch (e) { + appLogger.d('Audio focus abandon failed during session teardown', error: e); + } + try { + await player.dispose(); + } catch (e) { + appLogger.w('Audio player dispose failed during session teardown', error: e); + } + } else { + unawaited( + player.abandonAudioFocus().catchError((Object e) { + appLogger.d('Audio focus abandon failed during dispose', error: e); + }), + ); + unawaited(player.dispose()); } } @@ -1256,33 +1281,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _observesLifecycle = false; } _coordinator.unregisterMusicSession(_stopForVideoClaim); - _completedConfirmTimer?.cancel(); - _completedConfirmTimer = null; - _cancelSleepTimer(); - _finalizeCurrentTrack(); - for (final sub in _playerSubs) { - unawaited(sub.cancel()); - } - _playerSubs.clear(); - unawaited(_controlEventsSub?.cancel()); - _controlEventsSub = null; - final player = _player; - _player = null; - if (player != null && !player.disposed) { - unawaited( - player.abandonAudioFocus().catchError((Object e) { - appLogger.d('Audio focus abandon failed during dispose', error: e); - }), - ); - unawaited(player.dispose()); - } - final controls = _mediaControls; - _mediaControls = null; - if (controls != null) { - unawaited(controls.setBackgroundMode(false)); - unawaited(controls.clear()); - controls.dispose(); - } + _cancelTimersAndFinalizeTrack(); + // Runs to completion synchronously — see the awaitStop: false contract. + unawaited(_teardownPlayerAndControls(awaitStop: false)); unawaited(_positionController.close()); unawaited(_errorsController.close()); _volumeNotifier.dispose(); diff --git a/lib/services/music/music_source_resolver.dart b/lib/services/music/music_source_resolver.dart index 4481321e..6d566e0f 100644 --- a/lib/services/music/music_source_resolver.dart +++ b/lib/services/music/music_source_resolver.dart @@ -24,9 +24,6 @@ class MusicSource { /// `DirectPlay` / `Transcode` for progress reports. final String? playMethod; - final int selectedMediaIndex; - final String? selectedMediaSourceId; - /// True when [url] points at a downloaded/local copy. final bool isOffline; @@ -41,8 +38,6 @@ class MusicSource { this.headers, this.playSessionId, this.playMethod, - this.selectedMediaIndex = 0, - this.selectedMediaSourceId, this.isOffline = false, this.mediaInfo, this.reportingClient, @@ -69,17 +64,19 @@ class ServerMusicSourceResolver implements MusicSourceResolver { Future resolve(MediaItem track) async { final settings = await SettingsService.getInstance(); final context = await PlaybackSourceResolver(serverManager: serverManager, database: database).resolve( - metadata: track, - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: track, + selectedMediaIndex: 0, + // Video-shaped preset is ignored for tracks; `original` also keeps the + // resolver's downloaded-copy preference on. + qualityPreset: TranscodeQualityPreset.original, + audioQualityPreset: settings.read(SettingsService.musicQualityPreset), + // Plex music transcode requires both session ids; fresh per track so + // concurrent gapless arming never reuses a live transcode session. + sessionIdentifier: generateSessionIdentifier(), + transcodeSessionId: generateSessionIdentifier(), + ), offlineLibraryMode: false, - // Video-shaped preset is ignored for tracks; `original` also keeps the - // resolver's downloaded-copy preference on. - qualityPreset: TranscodeQualityPreset.original, - audioQualityPreset: settings.read(SettingsService.musicQualityPreset), - // Plex music transcode requires both session ids; fresh per track so - // concurrent gapless arming never reuses a live transcode session. - sessionIdentifier: generateSessionIdentifier(), - transcodeSessionId: generateSessionIdentifier(), ); final result = context.result; @@ -93,8 +90,6 @@ class ServerMusicSourceResolver implements MusicSourceResolver { headers: context.streamHeaders, playSessionId: result.playSessionId, playMethod: result.playMethod ?? (result.isTranscoding ? 'Transcode' : 'DirectPlay'), - selectedMediaIndex: result.selectedMediaIndex, - selectedMediaSourceId: result.selectedMediaSourceId, isOffline: result.isOffline, mediaInfo: result.mediaInfo, reportingClient: context.reportingClient, diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 637391fd..911ea261 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -27,10 +27,8 @@ export 'media_list_playback_launcher.dart' /// 3. Navigating to the video player /// 4. Handling errors with appropriate feedback /// -/// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for -/// the backend-neutral entry point. Plex-only flows such as -/// [launchFromPlaylistItem] live directly on this class because they have no -/// Jellyfin equivalent. +/// Implements the backend-neutral [MediaListPlaybackLauncher] entry points +/// on top of that resource. class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { final BuildContext context; final PlexClient client; @@ -153,17 +151,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - // If the queue is empty, try fetching it again with getPlayQueue - if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { - final fetchedQueue = await client.getPlayQueue( - playQueue.playQueueID, - librarySectionID: sourceLibraryId, - librarySectionTitle: sourceLibraryTitle, - ); - if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { - playQueue = fetchedQueue; - } - } + playQueue = await _refetchIfEmpty(playQueue, libraryId: sourceLibraryId, libraryTitle: sourceLibraryTitle); // Close loading dialog before navigating to the player await dismissLoading(); @@ -181,40 +169,6 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - /// Launch playback from a playlist starting at a specific item. - Future launchFromPlaylistItem({ - required MediaPlaylist playlist, - required MediaItem selectedItem, - bool showLoadingIndicator = true, - }) async { - return executeWithLoading( - context: context, - showLoading: showLoadingIndicator, - actionLabel: t.common.play, - execute: (dismissLoading) async { - // Plex's createPlayQueue takes the metadata `key` (`/library/metadata/{id}`), - // not the bare ratingKey. Construct it from the MediaItem id. - final selectedKey = '/library/metadata/${selectedItem.id}'; - final playQueue = await client.createPlayQueue( - playlistID: int.parse(playlist.id), - type: 'video', - key: selectedKey, - ); - - // Close loading dialog before navigating to the player - await dismissLoading(); - - return _launchFromQueue( - playQueue: playQueue, - ratingKey: playlist.id, - serverId: serverIdOrNull(serverId), - serverName: serverName, - selectedItem: _resolveSelectedMediaItem(playQueue), - ); - }, - ); - } - /// Launch shuffled playback for a show or season. @override Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}) async { @@ -264,14 +218,22 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - /// Launch playback from a folder's contents. + /// Launch playback from a folder's contents. The `/folder` key and the + /// owning library are both stamped onto the folder row by the listing + /// fetch, so the neutral [MediaItem] carries everything Plex needs. + @override Future launchFromFolder({ - required String folderKey, + required MediaItem folder, required bool shuffle, - String? libraryId, - String? libraryTitle, bool showLoadingIndicator = true, }) async { + final folderKey = folder.backendFolderKey; + if (folderKey == null) { + return PlayQueueError(Exception('Folder is missing its backend folder key')); + } + final libraryId = folder.libraryId; + final libraryTitle = folder.libraryTitle; + return executeWithLoading( context: context, showLoading: showLoadingIndicator, @@ -287,16 +249,7 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { librarySectionTitle: libraryTitle, ); - if (playQueue != null && (playQueue.items == null || playQueue.items!.isEmpty)) { - final fetchedQueue = await client.getPlayQueue( - playQueue.playQueueID, - librarySectionID: libraryId, - librarySectionTitle: libraryTitle, - ); - if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { - playQueue = fetchedQueue; - } - } + playQueue = await _refetchIfEmpty(playQueue, libraryId: libraryId, libraryTitle: libraryTitle); await dismissLoading(); @@ -312,6 +265,27 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } + /// Creation sometimes returns a queue without its items; re-read it by ID + /// and keep the refetched copy only when it actually carries items. + Future _refetchIfEmpty( + PlayQueueResponse? playQueue, { + String? libraryId, + String? libraryTitle, + }) async { + if (playQueue == null || (playQueue.items != null && playQueue.items!.isNotEmpty)) { + return playQueue; + } + final fetchedQueue = await client.getPlayQueue( + playQueue.playQueueID, + librarySectionID: libraryId, + librarySectionTitle: libraryTitle, + ); + if (fetchedQueue != null && fetchedQueue.items != null && fetchedQueue.items!.isNotEmpty) { + return fetchedQueue; + } + return playQueue; + } + /// Core method to launch playback from a play queue. Future _launchFromQueue({ required PlayQueueResponse? playQueue, diff --git a/lib/services/playback_context.dart b/lib/services/playback_context.dart index 37fce46e..63666d23 100644 --- a/lib/services/playback_context.dart +++ b/lib/services/playback_context.dart @@ -27,5 +27,4 @@ class PlaybackContext { bool get usesLocalMedia => sourceKind == PlaybackSourceKind.localFile; bool get shouldQueueOnReportFailure => reportingMode == PlaybackReportingMode.onlineWithOfflineFallback; - bool get shouldQueueOnly => reportingMode == PlaybackReportingMode.offlineQueue; } diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 79850eb1..f4125a3c 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -8,15 +8,12 @@ import '../media/media_item.dart'; import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; -import '../models/audio_quality_preset.dart'; -import '../models/download_models.dart'; -import '../models/transcode_quality_preset.dart'; import '../mpv/models.dart'; import '../utils/app_logger.dart'; -import '../utils/downloaded_version_match.dart'; import '../utils/global_key_utils.dart'; import 'cached_playback_metadata_service.dart'; import 'download_storage_service.dart'; +import 'downloaded_video_source.dart'; import 'playback_initialization_types.dart'; // Re-export so existing callers (video_player_screen) can keep importing @@ -70,7 +67,7 @@ class PlaybackInitializationService { /// streaming an explicitly requested non-downloaded version. With /// [allowAnyDownloadedVersion] the single downloaded version is returned on /// mismatch instead — for offline flows where the alternative is failing. - Future<({String path, int mediaIndex, String? mediaSourceId})?> _resolveOfflineVideoSource( + Future _resolveOfflineVideoSource( ServerId serverId, String ratingKey, { required int mediaIndex, @@ -89,55 +86,16 @@ class PlaybackInitializationService { ..where((tbl) => tbl.globalKey.equals(buildGlobalKey(ServerId(serverId), ratingKey))); final downloadedItem = await query.getSingleOrNull(); - - // Return null if not found or not completed - if (downloadedItem == null || downloadedItem.status != DownloadStatus.completed.index) { + if (downloadedItem == null) { return null; } - final matches = downloadedVersionMatches( + return await resolveDownloadedVideoSource( downloadedItem, requestedMediaIndex: mediaIndex, requestedMediaSourceId: selectedMediaSourceId, + allowAnyDownloadedVersion: allowAnyDownloadedVersion, ); - if (!matches) { - if (!allowAnyDownloadedVersion) { - appLogger.d( - '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex} ' - '(source ${downloadedItem.mediaSourceId}), but requested version ' - '$mediaIndex (source ${selectedMediaSourceId?.trim()}) — skipping offline', - ); - return null; - } - appLogger.d( - '[VersionTrace] Requested version $mediaIndex (source ${selectedMediaSourceId?.trim()}) ' - 'is not downloaded — falling back to downloaded version ' - '${downloadedItem.mediaIndex} (source ${downloadedItem.mediaSourceId})', - ); - } - - // Return null if no video file path - if (downloadedItem.videoFilePath == null) { - return null; - } - - final storageService = DownloadStorageService.instance; - final storedPath = downloadedItem.videoFilePath!; - - // Get readable path (handles both SAF URIs and file paths) - final readablePath = await storageService.getReadablePath(storedPath); - - // For file paths (not SAF), verify the file exists - if (!storageService.isSafUri(storedPath)) { - final file = File(readablePath); - if (!await file.exists()) { - appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)'); - return null; - } - } - - appLogger.d('Found offline video: $readablePath'); - return (path: readablePath, mediaIndex: downloadedItem.mediaIndex, mediaSourceId: downloadedItem.mediaSourceId); } catch (e) { appLogger.w('Error checking offline video path', error: e); return null; @@ -150,28 +108,20 @@ class PlaybackInitializationService { /// /// Downloaded/offline path: when [preferOffline] finds a downloaded copy, /// builds from cached [MediaSourceInfo] and local sidecars immediately. - Future getPlaybackData({ - required MediaItem metadata, - required int selectedMediaIndex, - String? selectedMediaSourceId, - String? preferredVersionSignature, + Future getPlaybackData( + PlaybackInitializationOptions options, { bool preferOffline = false, - TranscodeQualityPreset qualityPreset = TranscodeQualityPreset.original, - AudioQualityPreset? audioQualityPreset, - int? selectedAudioStreamId, - SubtitleTrack? preferredSubtitleTrack, - String? sessionIdentifier, - String? transcodeSessionId, }) async { + final metadata = options.metadata; final serverId = metadata.serverId ?? client?.serverId; - ({String path, int mediaIndex, String? mediaSourceId})? offlineSource; + DownloadedVideoSource? offlineSource; if (serverId != null && (preferOffline || client == null) && database != null) { offlineSource = await _resolveOfflineVideoSource( ServerId(serverId), metadata.id, - mediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, + mediaIndex: options.selectedMediaIndex, + selectedMediaSourceId: options.selectedMediaSourceId, // With no client there is nothing to stream from, so any downloaded // version beats failing. With a client the strict match must stand: // an explicitly requested non-downloaded version streams from the @@ -196,20 +146,7 @@ class PlaybackInitializationService { PlaybackInitializationResult result; try { - result = await client!.getPlaybackInitialization( - PlaybackInitializationOptions( - metadata: metadata, - selectedMediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - qualityPreset: qualityPreset, - audioQualityPreset: audioQualityPreset, - selectedAudioStreamId: selectedAudioStreamId, - preferredSubtitleTrack: preferredSubtitleTrack, - sessionIdentifier: sessionIdentifier, - transcodeSessionId: transcodeSessionId, - ), - ); + result = await client!.getPlaybackInitialization(options); } catch (e) { rethrow; } diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index c6a07eff..1ca83374 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -1,3 +1,5 @@ +import '../exceptions/media_server_exceptions.dart'; +import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_source_info.dart'; import '../media/media_version.dart'; @@ -180,3 +182,34 @@ class PlaybackException implements Exception { @override String toString() => message; } + +/// Maps a transport-level failure raised while initializing playback onto a +/// display-safe [PlaybackException]. +/// +/// Backend-neutral on purpose: Plex and Jellyfin both throw the same +/// [MediaServerException] hierarchy, so both clients classify identically. +PlaybackException classifyPlaybackFailure(Object error) { + if (error is MediaServerAuthException || + error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { + return PlaybackException( + t.messages.playbackAuthenticationRequired, + reason: PlaybackFailureReason.authenticationRequired, + ); + } + if (error is MediaServerHttpException) { + if (error.isCancellation) { + return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); + } + final status = error.statusCode; + if (error.isTransient || status != null && status >= 500) { + return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); + } + if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + } + if (error is FormatException || error is TypeError) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + return PlaybackException(t.messages.playbackFailed); +} diff --git a/lib/services/playback_progress_tracker.dart b/lib/services/playback_progress_tracker.dart index d37f050c..e35e2b8d 100644 --- a/lib/services/playback_progress_tracker.dart +++ b/lib/services/playback_progress_tracker.dart @@ -255,27 +255,14 @@ class PlaybackProgressTracker { } }) .catchError((Object e) { - _consecutiveFailures++; - // Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s) - _ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6); - appLogger.d( - 'Progress update failed ($_consecutiveFailures consecutive), ' - 'skipping next $_ticksToSkip tick(s)', - error: e, - ); + _recordProgressFailure(e); unawaited(_queueOnlineFailureProgress(position, duration)); }), ); } } catch (e) { if (!isOffline) { - _consecutiveFailures++; - _ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6); - appLogger.d( - 'Progress update failed ($_consecutiveFailures consecutive), ' - 'skipping next $_ticksToSkip tick(s)', - error: e, - ); + _recordProgressFailure(e); await _queueOnlineFailureProgress( attemptedPosition ?? player.state.position, attemptedDuration ?? player.state.duration, @@ -303,6 +290,17 @@ class PlaybackProgressTracker { } } + void _recordProgressFailure(Object e) { + _consecutiveFailures++; + // Exponential backoff: skip 1, 2, 4, 8... ticks (capped at 6 ≈ 60s) + _ticksToSkip = (1 << (_consecutiveFailures - 1)).clamp(1, 6); + appLogger.d( + 'Progress update failed ($_consecutiveFailures consecutive), ' + 'skipping next $_ticksToSkip tick(s)', + error: e, + ); + } + void _resetBackoff() { if (_consecutiveFailures > 0) { _consecutiveFailures = 0; diff --git a/lib/services/playback_source_resolver.dart b/lib/services/playback_source_resolver.dart index dabd4b84..52253f0b 100644 --- a/lib/services/playback_source_resolver.dart +++ b/lib/services/playback_source_resolver.dart @@ -1,11 +1,7 @@ import '../database/app_database.dart'; import '../media/ids.dart'; import '../media/media_backend.dart'; -import '../media/media_item.dart'; import '../media/media_server_client.dart'; -import '../models/audio_quality_preset.dart'; -import '../models/transcode_quality_preset.dart'; -import '../mpv/mpv.dart'; import 'multi_server_manager.dart'; import 'playback_context.dart'; import 'playback_initialization_service.dart'; @@ -17,40 +13,20 @@ class PlaybackSourceResolver { const PlaybackSourceResolver({required this.serverManager, required this.database}); /// [preferOffline] overrides the default downloaded-copy preference - /// (`offlineLibraryMode || qualityPreset.isOriginal`). Pass false for - /// flows that must stay on the server stream, e.g. a transcode restart. - /// - /// [audioQualityPreset] is the music transcode preset, consulted by the - /// backends only for [MediaKind.track] items ([qualityPreset] is - /// video-shaped and ignored for tracks). - Future resolve({ - required MediaItem metadata, - required int selectedMediaIndex, - String? selectedMediaSourceId, - String? preferredVersionSignature, + /// (`offlineLibraryMode || options.qualityPreset.isOriginal`, so an omitted + /// preset keeps it on). Pass false for flows that must stay on the server + /// stream, e.g. a transcode restart. + Future resolve( + PlaybackInitializationOptions options, { required bool offlineLibraryMode, - required TranscodeQualityPreset qualityPreset, - AudioQualityPreset? audioQualityPreset, - int? selectedAudioStreamId, - SubtitleTrack? preferredSubtitleTrack, - String? sessionIdentifier, - String? transcodeSessionId, bool? preferOffline, }) async { + final metadata = options.metadata; final reportingClient = _playbackClient(serverIdOrNull(metadata.serverId), offlineLibraryMode: offlineLibraryMode); final service = PlaybackInitializationService(client: reportingClient, database: database); final result = await service.getPlaybackData( - metadata: metadata, - selectedMediaIndex: selectedMediaIndex, - selectedMediaSourceId: selectedMediaSourceId, - preferredVersionSignature: preferredVersionSignature, - preferOffline: preferOffline ?? (offlineLibraryMode || qualityPreset.isOriginal), - qualityPreset: qualityPreset, - audioQualityPreset: audioQualityPreset, - selectedAudioStreamId: selectedAudioStreamId, - preferredSubtitleTrack: preferredSubtitleTrack, - sessionIdentifier: sessionIdentifier, - transcodeSessionId: transcodeSessionId, + options, + preferOffline: preferOffline ?? (offlineLibraryMode || options.qualityPreset.isOriginal), ); final sourceKind = result.usesLocalMedia @@ -75,7 +51,7 @@ class PlaybackSourceResolver { streamHeaders: _streamHeaders( client: reportingClient, sourceKind: sourceKind, - sessionIdentifier: sessionIdentifier, + sessionIdentifier: options.sessionIdentifier, ), ); } diff --git a/lib/services/playback_subtitle_resolver.dart b/lib/services/playback_subtitle_resolver.dart index 3f1ed4a6..bd7e14c4 100644 --- a/lib/services/playback_subtitle_resolver.dart +++ b/lib/services/playback_subtitle_resolver.dart @@ -275,13 +275,6 @@ class PlaybackSubtitleResolver { return findMpvTrackForPlexSubtitle(sourceTrack, nativeTracks, allPlexTracks: allSourceTracks); } - static PlaybackSourceSubtitleChoice nextSourceChoice( - List tracks, - PlaybackSourceSubtitleChoice currentChoice, - ) { - return advanceSourceChoice(tracks, currentChoice, 1); - } - static PlaybackSourceSubtitleChoice advanceSourceChoice( List tracks, PlaybackSourceSubtitleChoice currentChoice, diff --git a/lib/services/playlist_items_loader.dart b/lib/services/playlist_items_loader.dart index fb1b709b..e9f38316 100644 --- a/lib/services/playlist_items_loader.dart +++ b/lib/services/playlist_items_loader.dart @@ -1,3 +1,4 @@ +import '../media/library_query.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; import '../utils/media_server_http_client.dart'; @@ -10,20 +11,11 @@ Future> fetchAllPlaylistItems( String playlistId, { int pageSize = playlistItemsPageSize, AbortController? abort, -}) async { - final all = []; - var offset = 0; - while (true) { - abort?.throwIfAborted(); - final page = await client.fetchPlaylistPage(playlistId, start: offset, size: pageSize, abort: abort); - abort?.throwIfAborted(); - if (page.items.isEmpty) break; - all.addAll(page.items); - if (all.length >= page.totalCount) break; - offset += page.items.length; - } - return all; -} +}) => drainPages( + (start, size) => client.fetchPlaylistPage(playlistId, start: start, size: size, abort: abort), + pageSize: pageSize, + abort: abort, +); /// Page through every item in a collection via the backend-neutral client API. Future> fetchAllCollectionItemsPaged( @@ -32,21 +24,14 @@ Future> fetchAllCollectionItemsPaged( int pageSize = 100, String? libraryId, String? libraryTitle, -}) async { - final all = []; - var offset = 0; - while (true) { - final page = await client.fetchCollectionPage( - collectionId, - start: offset, - size: pageSize, - libraryId: libraryId, - libraryTitle: libraryTitle, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - if (all.length >= page.totalCount || page.items.length < pageSize) break; - offset += page.items.length; - } - return all; -} +}) => drainPages( + (start, size) => client.fetchCollectionPage( + collectionId, + start: start, + size: size, + libraryId: libraryId, + libraryTitle: libraryTitle, + ), + pageSize: pageSize, + stopOnShortPage: true, +); diff --git a/lib/services/plex_api_cache.dart b/lib/services/plex_api_cache.dart index d9dee915..05f4754c 100644 --- a/lib/services/plex_api_cache.dart +++ b/lib/services/plex_api_cache.dart @@ -1,6 +1,7 @@ import '../media/ids.dart'; import 'package:drift/drift.dart'; +import 'package:flutter/foundation.dart'; import '../database/app_database.dart'; import '../database/plex_metadata_recovery.dart'; @@ -73,6 +74,7 @@ class PlexApiCache extends ApiCache { // Rating keys can be alphanumeric, not just numeric. static final RegExp _metadataKeyPattern = RegExp(r'/library/metadata/([^/]+)$'); + @visibleForTesting Future> getPinnedKeys(ServerId serverId) => extractPinnedIds(serverId, _metadataKeyPattern); /// Copy one pinned item between cache namespaces. diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a6a46fa1..c6a415bf 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -7,7 +7,8 @@ import 'plex_client.dart'; import '../exceptions/media_server_exceptions.dart'; import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; -import '../models/user_switch_response.dart'; +import '../models/plex/plex_home_user.dart'; +import '../models/plex/plex_switch_response.dart'; import '../utils/app_logger.dart'; import '../utils/device_identity.dart'; import '../utils/endpoint_race.dart'; @@ -15,6 +16,7 @@ import '../utils/json_utils.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/media_server_http_client.dart'; import '../utils/poll_with_backoff.dart'; +import '../utils/url_utils.dart'; /// Redacts the middle of an IP address or hostname for safe logging. /// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`. @@ -162,11 +164,7 @@ class PlexAuthService { String getAuthUrl(String pinCode) { final params = {'clientID': _clientIdentifier, 'code': pinCode, 'context[device][product]': _appName}; - final queryString = params.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); - - return 'https://app.plex.tv/auth#?$queryString'; + return 'https://app.plex.tv/auth#?${encodeQueryParameters(params)}'; } /// Poll the PIN to check if it has been claimed @@ -264,8 +262,9 @@ class PlexAuthService { return PlexHome.fromJson(response.data as Map); } - /// Switch to a different user in the home - Future switchToUser(String userUUID, String currentToken, {String? pin}) async { + /// Switch to a different user in the home, returning the freshly minted + /// user-level token + Future switchToUser(String userUUID, String currentToken, {String? pin}) async { final queryParams = { 'includeSubscriptions': '1', 'includeProviders': '1', @@ -288,7 +287,7 @@ class PlexAuthService { ); _checkStatus(response); - return UserSwitchResponse.fromJson(response.data as Map); + return parsePlexSwitchAuthToken(response.data as Map); } } @@ -1016,6 +1015,19 @@ class PlexConnection { } } +/// Default implementation of the `Future> Function(String)` +/// fetcher seam injected into `PlexHomeService` and `ConnectionBootstrap`: +/// spins up a throwaway [PlexAuthService] for a single `/home/users` call. +Future> fetchPlexHomeUsers(String accountToken) async { + final auth = await PlexAuthService.create(); + try { + final home = await auth.getHomeUsers(accountToken); + return home.users; + } finally { + auth.dispose(); + } +} + String? _optionalScalarString(Object? value) => switch (value) { null => null, final String value => value, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 9290d139..27e48046 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -29,18 +29,14 @@ import 'settings_service.dart'; import 'library_query_translator.dart'; import 'scrub_preview_source.dart'; import '../utils/media_server_http_client.dart'; +import '../utils/url_utils.dart'; import '../exceptions/media_server_exceptions.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; import '../models/livetv_dvr.dart'; import '../models/livetv_hub_result.dart'; -import '../models/livetv_lineup.dart'; import '../models/livetv_program.dart'; -import '../models/livetv_server_status.dart'; -import '../models/livetv_session.dart'; import '../models/media_grab_operation.dart'; -import '../models/media_grabber_device.dart'; -import '../models/media_provider_info.dart'; import '../models/media_subscription.dart'; import '../models/plex/plex_activity.dart'; import '../models/plex/plex_config.dart'; @@ -169,6 +165,20 @@ List _processHubResponse( return hubs; } +/// Library-hub item filter. Music-section hubs carry artist/album/track items — +/// the default video-only filter would empty them out. +bool _videoOrMusicHubItem(PlexMetadataDto item) { + final type = item.type?.toLowerCase(); + return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type); +} + +/// Related-hub item filter: related rows include collection entries alongside +/// the usual video items. +bool _videoOrCollectionHubItem(PlexMetadataDto item) { + final type = item.type?.toLowerCase(); + return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection; +} + int? _librarySectionIdFromJson(Map? json) => plexLibrarySectionIdFromJson(json); int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId); @@ -259,9 +269,54 @@ class _PlexMediaProviderState { final String? continueWatchingHubKey; } +/// Canonical declarations of the [PlexClient] internals that the `part` +/// mixins below call into. +/// +/// Every part mixin is `on _PlexClientInternals`, so each shared member is +/// declared exactly once here instead of being re-declared (and drifting) +/// per file. Members used by a single part stay declared in that part. +mixin _PlexClientInternals on MediaServerCacheMixin { + FailoverHttpClient get _http; + + Future _getWithFailover( + String path, { + Map? queryParameters, + // ignore: unused_element_parameter + Map? headers, + // ignore: unused_element_parameter + Duration? timeout, + AbortController? abort, + bool allowEndpointFailover = true, + }); + + Map? _getMediaContainer(MediaServerResponse response); + + Map _buildPaginationParams(int? start, int? size); + + Future<_LibraryContentResult> _fetchPaginatedList( + String path, { + int? start, + int? size, + AbortController? abort, + int? librarySectionID, + String? librarySectionTitle, + }); + + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); + + Future> _wrapListApiCall( + Future Function() apiCall, + List Function(MediaServerResponse response) parseResponse, + String errorMessage, + ); + + Future buildMetadataUri(String ratingKey); +} + class PlexClient with MediaServerCacheMixin, + _PlexClientInternals, _PlexLiveTvClientMethods, _PlexPlaylistMethods, _PlexCollectionMethods, @@ -862,6 +917,7 @@ class PlexClient ); } + @visibleForTesting Future> getServerIdentity() async { final response = await _getWithFailover('/identity'); return response.data; @@ -1235,23 +1291,18 @@ class PlexClient static const int _fetchAllPageSize = 200; /// Iterate every page of a paginated endpoint and concatenate the results. - /// Stops as soon as [_LibraryContentResult.totalSize] is reached or a page - /// returns no items. Errors propagate. + /// Adapts Plex's [_LibraryContentResult] onto the shared [drainPages] drain, + /// so it stops as soon as [_LibraryContentResult.totalSize] is reached or a + /// page returns no items. Errors propagate. @override Future> _fetchAllPages( Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { AbortController? abort, - }) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPage(start, _fetchAllPageSize, abort); - all.addAll(page.items); - start += page.items.length; - if (page.items.isEmpty) break; - if (start >= page.totalSize) break; - } - return all; + }) { + return drainPages((start, size) async { + final page = await fetchPage(start, size, abort); + return LibraryPage(items: page.items, totalCount: page.totalSize, offset: start); + }, pageSize: _fetchAllPageSize); } /// Walk every page of [path] and return a single synthesized response whose @@ -1486,18 +1537,6 @@ class PlexClient return (items: results, rawCount: searchResults.length); } - /// Get recently added media (filtered to video content only) - Future> _getRecentlyAdded({int limit = 50}) async { - final response = await _getWithFailover( - '/library/recentlyAdded', - queryParameters: {'X-Plex-Container-Size': limit, 'includeGuids': 1}, - ); - final allItems = _extractMetadataList(response); - - // Filter out music content (artists, albums, tracks) - return allItems.where((item) => !ContentTypes.musicTypes.contains(item.type?.toLowerCase())).toList(); - } - /// Get continue watching items via the hubs system. /// Prefer the provider's dedicated Continue Watching feature key when /// advertised; fall back to Plex Web's legacy hubs query. Both respect the @@ -2110,109 +2149,90 @@ class PlexClient return fallbackSorts; } + /// Shared transport for the hub endpoints: bounded transient retry with no + /// endpoint failover (a hub row is not worth flipping the active endpoint), + /// isolate-offloaded parsing, and log-and-empty on failure so one dead hub + /// row never takes down the screen around it. + /// + /// [failureLabel] names the hub set in the failure log line. + Future> _fetchHubs({ + required String path, + required Map queryParameters, + required String operation, + required List attemptTimeouts, + required String failureLabel, + int? librarySectionID, + String? librarySectionTitle, + bool Function(PlexMetadataDto)? filter, + }) async { + try { + final response = await retryTransientMediaServerCall( + operation: operation, + attemptTimeouts: attemptTimeouts, + call: (timeout, abort) => _getWithFailover( + path, + queryParameters: queryParameters, + timeout: timeout, + abort: abort, + allowEndpointFailover: false, + ), + ); + final sid = serverId; + final sname = serverName; + final data = response.data as Map; + return await tryIsolateRun( + () => _processHubResponse( + data, + sid, + sname, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + filter: filter, + ), + ); + } catch (e) { + appLogger.e('Failed to get $failureLabel: $e'); + } + return []; + } + /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. Future> _getLibraryHubs( String sectionId, { int limit = defaultHubPreviewLimit, String? libraryName, - }) async { - try { - final response = await retryTransientMediaServerCall( - operation: 'Plex library hubs', - attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - '/hubs/sections/$sectionId', - queryParameters: {'count': limit, 'includeGuids': 1}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun( - () => _processHubResponse( - data, - sid, - sname, - librarySectionID: _librarySectionIdFromString(sectionId), - librarySectionTitle: libraryName, - // Music-section hubs carry artist/album/track items — the default - // video-only filter would empty them out. - filter: (item) { - final type = item.type?.toLowerCase(); - return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type); - }, - ), - ); - } catch (e) { - appLogger.e('Failed to get library hubs: $e'); - } - return []; - } + }) => _fetchHubs( + path: '/hubs/sections/$sectionId', + queryParameters: {'count': limit, 'includeGuids': 1}, + operation: 'Plex library hubs', + attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, + failureLabel: 'library hubs', + librarySectionID: _librarySectionIdFromString(sectionId), + librarySectionTitle: libraryName, + filter: _videoOrMusicHubItem, + ); /// Get global hubs (home page recommendations) /// Returns actual home page hubs like "Recently Added Movies", "Recently Added TV", etc. /// This matches the official Plex client's home page layout. - Future> _getGlobalHubs({int limit = defaultHubPreviewLimit}) async { - try { - final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs'; - final response = await retryTransientMediaServerCall( - operation: 'Plex global hubs', - attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - hubKey, - queryParameters: {'count': limit, 'includeGuids': 1}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun(() => _processHubResponse(data, sid, sname)); - } catch (e) { - appLogger.e('Failed to get global hubs: $e'); - } - return []; - } + Future> _getGlobalHubs({int limit = defaultHubPreviewLimit}) => _fetchHubs( + path: _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs', + queryParameters: {'count': limit, 'includeGuids': 1}, + operation: 'Plex global hubs', + attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, + failureLabel: 'global hubs', + ); /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) - Future> _getRelatedHubs(String ratingKey, {int count = 10}) async { - try { - final response = await retryTransientMediaServerCall( - operation: 'Plex related hubs', - attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - '/hubs/metadata/$ratingKey/related', - queryParameters: {'count': count}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun( - () => _processHubResponse( - data, - sid, - sname, - filter: (item) { - final type = item.type?.toLowerCase(); - return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection; - }, - ), - ); - } catch (e) { - appLogger.e('Failed to get related hubs: $e'); - } - return []; - } + Future> _getRelatedHubs(String ratingKey, {int count = 10}) => _fetchHubs( + path: '/hubs/metadata/$ratingKey/related', + queryParameters: {'count': count}, + operation: 'Plex related hubs', + attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, + failureLabel: 'related hubs', + filter: _videoOrCollectionHubItem, + ); /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub @@ -3224,36 +3244,10 @@ class PlexClient ); } catch (error, stackTrace) { if (error is PlaybackException) rethrow; - Error.throwWithStackTrace(_classifyPlaybackFailure(error), stackTrace); + Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace); } } - PlaybackException _classifyPlaybackFailure(Object error) { - if (error is MediaServerAuthException || - error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { - return PlaybackException( - t.messages.playbackAuthenticationRequired, - reason: PlaybackFailureReason.authenticationRequired, - ); - } - if (error is MediaServerHttpException) { - if (error.isCancellation) { - return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); - } - final status = error.statusCode; - if (error.isTransient || status != null && status >= 500) { - return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); - } - if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - } - if (error is FormatException || error is TypeError) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - return PlaybackException(t.messages.playbackFailed); - } - /// Direct-play result for a transcode decision that fell back (failed or /// said direct-play only), surfacing the reason so the UI can notify the /// user. Shared by the video and music branches of @@ -3534,12 +3528,6 @@ class PlexClient return results.map((m) => PlexMappers.mediaItem(m)).toList(); } - @override - Future> fetchRecentlyAdded({int limit = 50}) async { - final items = await _getRecentlyAdded(limit: limit); - return items.map((m) => PlexMappers.mediaItem(m)).toList(); - } - @override Future> fetchContinueWatching({int? count = 20}) async { final items = await _getContinueWatching(count: count); diff --git a/lib/services/plex_client/parts/collections.dart b/lib/services/plex_client/parts/collections.dart index bc0d0e7c..3318a240 100644 --- a/lib/services/plex_client/parts/collections.dart +++ b/lib/services/plex_client/parts/collections.dart @@ -1,23 +1,6 @@ part of '../../plex_client.dart'; -mixin _PlexCollectionMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - Map _buildPaginationParams(int? start, int? size); - +mixin _PlexCollectionMethods on _PlexClientInternals { _LibraryContentResult _extractLibraryContentResult( MediaServerResponse response, { int? librarySectionID, @@ -27,25 +10,12 @@ mixin _PlexCollectionMethods on MediaServerCacheMixin { int? requestedSize, }); - Future<_LibraryContentResult> _fetchPaginatedList( - String path, { - int? start, - int? size, - AbortController? abort, - int? librarySectionID, - String? librarySectionTitle, - }); - Future> _fetchAllPages( Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { // ignore: unused_element_parameter AbortController? abort, }); - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future buildMetadataUri(String ratingKey); - Future<_LibraryContentResult> _getLibraryCollectionsPage( String sectionId, { int? start, diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index 80f9baf4..9b1ff2ad 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -3,38 +3,13 @@ part of '../../plex_client.dart'; const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels'; const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; -mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport, LiveTvDvrSupport { +mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, LiveTvDvrSupport { PlexConfig get config; - MediaServerHttpClient get _http; - - @override - ServerId get serverId; - - @override - String? get serverName; List<({String identifier, String gridEndpoint})> get _providerEpg; - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); PlexMetadataDto _createTaggedMetadata(Map json); - Future> _wrapListApiCall( - Future Function() apiCall, - List Function(MediaServerResponse response) parseResponse, - String errorMessage, - ); - /// POST the tune endpoint with one retry on transient HTTP failure. Future _postTuneWithRetry(String path, String sessionIdentifier) async { final query = {'X-Plex-Session-Identifier': sessionIdentifier}; @@ -96,7 +71,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport for (final entry in request.prefs.entries) 'prefs[${entry.key}]': entry.value, for (final entry in request.params.entries) 'params[${entry.key}]': entry.value, }; - final encoded = MediaServerHttpClient.encodeQueryParameters(flat); + final encoded = encodeQueryParameters(flat); if (encoded.isNotEmpty) parts.add(encoded); return parts.join('&'); } @@ -197,73 +172,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return dvrs.isNotEmpty; } - @override - Future fetchLiveTvServerStatus() async { - final response = await _getWithFailover('/'); - final container = _getMediaContainer(response); - return LiveTvServerStatus.fromJson(container ?? const {}); - } - - @override - Future fetchDvr(String dvrId) async { - final response = await _getWithFailover('/livetv/dvrs/$dvrId'); - final container = _getMediaContainer(response); - final rootMappings = container?['ChannelMapping']; - return _extractFirst(response, const ['Dvr'], (json) { - final map = Map.from(json)..putIfAbsent('ChannelMapping', () => rootMappings); - return LiveTvDvr.fromJson(map); - }); - } - - @override - Future> createDvr({ - required List devices, - required List lineups, - String? language, - String? country, - String? postalCode, - }) async { - final response = await _http.post( - '/livetv/dvrs', - queryParameters: { - 'device': devices, - 'lineup': lineups, - ...?(language == null ? null : {'language': language}), - ...?(country == null ? null : {'country': country}), - ...?(postalCode == null ? null : {'postalCode': postalCode}), - }, - timeout: MediaServerTimeouts.receive, - ); - _throwIfFailed(response); - return LiveTvActivityResult( - value: _extractFirst(response, const ['Dvr'], LiveTvDvr.fromJson), - activityUuid: _activityUuid(response), - ); - } - - @override - Future deleteDvr(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId')); - - @override - Future updateDvrPrefs(String dvrId, Map prefs) => - _expectOk(() => _http.put('/livetv/dvrs/$dvrId/prefs', queryParameters: prefs)); - - @override - Future attachDeviceToDvr(String dvrId, String deviceId) => - _expectOk(() => _http.put('/livetv/dvrs/$dvrId/devices/$deviceId')); - - @override - Future detachDeviceFromDvr(String dvrId, String deviceId) => - _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/devices/$deviceId')); - - @override - Future addLineupToDvr(String dvrId, String lineupUri) => - _expectOk(() => _http.put('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); - - @override - Future removeLineupFromDvr(String dvrId, String lineupUri) => - _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/lineups', queryParameters: {'lineup': lineupUri})); - @override Future> reloadGuide(String dvrId) async { final response = await _http.post('/livetv/dvrs/$dvrId/reloadGuide', timeout: MediaServerTimeouts.receive); @@ -271,229 +179,23 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return LiveTvActivityResult(value: null, activityUuid: _activityUuid(response)); } - @override - Future cancelGuideReload(String dvrId) => _expectOk(() => _http.delete('/livetv/dvrs/$dvrId/reloadGuide')); - - @override - Future> fetchGrabbers({String? protocol}) async { - final response = await _getWithFailover( - '/media/grabbers', - queryParameters: { - ...?(protocol == null ? null : {'protocol': protocol}), - }, - ); - return _extractContainerList(response, const ['MediaGrabber'], MediaGrabber.fromJson); - } - - @override - Future> fetchGrabberDevices() async { - final response = await _getWithFailover('/media/grabbers/devices'); - return _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future>> discoverGrabberDevices() async { - final response = await _http.post('/media/grabbers/devices/discover', timeout: MediaServerTimeouts.receive); - _throwIfFailed(response); - return LiveTvActivityResult( - value: _extractContainerList(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson), - activityUuid: _activityUuid(response), - ); - } - - @override - Future fetchGrabberDevice(String deviceId) async { - final response = await _getWithFailover('/media/grabbers/devices/$deviceId'); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future addGrabberDevice(String uri, {String? grabberId}) async { - final path = grabberId == null ? '/media/grabbers/devices' : '/media/grabbers/$grabberId/devices'; - final response = await _http.post(path, queryParameters: {'uri': uri}); - _throwIfFailed(response); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future updateGrabberDevice(String deviceId, {bool? enabled, String? title}) => _expectOk( - () => _http.put( - '/media/grabbers/devices/$deviceId', - queryParameters: { - ...?(enabled == null ? null : {'enabled': enabled ? 1 : 0}), - ...?(title == null ? null : {'title': title}), - }, - ), - ); - - @override - Future deleteGrabberDevice(String deviceId) => - _expectOk(() => _http.delete('/media/grabbers/devices/$deviceId')); - - @override - Future> fetchGrabberDeviceChannels(String deviceId) async { - final response = await _getWithFailover('/media/grabbers/devices/$deviceId/channels'); - return _extractContainerList(response, const ['DeviceChannel'], MediaGrabberDeviceChannel.fromJson); - } - - @override - Future> scanGrabberDevice( - String deviceId, { - String? source, - Map prefs = const {}, - String? network, - String? country, - }) async { - final response = await _http.post( - '/media/grabbers/devices/$deviceId/scan', - queryParameters: { - ...?(source == null ? null : {'source': source}), - for (final entry in prefs.entries) 'prefs[${entry.key}]': entry.value, - ...?(network == null ? null : {'network': network}), - ...?(country == null ? null : {'country': country}), - }, - timeout: MediaServerTimeouts.receive, - ); - _throwIfFailed(response); - return LiveTvActivityResult( - value: _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson), - activityUuid: _activityUuid(response), - ); - } - - @override - Future cancelGrabberDeviceScan(String deviceId) async { - final response = await _http.delete('/media/grabbers/devices/$deviceId/scan'); - _throwIfFailed(response); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future saveGrabberDeviceChannelMap( - String deviceId, - MediaGrabberChannelMapRequest request, - ) async { - final response = await _http.put( - '/media/grabbers/devices/$deviceId/channelmap', - queryParameters: { - if (request.channelsEnabled.isNotEmpty) 'channelsEnabled': request.channelsEnabled.join(','), - for (final entry in request.channelMapping.entries) 'channelMapping[${entry.key}]': entry.value, - for (final entry in request.channelMappingByKey.entries) 'channelMappingByKey[${entry.key}]': entry.value, - }, - ); - _throwIfFailed(response); - return _extractFirst(response, const ['Device', 'Devices'], MediaGrabberDevice.fromJson); - } - - @override - Future updateGrabberDevicePrefs(String deviceId, Map prefs) => - _expectOk(() => _http.put('/media/grabbers/devices/$deviceId/prefs', queryParameters: prefs)); - - @override - String buildGrabberDeviceThumbUrl(String deviceId, int version) => - '${config.baseUrl}/media/grabbers/devices/$deviceId/thumb/$version'.withPlexToken(config.token); - - @override - Future> fetchEpgCountries() async { - final response = await _getWithFailover('/livetv/epg/countries'); - return _extractContainerList(response, const ['Country'], LiveTvCountry.fromJson); - } - - @override - Future> fetchEpgLanguages() async { - final response = await _getWithFailover('/livetv/epg/languages'); - return _extractContainerList(response, const ['Language'], LiveTvLanguage.fromJson); - } - - @override - Future> fetchEpgRegions(String country, String epgId) async { - final response = await _getWithFailover('/livetv/epg/countries/$country/$epgId/regions'); - return _extractContainerList(response, const ['Region'], LiveTvRegion.fromJson); - } - - @override - Future fetchEpgLineups(String country, String epgId, {String? postalCode, String? region}) async { - final path = region == null - ? '/livetv/epg/countries/$country/$epgId/lineups' - : '/livetv/epg/countries/$country/$epgId/regions/$region/lineups'; - final response = await _getWithFailover( - path, - queryParameters: { - ...?(postalCode == null ? null : {'postalCode': postalCode}), - }, - ); - final container = _getMediaContainer(response); - return LiveTvLineupResult( - lineupGroupUuid: container?['uuid'] as String?, - lineups: _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson), - ); - } - - @override - Future> fetchEpgChannelsForLineup(String lineupUri) async { - final response = await _getWithFailover('/livetv/epg/channels', queryParameters: {'lineup': lineupUri}); - return _extractContainerList(response, const [ - 'Channel', - ], (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName)); - } - - @override - Future> fetchEpgChannelsForLineups(List lineupUris) async { - final response = await _getWithFailover('/livetv/epg/lineupchannels', queryParameters: {'lineup': lineupUris}); - return _extractContainerList(response, const ['Lineup'], LiveTvLineup.fromJson); - } - - @override - Future> computeEpgChannelMap({required String deviceUri, required String lineupUri}) async { - final response = await _getWithFailover( - '/livetv/epg/channelmap', - queryParameters: {'device': deviceUri, 'lineup': lineupUri}, - ); - return _extractContainerList(response, const ['ChannelMapping'], ChannelMapping.fromJson); - } - - @override - Future?>> findBestLineup({ - required String deviceUri, - required String lineupGroupUri, - }) async { - final response = await _getWithFailover( - '/livetv/epg/lineup', - queryParameters: {'device': deviceUri, 'lineupGroup': lineupGroupUri}, - timeout: MediaServerTimeouts.receive, - ); - return LiveTvActivityResult(value: _getMediaContainer(response), activityUuid: _activityUuid(response)); - } - /// Get EPG channels using provider lineup endpoints (matches official Plex web client) Future> getEpgChannels({String? lineup}) async { List parseChannels(MediaServerResponse response) { final container = _getMediaContainer(response); - if (container != null && container['Channel'] is List && (container['Channel'] as List).isNotEmpty) { - appLogger.d('EPG channel sample: ${(container['Channel'] as List).first}'); + if (container == null || (container['Channel'] == null && container['Metadata'] == null)) { + appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); + return []; } - if (container != null && container['Channel'] != null) { - return (container['Channel'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); + final rawChannels = container['Channel']; + if (rawChannels is List && rawChannels.isNotEmpty) { + appLogger.d('EPG channel sample: ${rawChannels.first}'); } - if (container != null && container['Metadata'] != null) { - return (container['Metadata'] as List) - .map( - (json) => LiveTvChannel.fromJson( - json as Map, - ).copyWith(serverId: serverId, serverName: serverName), - ) - .where((ch) => ch.key.isNotEmpty) - .toList(); - } - appLogger.d('EPG channels: container keys=${container?.keys.toList()}, size=${container?['size']}'); - return []; + return _extractContainerList( + response, + const ['Channel', 'Metadata'], + (json) => LiveTvChannel.fromJson(json).copyWith(serverId: serverId, serverName: serverName), + ).where((ch) => ch.key.isNotEmpty).toList(); } final allChannels = []; @@ -744,19 +446,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); } - @override - Future fetchRecordingRule( - String subscriptionId, { - bool includeGrabs = true, - bool includeStorage = true, - }) async { - final response = await _getWithFailover( - '/media/subscriptions/$subscriptionId', - queryParameters: {'includeGrabs': includeGrabs ? 1 : 0, 'includeStorage': includeStorage ? 1 : 0}, - ); - return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); - } - @override Future createRecordingRule(MediaSubscriptionCreateRequest request) async { final response = await _http.post(_withQuery('/media/subscriptions', _subscriptionCreateQuery(request))); @@ -778,18 +467,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport Future deleteRecordingRule(String subscriptionId) => _expectOk(() => _http.delete('/media/subscriptions/$subscriptionId')); - @override - Future moveRecordingRule(String subscriptionId, {String? afterSubscriptionId}) async { - final response = await _http.put( - '/media/subscriptions/$subscriptionId/move', - queryParameters: { - ...?(afterSubscriptionId == null ? null : {'after': afterSubscriptionId}), - }, - ); - _throwIfFailed(response); - return _extractFirst(response, const ['MediaSubscription'], MediaSubscription.fromJson); - } - @override Future processRecordingRules() => _expectOk(() => _http.post('/media/subscriptions/process')); @@ -824,23 +501,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return _extractContainerList(response, const ['MediaSubscription'], MediaSubscription.fromJson); } - @override - Future> fetchMediaProviders() async { - final response = await _getWithFailover('/media/providers'); - return _extractContainerList(response, const ['MediaProvider'], MediaProviderInfo.fromJson); - } - - @override - Future registerMediaProvider(String url) => - _expectOk(() => _http.post('/media/providers', queryParameters: {'url': url})); - - @override - Future refreshMediaProviders() => _expectOk(() => _http.post('/media/providers/refresh')); - - @override - Future unregisterMediaProvider(String providerId) => - _expectOk(() => _http.delete('/media/providers/$providerId')); - /// Tune to a live TV channel. /// /// POSTs to the tune endpoint and extracts metadata, session info, and @@ -873,11 +533,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport if (container == null) return null; final containerStatus = container['status']; - final statusInt = containerStatus is num - ? containerStatus.toInt() - : containerStatus is String - ? int.tryParse(containerStatus) - : null; + final statusInt = flexibleInt(containerStatus); if (statusInt != null && statusInt != 0 && statusInt != 200) { final msg = container['message'] ?? t.liveTv.unknownError; appLogger.w('Tune channel error: $msg (status: $containerStatus)'); @@ -907,14 +563,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport if (op is Map) { if (op['Metadata'] case [final Map firstMetadata, ...]) { if (firstMetadata['Media'] case [final Map firstMedia, ...]) { - final rawBeginsAt = firstMedia['beginsAt']; - - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; - + beginsAt = flexibleInt(firstMedia['beginsAt']); appLogger.d('beginsAt=$beginsAt'); } } @@ -980,12 +629,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport if (media is List && media.isNotEmpty) { final firstMedia = media.first; if (firstMedia is Map) { - final rawBeginsAt = firstMedia['beginsAt']; - beginsAt = switch (rawBeginsAt) { - final num n => n.toInt(), - final String s => int.tryParse(s), - _ => null, - }; + beginsAt = flexibleInt(firstMedia['beginsAt']); } } } @@ -1053,10 +697,9 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport if (config.token != null) 'X-Plex-Token': config.token!, }; - // Manual query encoding — use '%20' for spaces as Plex requires. - final queryString = allParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); + // '%20' for spaces as Plex requires — not `Uri.queryParameters`, which + // emits `+`. + final queryString = encodeQueryParameters(allParams); // Decision — wrapper around the same transport so no default X-Plex-* // HTTP headers leak through (everything travels in the query string). @@ -1086,9 +729,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport // Token is added by the caller via .withPlexToken() final startParams = Map.from(allParams)..remove('X-Plex-Token'); - final startQuery = startParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); + final startQuery = encodeQueryParameters(startParams); return '$_plexVideoHlsStartEndpoint?$startQuery'; } catch (e, st) { @@ -1106,53 +747,6 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport return '${config.baseUrl}$streamPath'.withPlexToken(config.token); } - @override - Future> fetchLiveTvSessionsDetailed() async { - final response = await _getWithFailover('/livetv/sessions'); - return _extractContainerList(response, const [ - 'LiveTVSession', - 'LiveTvSession', - 'Session', - 'Metadata', - ], LiveTvSession.fromJson); - } - - @override - Future fetchLiveTvSession(String sessionId) async { - final response = await _getWithFailover('/livetv/sessions/$sessionId'); - return _extractFirst(response, const [ - 'LiveTVSession', - 'LiveTvSession', - 'Session', - 'Metadata', - ], LiveTvSession.fromJson); - } - - @override - Uri buildNotificationWebSocketUri({List? filters}) { - final base = Uri.parse(config.baseUrl); - return base.replace( - scheme: base.scheme == 'https' ? 'wss' : 'ws', - path: '/:/websocket/notifications', - queryParameters: { - if (config.token != null) 'X-Plex-Token': config.token!, - if (filters != null) 'filters': filters.join(','), - }, - ); - } - - @override - Uri buildNotificationEventSourceUri({List? filters}) { - final base = Uri.parse(config.baseUrl); - return base.replace( - path: '/:/eventsource/notifications', - queryParameters: { - if (config.token != null) 'X-Plex-Token': config.token!, - if (filters != null) 'filters': filters.join(','), - }, - ); - } - /// Build the source URI for favorite channels: `server://{machineIdentifier}/{providerIdentifier}` @override Future buildFavoriteChannelSource({String? lineup}) async { diff --git a/lib/services/plex_client/parts/metadata_edit.dart b/lib/services/plex_client/parts/metadata_edit.dart index 472dc273..f5e00d81 100644 --- a/lib/services/plex_client/parts/metadata_edit.dart +++ b/lib/services/plex_client/parts/metadata_edit.dart @@ -1,33 +1,7 @@ part of '../../plex_client.dart'; -mixin _PlexMetadataEditMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; +mixin _PlexMetadataEditMethods on _PlexClientInternals { PlexApiCache get _cache; - @override - ServerId get serverId; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future> _wrapListApiCall( - Future Function() apiCall, - List Function(MediaServerResponse response) parseResponse, - String errorMessage, - ); Future updateMetadata({ required int sectionId, diff --git a/lib/services/plex_client/parts/play_queues.dart b/lib/services/plex_client/parts/play_queues.dart index ee501e70..d6800ecc 100644 --- a/lib/services/plex_client/parts/play_queues.dart +++ b/lib/services/plex_client/parts/play_queues.dart @@ -1,29 +1,12 @@ part of '../../plex_client.dart'; -mixin _PlexPlayQueueMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - +mixin _PlexPlayQueueMethods on _PlexClientInternals { PlexMetadataDto _createTaggedMetadataWithLibrary( Map json, { int? librarySectionID, String? librarySectionTitle, }); - Future buildMetadataUri(String ratingKey); - PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) { final container = data is Map && data['MediaContainer'] is Map ? data['MediaContainer'] as Map diff --git a/lib/services/plex_client/parts/playlists.dart b/lib/services/plex_client/parts/playlists.dart index 566fe0fe..6123ee9b 100644 --- a/lib/services/plex_client/parts/playlists.dart +++ b/lib/services/plex_client/parts/playlists.dart @@ -1,62 +1,24 @@ part of '../../plex_client.dart'; -mixin _PlexPlaylistMethods on MediaServerCacheMixin { +mixin _PlexPlaylistMethods on _PlexClientInternals { static const int _playlistPageSize = 200; static const int _defaultPlaylistContainerSize = 100; - FailoverHttpClient get _http; - @override - ServerId get serverId; - @override - String? get serverName; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - Map _buildPaginationParams(int? start, int? size); - - Future<_LibraryContentResult> _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}); - ({List items, int totalSize}) _extractPlaylistListResult( MediaServerResponse response, { int? start, int? size, }); - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future buildMetadataUri(String ratingKey); - Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) => _fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort); Future> _getPlaylists({String playlistType = 'video', bool? smart}) async { try { - final all = []; - var start = 0; - while (true) { - final page = await _getPlaylistsPage( - playlistType: playlistType, - smart: smart, - start: start, - size: _playlistPageSize, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalSize) break; - } - return all; + return await drainPages((start, size) async { + final page = await _getPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size); + return LibraryPage(items: page.items, totalCount: page.totalSize, offset: start); + }, pageSize: _playlistPageSize); } catch (e, st) { appLogger.e('Failed to get playlists', error: e, stackTrace: st); return []; diff --git a/lib/services/seerr/seerr_client.dart b/lib/services/seerr/seerr_client.dart index bf5cd27e..60f87e34 100644 --- a/lib/services/seerr/seerr_client.dart +++ b/lib/services/seerr/seerr_client.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:http/http.dart' as http; import '../../models/seerr/seerr_details.dart'; @@ -65,6 +66,7 @@ class SeerrClient { // ---------- Auth ---------- + @visibleForTesting Future getMe() async { final data = await _request('GET', '/auth/me'); return SeerrUser.fromJson(data as Map); @@ -128,14 +130,13 @@ class SeerrClient { // ---------- Details ---------- - Future getMovie(int tmdbId) async { - final data = await _request('GET', '/movie/$tmdbId'); - return SeerrMovieDetails.fromJson(data as Map); - } + Future getMovie(int tmdbId) => _details('/movie/$tmdbId'); - Future getTv(int tmdbId) async { - final data = await _request('GET', '/tv/$tmdbId'); - return SeerrTvDetails.fromJson(data as Map); + Future getTv(int tmdbId) => _details('/tv/$tmdbId'); + + Future _details(String path) async { + final data = await _request('GET', path); + return SeerrDetails.fromJson(data as Map); } // ---------- Requests ---------- @@ -145,10 +146,6 @@ class SeerrClient { return SeerrRequest.fromJson(data as Map); } - Future deleteRequest(int requestId) async { - await _request('DELETE', '/request/$requestId'); - } - // ---------- Sonarr / Radarr options (request sheet advanced pickers) ---------- Future> getRadarrServices() => _serviceList('/service/radarr'); diff --git a/lib/services/seerr/seerr_constants.dart b/lib/services/seerr/seerr_constants.dart index 830a2b5c..c7bd7be1 100644 --- a/lib/services/seerr/seerr_constants.dart +++ b/lib/services/seerr/seerr_constants.dart @@ -23,9 +23,7 @@ abstract final class SeerrMediaServerType { /// app checks are named; the full mask is stored on the session untouched. abstract final class SeerrPermission { static const int admin = 2; - static const int manageRequests = 16; static const int request = 32; - static const int autoApprove = 128; static const int request4k = 1024; static const int request4kMovie = 2048; static const int request4kTv = 4096; diff --git a/lib/services/seerr/seerr_http_client.dart b/lib/services/seerr/seerr_http_client.dart index ea70f065..a06e3052 100644 --- a/lib/services/seerr/seerr_http_client.dart +++ b/lib/services/seerr/seerr_http_client.dart @@ -7,6 +7,7 @@ import '../../utils/app_logger.dart'; import '../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../utils/platform_http_client_io.dart' as platform; +import '../../utils/url_utils.dart'; import '../trackers/tracker_http_client.dart'; import 'seerr_constants.dart'; import 'seerr_exceptions.dart'; @@ -26,9 +27,8 @@ class SeerrResponse { /// Adds the two things the tracker HTTP layer doesn't cover: /// 1. `connect.sid` cookie capture from `Set-Cookie` on login, replayed as /// `Cookie:` on every subsequent request — Express session auth. -/// 2. Query encoding with `%20` for spaces: Seerr proxies `/search` to -/// TMDB, which rejects `+` in the query value, so `Uri.queryParameters` -/// (which emits `+`) cannot be used. +/// 2. Query encoding via [encodeQueryParameters] (`%20` for spaces): Seerr +/// proxies `/search` to TMDB, which rejects `+` in the query value. class SeerrHttpClient { final String baseUrl; final http.Client _http; @@ -110,12 +110,8 @@ class SeerrHttpClient { Uri _uri(String path, Map? query) { final base = Uri.parse('$baseUrl${SeerrConstants.apiPath}$path'); - if (query == null || query.isEmpty) return base; - final parts = [ - for (final entry in query.entries) - if (entry.value != null) '${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value.toString())}', - ]; - return parts.isEmpty ? base : base.replace(query: parts.join('&')); + final encoded = encodeQueryParameters(query); + return encoded.isEmpty ? base : base.replace(query: encoded); } /// Throw the mapped exception for a 4xx/5xx response; no-op on success. diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index 13f3987b..531f38aa 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -17,7 +17,6 @@ import '../utils/platform_detector.dart'; import 'file_picker_service.dart'; import 'settings_service.dart'; import 'storage_service.dart'; -import 'trackers/tracker_constants.dart'; class ImportResult { final int keysImported; @@ -87,120 +86,13 @@ class SettingsExportService { static const Set _nonPortableDeviceStorageKeys = {'custom_download_path', 'custom_download_path_type'}; static const String _tvosDatabaseRecoveryPrefix = 'tvos_db_recovery_'; - /// Closed registry of portable, user-facing settings. The [Pref] declarations - /// are the source of truth for both keys and stored types; credentials, - /// runtime state, device paths, history, endpoints, and user-authored player + /// Closed registry of portable, user-facing settings, keyed by preference + /// key. [SettingsService.portablePrefs] is the source of truth for membership + /// and the [Pref] declarations for the stored types; credentials, runtime + /// state, device paths, history, endpoints, and user-authored player /// configuration are intentionally absent. static final Map _portablePreferences = { - for (final pref in >[ - SettingsService.enableDebugLogging, - SettingsService.enableHardwareDecoding, - SettingsService.enableHDR, - SettingsService.preferredVideoCodec, - SettingsService.preferredAudioCodec, - SettingsService.viewMode, - SettingsService.seekTimeSmall, - SettingsService.seekTimeLarge, - SettingsService.rewindOnResume, - SettingsService.showHeroSection, - SettingsService.tvFullCardLayout, - SettingsService.focusGlow, - SettingsService.useGlobalHubs, - SettingsService.showServerNameOnHubs, - SettingsService.groupLibrariesByServer, - SettingsService.sleepTimerDuration, - SettingsService.audioSyncOffset, - SettingsService.subtitleSyncOffset, - SettingsService.subtitleSearchLanguage, - SettingsService.volume, - SettingsService.rotationLocked, - SettingsService.subtitleFontSize, - SettingsService.subtitleTextColor, - SettingsService.subtitleBorderSize, - SettingsService.subtitleBorderColor, - SettingsService.subtitleBackgroundColor, - SettingsService.subtitleBackgroundOpacity, - SettingsService.subAssOverride, - SettingsService.subtitleRenderResolution, - SettingsService.subtitleBold, - SettingsService.subtitleItalic, - SettingsService.rememberTrackSelections, - SettingsService.showChapterMarkersOnTimeline, - SettingsService.clickVideoTogglesPlayback, - SettingsService.autoSkipIntro, - SettingsService.autoSkipCredits, - SettingsService.forceSkipMarkerFallback, - SettingsService.autoSkipDelay, - SettingsService.introPattern, - SettingsService.creditsPattern, - SettingsService.downloadOnWifiOnly, - SettingsService.autoRemoveWatchedDownloads, - SettingsService.downloadIncludeSpecials, - SettingsService.autoCheckUpdatesOnStartup, - SettingsService.showPerformanceOverlay, - SettingsService.autoHidePerformanceOverlay, - SettingsService.enableDiscordRPC, - SettingsService.enableTraktScrobble, - SettingsService.enableTraktWatchedSync, - SettingsService.enableMalScrobble, - SettingsService.enableAnilistScrobble, - SettingsService.enableSimklScrobble, - SettingsService.matchContentFrameRate, - SettingsService.tunneledPlayback, - SettingsService.dvConversionMode, - SettingsService.defaultQualityPreset, - SettingsService.musicQualityPreset, - SettingsService.musicVolume, - SettingsService.autoPlayNextEpisode, - SettingsService.useExoPlayer, - SettingsService.startupSection, - SettingsService.alwaysKeepSidebarOpen, - SettingsService.showUnwatchedCount, - SettingsService.showEpisodeNumberOnCards, - SettingsService.showSeasonPostersOnTabs, - SettingsService.hideSpoilers, - SettingsService.showNavBarLabels, - SettingsService.globalShaderPreset, - SettingsService.requireProfileSelectionOnOpen, - SettingsService.useExternalPlayer, - SettingsService.forceTvMode, - SettingsService.visualEffects, - SettingsService.ambientLighting, - SettingsService.audioPassthrough, - SettingsService.audioNormalization, - SettingsService.audioDownmix, - SettingsService.audioDownmixNormalize, - SettingsService.liveTvDefaultFavorites, - SettingsService.matchRefreshRate, - SettingsService.matchDynamicRange, - SettingsService.appLocale, - SettingsService.autoPip, - SettingsService.maxVolume, - SettingsService.downmixCenterBoost, - SettingsService.subtitlePosition, - SettingsService.defaultPlaybackSpeed, - SettingsService.defaultBoxFitMode, - SettingsService.displaySwitchDelay, - SettingsService.themeMode, - SettingsService.videoPlayerNavigationEnabled, - SettingsService.enableCompanionRemoteServer, - SettingsService.startInFullscreen, - SettingsService.exitFullscreenOnPlayerClose, - SettingsService.bufferSize, - SettingsService.libraryDensity, - SettingsService.tvCornerSpotlightBackdrop, - SettingsService.episodePosterMode, - SettingsService.continueWatchingAction, - SettingsService.episodeAction, - SettingsService.keyboardHotkeys, - ]) - pref.key: _PreferencePolicy(_storageTypeFor(pref)), - for (final service in TrackerService.values) - for (final pref in >[ - SettingsService.trackerFilterModePref(service), - SettingsService.trackerFilterIdsPref(service), - ]) - pref.key: _PreferencePolicy(_storageTypeFor(pref)), + for (final pref in SettingsService.portablePrefs) pref.key: _PreferencePolicy(_storageTypeFor(pref)), }; static const Set _jsonStringListPreferenceKeys = {'hidden_libraries', 'library_order'}; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index 70206cb4..cb4f8e0d 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -15,6 +15,7 @@ import '../models/mpv_config_models.dart'; import '../models/external_player_models.dart'; import 'base_shared_preferences_service.dart'; import 'device_performance.dart'; +import 'shortcut_action.dart'; export 'base_shared_preferences_service.dart' show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref; import '../models/audio_quality_preset.dart'; @@ -279,33 +280,7 @@ List _decodeMpvPresets(dynamic raw) { } Map _defaultKeyboardHotkeys() => { - 'play_pause': const HotKey(key: PhysicalKeyboardKey.space), - 'volume_up': const HotKey(key: PhysicalKeyboardKey.arrowUp), - 'volume_down': const HotKey(key: PhysicalKeyboardKey.arrowDown), - 'seek_forward': const HotKey(key: PhysicalKeyboardKey.arrowRight), - 'seek_backward': const HotKey(key: PhysicalKeyboardKey.arrowLeft), - 'seek_forward_large': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), - 'seek_backward_large': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), - 'fullscreen_toggle': const HotKey(key: PhysicalKeyboardKey.keyF), - 'mute_toggle': const HotKey(key: PhysicalKeyboardKey.keyM), - 'subtitle_toggle': const HotKey(key: PhysicalKeyboardKey.keyS), - 'audio_track_next': const HotKey(key: PhysicalKeyboardKey.keyA), - 'subtitle_track_next': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), - 'chapter_next': const HotKey(key: PhysicalKeyboardKey.keyN), - 'chapter_previous': const HotKey(key: PhysicalKeyboardKey.keyP), - 'episode_next': const HotKey(key: PhysicalKeyboardKey.keyN, modifiers: [HotKeyModifier.shift]), - 'episode_previous': const HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.shift]), - 'speed_increase': const HotKey(key: PhysicalKeyboardKey.equal), - 'speed_decrease': const HotKey(key: PhysicalKeyboardKey.minus), - 'speed_reset': const HotKey(key: PhysicalKeyboardKey.keyR), - 'zoom_in': const HotKey(key: PhysicalKeyboardKey.equal, modifiers: [HotKeyModifier.alt]), - 'zoom_out': const HotKey(key: PhysicalKeyboardKey.minus, modifiers: [HotKeyModifier.alt]), - 'zoom_reset': const HotKey(key: PhysicalKeyboardKey.backspace, modifiers: [HotKeyModifier.alt]), - 'sub_seek_next': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), - 'sub_seek_prev': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), - 'shader_toggle': const HotKey(key: PhysicalKeyboardKey.keyG), - 'skip_marker': const HotKey(key: PhysicalKeyboardKey.enter), - 'screenshot': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.control]), + for (final action in ShortcutAction.values) action.id: action.defaultHotKey, }; Map _decodeKeyboardHotkeys(dynamic raw) { @@ -397,11 +372,7 @@ class SettingsService extends BaseSharedPreferencesService { static const showPerformanceOverlay = BoolPref('show_performance_overlay'); static const autoHidePerformanceOverlay = BoolPref('auto_hide_performance_overlay', defaultValue: true); static const enableDiscordRPC = BoolPref('enable_discord_rpc'); - static const enableTraktScrobble = BoolPref('enable_trakt_scrobble', defaultValue: true); static const enableTraktWatchedSync = BoolPref('enable_trakt_watched_sync', defaultValue: true); - static const enableMalScrobble = BoolPref('enable_mal_scrobble', defaultValue: true); - static const enableAnilistScrobble = BoolPref('enable_anilist_scrobble', defaultValue: true); - static const enableSimklScrobble = BoolPref('enable_simkl_scrobble', defaultValue: true); static const matchContentFrameRate = BoolPref('match_content_frame_rate'); static const tunneledPlayback = BoolPref('tunneled_playback', defaultValue: true); static const dvConversionMode = EnumPref( @@ -573,6 +544,11 @@ class SettingsService extends BaseSharedPreferencesService { /// keeps applying until the user chooses). static IntPref dvrTargetSectionPref(ServerId serverId, int type) => IntPref('dvr_target_section_${type}_$serverId'); + /// Per-service "scrobble to this tracker" toggle. Trakt's second toggle + /// ([enableTraktWatchedSync]) has no counterpart on the other services and + /// stays a standalone constant. + static BoolPref scrobblePref(TrackerService s) => BoolPref('enable_${s.name}_scrobble', defaultValue: true); + static EnumPref trackerFilterModePref(TrackerService s) => EnumPref( 'tracker_library_filter_mode_${s.name}', values: TrackerLibraryFilterMode.values, @@ -831,38 +807,34 @@ class SettingsService extends BaseSharedPreferencesService { return null; } - /// Settings that "Reset All Settings" actually resets. Mirrors the original - /// reset surface — notably excludes user-customized data (intro/credits regex - /// patterns) and opt-in toggles prior versions didn't reset, so behavior - /// stays identical for users. - static List> _resettablePrefs() => [ + /// Preference registry behind "Reset All Settings" and settings export. + /// Every participating preference is named exactly once, in the group that + /// states its policy; anything absent from all three groups takes part in + /// neither surface (credentials, runtime state, migration sentinels). + /// + /// Group one: reset *and* exported — the ordinary case. + static final List> _resetAndPortablePrefs = [ enableDebugLogging, - bufferSize, enableHardwareDecoding, enableHDR, preferredVideoCodec, preferredAudioCodec, viewMode, - showHeroSection, - continueWatchingAction, - episodeAction, seekTimeSmall, seekTimeLarge, + showHeroSection, sleepTimerDuration, audioSyncOffset, subtitleSyncOffset, subtitleSearchLanguage, volume, - maxVolume, subtitleFontSize, subtitleTextColor, subtitleBorderSize, subtitleBorderColor, subtitleBackgroundColor, subtitleBackgroundOpacity, - subtitlePosition, rememberTrackSelections, - customDownloadPathType, downloadOnWifiOnly, backgroundDownloadWarningAcknowledged, downloadIncludeSpecials, @@ -870,17 +842,13 @@ class SettingsService extends BaseSharedPreferencesService { showPerformanceOverlay, autoHidePerformanceOverlay, enableDiscordRPC, - enableTraktScrobble, enableTraktWatchedSync, - enableMalScrobble, - enableAnilistScrobble, - enableSimklScrobble, + // Scrobble toggle, one per tracker service. + for (final s in TrackerService.values) scrobblePref(s), matchContentFrameRate, tunneledPlayback, dvConversionMode, musicVolume, - defaultPlaybackSpeed, - defaultBoxFitMode, autoPlayNextEpisode, useExoPlayer, startupSection, @@ -900,20 +868,71 @@ class SettingsService extends BaseSharedPreferencesService { audioNormalization, audioDownmix, audioDownmixNormalize, + appLocale, + autoPip, + maxVolume, downmixCenterBoost, + subtitlePosition, + defaultPlaybackSpeed, + defaultBoxFitMode, themeMode, - keyboardHotkeys, + videoPlayerNavigationEnabled, + bufferSize, libraryDensity, tvCornerSpotlightBackdrop, episodePosterMode, + continueWatchingAction, + episodeAction, + keyboardHotkeys, + // Library filters, one pair per tracker service. + for (final s in TrackerService.values) ...[trackerFilterModePref(s), trackerFilterIdsPref(s)], + ]; + + /// Group two: exported but *not* reset. Mirrors the original reset surface — + /// user-customized data (intro/credits regex patterns) and opt-in toggles + /// prior versions didn't reset, so behavior stays identical for users. + static final List> _portableOnlyPrefs = [ + rewindOnResume, + tvFullCardLayout, + focusGlow, + useGlobalHubs, + showServerNameOnHubs, + groupLibrariesByServer, + rotationLocked, + subAssOverride, + subtitleRenderResolution, + subtitleBold, + subtitleItalic, + showChapterMarkersOnTimeline, + clickVideoTogglesPlayback, + autoSkipIntro, + autoSkipCredits, + forceSkipMarkerFallback, + autoSkipDelay, + introPattern, + creditsPattern, + autoRemoveWatchedDownloads, + defaultQualityPreset, + musicQualityPreset, + liveTvDefaultFavorites, + matchRefreshRate, + matchDynamicRange, + displaySwitchDelay, + enableCompanionRemoteServer, + startInFullscreen, + exitFullscreenOnPlayerClose, + ]; + + /// Group three: reset but *not* exported — device-local paths, endpoints and + /// per-device state plus user-authored player configuration, none of which + /// should travel between installations. + static final List> _resetOnlyPrefs = [ + customDownloadPathType, mediaVersionPreferences, localLastPlayedAt, - appLocale, customDownloadPath, - videoPlayerNavigationEnabled, mpvConfigText, mpvPresets, - autoPip, customShaderPresets, selectedExternalPlayer, customExternalPlayers, @@ -921,17 +940,19 @@ class SettingsService extends BaseSharedPreferencesService { companionRemoteLastHostAddress, ]; + /// Settings that "Reset All Settings" actually resets. + static List> get _resettablePrefs => [..._resetAndPortablePrefs, ..._resetOnlyPrefs]; + + /// Settings carried by settings export/import files. + static List> get portablePrefs => [..._resetAndPortablePrefs, ..._portableOnlyPrefs]; + Future resetAllSettings() async { - final resettable = _resettablePrefs(); await Future.wait([ - ...resettable.map((p) => prefs.remove(p.key)), + ..._resettablePrefs.map((p) => prefs.remove(p.key)), // Legacy migration sentinels — removed alongside the keys they guarded. prefs.remove(_legacyUseSeasonPosterKey), prefs.remove(_legacyMpvConfigEntriesKey), prefs.remove(_bufferSizeMigratedKey), - ...TrackerService.values.expand( - (s) => [prefs.remove(trackerFilterModePref(s).key), prefs.remove(trackerFilterIdsPref(s).key)], - ), ]); refreshListenables(); } diff --git a/lib/services/shader_asset_loader.dart b/lib/services/shader_asset_loader.dart index 56a27252..dcab1efe 100644 --- a/lib/services/shader_asset_loader.dart +++ b/lib/services/shader_asset_loader.dart @@ -174,12 +174,7 @@ class ShaderAssetLoader { /// Get the shader file path for an ArtCNN preset. /// Returns a list containing exactly one ArtCNN shader path. static Future> getArtCNNShaders(ArtCNNConfig config) async { - final variantId = switch (config.variant) { - ArtCNNVariant.neutral => 'neutral', - ArtCNNVariant.denoise => 'dn', - ArtCNNVariant.denoiseSharpen => 'ds', - }; - final shaderPath = await _extractShader(_artcnnShaders['${config.model.name}_$variantId']!); + final shaderPath = await _extractShader(_artcnnShaders['${config.model.name}_${config.variant.slug}']!); if (shaderPath == null) return []; return [shaderPath]; } @@ -187,85 +182,32 @@ class ShaderAssetLoader { /// Get the shader file paths for an Anime4K preset. /// Returns a list of shader paths in the correct order for MPV. static Future> getAnime4KShaders(Anime4KConfig config) async { + final (restoreVariant, upscaleVariant) = switch (config.quality) { + Anime4KQuality.fast => ('restore_m', 'upscale_m'), + Anime4KQuality.hq => ('restore_vl', 'upscale_vl'), + }; + + // All modes start with Clamp, then apply their own ordered chain. + final chain = [ + 'clamp', + ...switch (config.mode) { + Anime4KMode.modeA => [restoreVariant], + Anime4KMode.modeB => [restoreVariant, upscaleVariant, 'downscale'], + Anime4KMode.modeC => [upscaleVariant, 'downscale'], + Anime4KMode.modeAA => [restoreVariant, restoreVariant], + Anime4KMode.modeBB => [restoreVariant, restoreVariant, upscaleVariant, 'downscale'], + Anime4KMode.modeCA => [upscaleVariant, restoreVariant, 'downscale'], + }, + ]; + final shaders = []; - final quality = config.quality; - final mode = config.mode; - - String restoreVariant; - String upscaleVariant; - - switch (quality) { - case Anime4KQuality.fast: - restoreVariant = 'restore_m'; - upscaleVariant = 'upscale_m'; - break; - case Anime4KQuality.hq: - restoreVariant = 'restore_vl'; - upscaleVariant = 'upscale_vl'; - break; - } - - // Build shader chain based on mode - // All modes start with Clamp - final clampPath = await _extractShader(_anime4kShaders['clamp']!); - if (clampPath != null) shaders.add(clampPath); - - switch (mode) { - case Anime4KMode.modeA: - // A: Clamp + Restore - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - break; - - case Anime4KMode.modeB: - // B: Clamp + Restore + Upscale + Downscale - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeC: - // C: Clamp + Upscale + Downscale - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeAA: - // A+A: Clamp + Restore + Restore - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) { - shaders.add(restorePath); - shaders.add(restorePath); // Second restore pass - } - break; - - case Anime4KMode.modeBB: - // B+B: Clamp + Restore + Restore + Upscale + Downscale - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) { - shaders.add(restorePath); - shaders.add(restorePath); // Second restore pass - } - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; - - case Anime4KMode.modeCA: - // C+A: Clamp + Upscale + Restore + Downscale - final upscalePath = await _extractShader(_anime4kShaders[upscaleVariant]!); - if (upscalePath != null) shaders.add(upscalePath); - final restorePath = await _extractShader(_anime4kShaders[restoreVariant]!); - if (restorePath != null) shaders.add(restorePath); - final downscalePath = await _extractShader(_anime4kShaders['downscale']!); - if (downscalePath != null) shaders.add(downscalePath); - break; + final extracted = {}; + for (final key in chain) { + if (!extracted.containsKey(key)) { + extracted[key] = await _extractShader(_anime4kShaders[key]!); + } + final shaderPath = extracted[key]; + if (shaderPath != null) shaders.add(shaderPath); } return shaders; diff --git a/lib/services/shortcut_action.dart b/lib/services/shortcut_action.dart new file mode 100644 index 00000000..60ca5fdc --- /dev/null +++ b/lib/services/shortcut_action.dart @@ -0,0 +1,140 @@ +import 'package:flutter/services.dart'; + +import '../i18n/strings.g.dart'; +import '../models/hotkey_model.dart'; +import 'shader_service.dart'; + +/// Every keyboard shortcut the video player understands. +/// +/// One row per action carries everything about it except the behaviour: the +/// persisted [id], the [defaultHotKey] shipped with the app, the localized +/// [label], and the capability flags that gate dispatch. Adding a shortcut is +/// one entry here plus a case in +/// `KeyboardShortcutsService.handleVideoPlayerKeyEvent`, which the analyzer +/// demands because that switch is exhaustive over this enum. +/// +/// Declaration order is the order shortcuts are listed in settings, and [id] is +/// persisted in preferences — do not reorder or rename existing entries. +enum ShortcutAction { + playPause('play_pause', HotKey(key: PhysicalKeyboardKey.space), requiresPlayback: true), + volumeUp('volume_up', HotKey(key: PhysicalKeyboardKey.arrowUp)), + volumeDown('volume_down', HotKey(key: PhysicalKeyboardKey.arrowDown)), + seekForward('seek_forward', HotKey(key: PhysicalKeyboardKey.arrowRight), requiresPlayback: true), + seekBackward('seek_backward', HotKey(key: PhysicalKeyboardKey.arrowLeft), requiresPlayback: true), + seekForwardLarge( + 'seek_forward_large', + HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + seekBackwardLarge( + 'seek_backward_large', + HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + fullscreenToggle('fullscreen_toggle', HotKey(key: PhysicalKeyboardKey.keyF)), + muteToggle('mute_toggle', HotKey(key: PhysicalKeyboardKey.keyM)), + subtitleToggle('subtitle_toggle', HotKey(key: PhysicalKeyboardKey.keyS)), + audioTrackNext('audio_track_next', HotKey(key: PhysicalKeyboardKey.keyA), requiresPlayback: true), + subtitleTrackNext( + 'subtitle_track_next', + HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + chapterNext('chapter_next', HotKey(key: PhysicalKeyboardKey.keyN), requiresPlayback: true), + chapterPrevious('chapter_previous', HotKey(key: PhysicalKeyboardKey.keyP), requiresPlayback: true), + episodeNext( + 'episode_next', + HotKey(key: PhysicalKeyboardKey.keyN, modifiers: [HotKeyModifier.shift]), + requiresMediaNavigation: true, + ), + episodePrevious( + 'episode_previous', + HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.shift]), + requiresMediaNavigation: true, + ), + speedIncrease('speed_increase', HotKey(key: PhysicalKeyboardKey.equal), requiresPlayback: true), + speedDecrease('speed_decrease', HotKey(key: PhysicalKeyboardKey.minus), requiresPlayback: true), + speedReset('speed_reset', HotKey(key: PhysicalKeyboardKey.keyR), requiresPlayback: true), + zoomIn('zoom_in', HotKey(key: PhysicalKeyboardKey.equal, modifiers: [HotKeyModifier.alt]), repeatable: true), + zoomOut('zoom_out', HotKey(key: PhysicalKeyboardKey.minus, modifiers: [HotKeyModifier.alt]), repeatable: true), + zoomReset('zoom_reset', HotKey(key: PhysicalKeyboardKey.backspace, modifiers: [HotKeyModifier.alt])), + subSeekNext( + 'sub_seek_next', + HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), + requiresPlayback: true, + ), + subSeekPrev( + 'sub_seek_prev', + HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), + requiresPlayback: true, + ), + shaderToggle('shader_toggle', HotKey(key: PhysicalKeyboardKey.keyG), requiresShaderSupport: true), + skipMarker('skip_marker', HotKey(key: PhysicalKeyboardKey.enter), requiresPlayback: true), + screenshot('screenshot', HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.control])); + + const ShortcutAction( + this.id, + this.defaultHotKey, { + this.repeatable = false, + this.requiresPlayback = false, + this.requiresMediaNavigation = false, + this.requiresShaderSupport = false, + }); + + /// Stable key this action is stored under in preferences. + final String id; + + /// Shortcut used until the user assigns their own. + final HotKey defaultHotKey; + + /// Whether holding the key repeats the action instead of swallowing repeats. + final bool repeatable; + + /// Whether the action drives playback and needs playback authority. + final bool requiresPlayback; + + /// Whether the action switches media item and needs navigation authority. + final bool requiresMediaNavigation; + + /// Whether the action is only meaningful where shaders are available. + final bool requiresShaderSupport; + + static final Map _byId = {for (final action in values) action.id: action}; + + /// The action stored under [id], or null for an id this build does not know. + static ShortcutAction? fromId(String id) => _byId[id]; + + /// Whether this action can be used on the current platform. + bool get isSupported => !requiresShaderSupport || ShaderService.isPlatformSupported; + + /// Localized name shown in settings; seek labels embed the configured steps. + String label({required int seekTimeSmall, required int seekTimeLarge}) => switch (this) { + ShortcutAction.playPause => t.hotkeys.actions.playPause, + ShortcutAction.volumeUp => t.hotkeys.actions.volumeUp, + ShortcutAction.volumeDown => t.hotkeys.actions.volumeDown, + ShortcutAction.seekForward => t.hotkeys.actions.seekForward(seconds: seekTimeSmall), + ShortcutAction.seekBackward => t.hotkeys.actions.seekBackward(seconds: seekTimeSmall), + ShortcutAction.seekForwardLarge => t.hotkeys.actions.seekForward(seconds: seekTimeLarge), + ShortcutAction.seekBackwardLarge => t.hotkeys.actions.seekBackward(seconds: seekTimeLarge), + ShortcutAction.fullscreenToggle => t.hotkeys.actions.fullscreenToggle, + ShortcutAction.muteToggle => t.hotkeys.actions.muteToggle, + ShortcutAction.subtitleToggle => t.hotkeys.actions.subtitleToggle, + ShortcutAction.audioTrackNext => t.hotkeys.actions.audioTrackNext, + ShortcutAction.subtitleTrackNext => t.hotkeys.actions.subtitleTrackNext, + ShortcutAction.chapterNext => t.hotkeys.actions.chapterNext, + ShortcutAction.chapterPrevious => t.hotkeys.actions.chapterPrevious, + ShortcutAction.episodeNext => t.hotkeys.actions.episodeNext, + ShortcutAction.episodePrevious => t.hotkeys.actions.episodePrevious, + ShortcutAction.speedIncrease => t.hotkeys.actions.speedIncrease, + ShortcutAction.speedDecrease => t.hotkeys.actions.speedDecrease, + ShortcutAction.speedReset => t.hotkeys.actions.speedReset, + ShortcutAction.zoomIn => t.hotkeys.actions.zoomIn, + ShortcutAction.zoomOut => t.hotkeys.actions.zoomOut, + ShortcutAction.zoomReset => t.hotkeys.actions.zoomReset, + ShortcutAction.subSeekNext => t.hotkeys.actions.subSeekNext, + ShortcutAction.subSeekPrev => t.hotkeys.actions.subSeekPrev, + ShortcutAction.shaderToggle => t.hotkeys.actions.shaderToggle, + ShortcutAction.skipMarker => t.hotkeys.actions.skipMarker, + ShortcutAction.screenshot => t.hotkeys.actions.screenshot, + }; +} diff --git a/lib/services/sleep_timer_service.dart b/lib/services/sleep_timer_service.dart index 0ed53bd8..5d27d6f7 100644 --- a/lib/services/sleep_timer_service.dart +++ b/lib/services/sleep_timer_service.dart @@ -127,6 +127,7 @@ class SleepTimerService extends ChangeNotifier { } /// Execute the completion callback directly (fallback path) + @visibleForTesting void executeCompletion() { _executeCallback(); } diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ebf309e3..34f4cccf 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -113,20 +113,38 @@ class StorageService extends BaseSharedPreferencesService { String _userPrefixForProfileId(String profileId) => 'user_${userScopeForProfileId(profileId)}_'; - /// Read a string with user-scoped key, migrating from legacy key if needed. - String? _getScopedString(String baseKey) { - final scopedKey = '$_userPrefix$baseKey'; - final value = prefs.getString(scopedKey); - if (value != null || _userPrefix.isEmpty) return value; - // One-time migration from legacy global key - final legacy = prefs.getString(baseKey); + /// Read [baseKey] from the [prefix]-scoped slot, adopting the legacy + /// unscoped value once: the scoped key wins; otherwise the unscoped value is + /// copied into it and the unscoped key removed. [read]/[write] carry the + /// per-type codec. Adoption is skipped when [prefix] is empty (no scope to + /// migrate into) or [allowLegacyAdoption] is false (a scope other than the + /// active profile's, which must not steal legacy prefs). + T? _readScopedWithLegacyMigration( + String baseKey, { + required String prefix, + required T? Function(String key) read, + required void Function(String key, T value) write, + bool allowLegacyAdoption = true, + }) { + final scopedKey = '$prefix$baseKey'; + final value = read(scopedKey); + if (value != null || prefix.isEmpty || !allowLegacyAdoption) return value; + final legacy = read(baseKey); if (legacy != null) { - prefs.setString(scopedKey, legacy); + write(scopedKey, legacy); prefs.remove(baseKey); } return legacy; } + /// Read a string with user-scoped key, migrating from legacy key if needed. + String? _getScopedString(String baseKey) => _readScopedWithLegacyMigration( + baseKey, + prefix: _userPrefix, + read: prefs.getString, + write: prefs.setString, + ); + // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(ServerId serverId, String url) async { await prefs.setString('$_prefixServerEndpoint$serverId', url); @@ -217,19 +235,12 @@ class StorageService extends BaseSharedPreferencesService { await _setJsonMap('$_userPrefix$_prefixLibrarySort$sectionId', sortData); } - Map? getLibrarySort(String sectionId) { - final baseKey = '$_prefixLibrarySort$sectionId'; - final scopedKey = '$_userPrefix$baseKey'; - var result = _readJsonMap(scopedKey, legacyStringOk: true); - if (result != null || _userPrefix.isEmpty) return result; - // One-time migration from legacy key - result = _readJsonMap(baseKey, legacyStringOk: true); - if (result != null) { - _setJsonMap(scopedKey, result); - prefs.remove(baseKey); - } - return result; - } + Map? getLibrarySort(String sectionId) => _readScopedWithLegacyMigration>( + '$_prefixLibrarySort$sectionId', + prefix: _userPrefix, + read: (key) => _readJsonMap(key, legacyStringOk: true), + write: _setJsonMap, + ); // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') Future saveLibraryGrouping(String sectionId, String grouping) async { @@ -270,22 +281,18 @@ class StorageService extends BaseSharedPreferencesService { return _decodeStringSet(jsonString); } - Set getHiddenLibrariesForProfile(String profileId) { - final scopedKey = '${_userPrefixForProfileId(profileId)}$_keyHiddenLibraries'; - var jsonString = prefs.getString(scopedKey); - if (jsonString == null && getActiveProfileId() == profileId) { - // One-time migration from the legacy unscoped key, but only for the - // currently active profile. Otherwise merely opening another profile's - // scoped provider could steal legacy preferences into the wrong scope. - final legacy = prefs.getString(_keyHiddenLibraries); - if (legacy != null) { - prefs.setString(scopedKey, legacy); - prefs.remove(_keyHiddenLibraries); - jsonString = legacy; - } - } - return _decodeStringSet(jsonString); - } + Set getHiddenLibrariesForProfile(String profileId) => _decodeStringSet( + _readScopedWithLegacyMigration( + _keyHiddenLibraries, + prefix: _userPrefixForProfileId(profileId), + read: prefs.getString, + write: prefs.setString, + // Only the active profile may adopt the legacy unscoped value. Otherwise + // merely opening another profile's scoped provider could steal legacy + // preferences into the wrong scope. + allowLegacyAdoption: getActiveProfileId() == profileId, + ), + ); Set _decodeStringSet(String? jsonString) { if (jsonString == null) return {}; @@ -366,19 +373,12 @@ class StorageService extends BaseSharedPreferencesService { await _setStringList('$_userPrefix$_keyLibraryOrder', libraryKeys); } - List? getLibraryOrder() { - final baseKey = _keyLibraryOrder; - final scopedKey = '$_userPrefix$baseKey'; - final value = _getStringList(scopedKey); - if (value != null || _userPrefix.isEmpty) return value; - // One-time migration from legacy key - final legacy = _getStringList(baseKey); - if (legacy != null) { - _setStringList(scopedKey, legacy); - prefs.remove(baseKey); - } - return legacy; - } + List? getLibraryOrder() => _readScopedWithLegacyMigration>( + _keyLibraryOrder, + prefix: _userPrefix, + read: _getStringList, + write: _setStringList, + ); // Current User UUID — read once by [ConnectionBootstrap._promoteActiveProfileFromLegacy] // on the upgrade run, then cleared. Replaced by @@ -546,13 +546,18 @@ class StorageService extends BaseSharedPreferencesService { } } - Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) async { + /// Run [op] over every slot holding [baseKey]: the legacy unscoped key plus + /// each `user_{scope}_{baseKey}` variant. + Future _forEachScopedKey(String baseKey, Future Function(String key) op) async { final keys = prefs.keys .where((key) => key == baseKey || (key.startsWith('user_') && key.endsWith('_$baseKey'))) .toList(growable: false); - await Future.wait(keys.map((key) => _filterServerEntriesFromStringList(key, serverId))); + await Future.wait(keys.map(op)); } + Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) => + _forEachScopedKey(baseKey, (key) => _filterServerEntriesFromStringList(key, serverId)); + Future _clearSelectedLibraryForServer(String key, ServerId serverId) async { final selected = prefs.getString(key); if (selected != null && _belongsToServer(selected, serverId)) { @@ -560,15 +565,8 @@ class StorageService extends BaseSharedPreferencesService { } } - Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) async { - final keys = prefs.keys - .where( - (key) => - key == _keySelectedLibraryKey || (key.startsWith('user_') && key.endsWith('_$_keySelectedLibraryKey')), - ) - .toList(growable: false); - await Future.wait(keys.map((key) => _clearSelectedLibraryForServer(key, serverId))); - } + Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) => + _forEachScopedKey(_keySelectedLibraryKey, (key) => _clearSelectedLibraryForServer(key, serverId)); Future _clearKeysWithPrefixForServer(String keyPrefix, ServerId serverId) async { final serverPrefix = '$serverId:'; diff --git a/lib/services/sync_rule_executor.dart b/lib/services/sync_rule_executor.dart index 0815f316..6d88430d 100644 --- a/lib/services/sync_rule_executor.dart +++ b/lib/services/sync_rule_executor.dart @@ -3,7 +3,6 @@ import '../media/ids.dart'; import '../database/app_database.dart'; import '../media/media_item.dart'; -import '../media/media_kind.dart'; import '../media/media_server_client.dart'; import '../models/download_models.dart'; import '../utils/app_logger.dart'; @@ -462,12 +461,12 @@ class SyncRuleExecutor { // unwatched filter applies only to queueing; watched downloads still // belong to the list and must be removable with it. final membership = []; - await collectItemsForList(client, rootItems, unwatchedOnly: false, out: membership); + await collectListLeaves(client, rootItems, unwatchedOnly: false, out: membership); if (rule.downloadFilter != SyncRuleFilter.unwatched) { return (membership: membership, candidates: membership); } final serverUnwatched = []; - await collectItemsForList(client, rootItems, unwatchedOnly: true, out: serverUnwatched); + await collectListLeaves(client, rootItems, unwatchedOnly: true, out: serverUnwatched); final candidates = await _excludeLocallyWatched( episodes: serverUnwatched, serverId: ServerId(rule.serverId), @@ -496,43 +495,6 @@ class SyncRuleExecutor { libraryTitle: source?.libraryTitle, ); - /// Walks [items] and collects playable movie/episode/track entries into - /// [out]. Shows and seasons are expanded into their episodes; albums and - /// artists are expanded into their tracks (audio playlists/collections in - /// sync rules). Clips, nested collections/playlists, and unknown types are - /// skipped. [unwatchedOnly] applies the same played-state filter to every - /// kind — for tracks that means Plex/Jellyfin play counts. - Future collectItemsForList( - MediaServerClient client, - List items, { - required bool unwatchedOnly, - required List out, - }) async { - for (final item in items) { - switch (item.kind) { - case MediaKind.movie: - case MediaKind.episode: - case MediaKind.track: - if (unwatchedOnly && !item.isUnwatchedOrInProgress) break; - out.add(item); - case MediaKind.show: - case MediaKind.season: - await collectEpisodes(client, item.id, unwatchedOnly: unwatchedOnly, out: out, fallback: item); - case MediaKind.album: - case MediaKind.artist: - // One recursive-leaves call per container on both backends - // (Jellyfin retries tag-only artists by album-artist credit). - for (final track in await client.fetchPlayableDescendants(item.id)) { - if (unwatchedOnly && !track.isUnwatchedOrInProgress) continue; - out.add(track); - } - default: - // Skip clips, nested collections/playlists, unknown types. - break; - } - } - } - /// Drop items the user already marked watched locally — the server response /// still shows them as unwatched until the next bidirectional-sync push /// drains the OfflineWatchProgress queue, which can be many seconds away. diff --git a/lib/services/system_shelf_service.dart b/lib/services/system_shelf_service.dart index e21a7a6a..31c63ae1 100644 --- a/lib/services/system_shelf_service.dart +++ b/lib/services/system_shelf_service.dart @@ -110,19 +110,13 @@ class SystemShelfService { await _enqueueMutation(() async { final channel = _channel; if (channel == null) return; - try { - await channel.invokeMethod('clear', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - }); - } on MissingPluginException catch (e) { - appLogger.e('Failed to clear system shelf: native channel missing', error: e); - } on PlatformException catch (e) { - appLogger.e('Failed to clear system shelf: native platform error', error: e); - } catch (e) { - appLogger.e('Failed to clear system shelf', error: e); - } + await _invokeGuarded( + channel, + 'clear', + arguments: _envelope(profileId, generation), + label: 'Failed to clear system shelf', + severe: true, + ); }); } @@ -146,19 +140,45 @@ class SystemShelfService { return completer.future; } + /// Owner-scoped envelope every mutating native call carries. + static Map _envelope(String profileId, int generation, [Map? extra]) { + return {'schemaVersion': schemaVersion, 'ownerId': profileId, 'generation': generation, ...?extra}; + } + + /// Invokes [method] on [channel], logging channel failures under [label] (at + /// error level when [severe]) and returning null instead of throwing. + /// Errors that are not channel failures log [failureLabel] when given. + Future _invokeGuarded( + MethodChannel channel, + String method, { + Map? arguments, + required String label, + String? failureLabel, + bool severe = false, + }) async { + final log = severe ? appLogger.e : appLogger.w; + try { + return await channel.invokeMethod(method, arguments); + } on MissingPluginException catch (e) { + log('$label: native channel missing', error: e); + } on PlatformException catch (e) { + log('$label: native platform error', error: e); + } catch (e) { + log(failureLabel ?? label, error: e); + } + return null; + } + /// Get a pending deep link from cold start (consumed on first call). Future getInitialDeepLink() async { final channel = _channel; if (channel == null) return null; - try { - return await channel.invokeMethod('getInitialDeepLink'); - } on MissingPluginException catch (e) { - appLogger.w('System shelf initial deep link failed: native channel missing', error: e); - return null; - } catch (e) { - appLogger.w('Failed to get system shelf initial deep link', error: e); - return null; - } + return _invokeGuarded( + channel, + 'getInitialDeepLink', + label: 'System shelf initial deep link failed', + failureLabel: 'Failed to get system shelf initial deep link', + ); } /// Check whether the current platform has a launcher shelf integration. @@ -167,18 +187,13 @@ class SystemShelfService { if (override != null) return override(); final channel = _channel; if (channel == null) return false; - try { - return await channel.invokeMethod('isSupported') ?? false; - } on MissingPluginException catch (e) { - appLogger.w('System shelf unsupported: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.w('System shelf unsupported: native platform error', error: e); - return false; - } catch (e) { - appLogger.w('System shelf unsupported: native support check failed', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'isSupported', + label: 'System shelf unsupported', + failureLabel: 'System shelf unsupported: native support check failed', + ) ?? + false; } /// Sync Continue Watching items for the currently active [profileId]. @@ -204,24 +219,14 @@ class SystemShelfService { final result = await _enqueueMutation(() async { if (!_owns(profileId, generation)) return false; - try { - return await channel.invokeMethod('sync', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - 'items': items, - }) ?? - false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to sync system shelf: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to sync system shelf: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to sync system shelf', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'sync', + arguments: _envelope(profileId, generation, {'items': items}), + label: 'Failed to sync system shelf', + severe: true, + ) ?? + false; }); return result ?? false; } @@ -233,24 +238,14 @@ class SystemShelfService { final generation = _generation; final result = await _enqueueMutation(() async { if (!_owns(profileId, generation)) return false; - try { - return await channel.invokeMethod('remove', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - 'contentId': _buildContentId(serverId, ratingKey), - }) ?? - false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to remove system shelf item: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to remove system shelf item: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to remove system shelf item', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'remove', + arguments: _envelope(profileId, generation, {'contentId': _buildContentId(serverId, ratingKey)}), + label: 'Failed to remove system shelf item', + severe: true, + ) ?? + false; }); return result ?? false; } diff --git a/lib/services/track_manager.dart b/lib/services/track_manager.dart index 4a5fe1cc..b9ecb749 100644 --- a/lib/services/track_manager.dart +++ b/lib/services/track_manager.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; + import '../mpv/mpv.dart'; import '../media/media_item.dart'; @@ -82,6 +84,7 @@ class TrackManager { } /// Cached external subtitles for re-use after backend fallback. + @visibleForTesting List get lastExternalSubtitles => _lastExternalSubtitles; TrackManager({ diff --git a/lib/services/track_selection_service.dart b/lib/services/track_selection_service.dart index 99d11749..130bc0b6 100644 --- a/lib/services/track_selection_service.dart +++ b/lib/services/track_selection_service.dart @@ -229,6 +229,8 @@ SubtitleTrack? findMpvTrackForPlexSubtitle( // A container track has no stable native ID. Its source-container ordinal // is authoritative; a metadata-identical earlier track is not a match. + // Narrower than the guard in [findPlexTrackForMpvSubtitle]: a Plex stream + // carrying no container ordinal still falls back to metadata scoring. if (mpvTrack.isContainer && plexOrdinal >= 0 && !ordinalMatches) continue; final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); @@ -308,6 +310,8 @@ MediaSubtitleTrack? findPlexTrackForMpvSubtitle( final ordinalMatches = containerPlexTracks != null && mpvOrdinal >= 0 && containerPlexTracks.indexOf(plexTrack) == mpvOrdinal; + // The probe fixes isContainer here, so once a container ordinal list exists + // a container track matches at its own ordinal or not at all. if (mpvTrack.isContainer && containerPlexTracks != null && !ordinalMatches) continue; final score = _scoreSubtitleMatch(mpvTrack, plexTrack, ordinalMatches: ordinalMatches); @@ -347,6 +351,8 @@ AudioTrack? findMpvTrackForPlexAudio( AudioTrack? bestMatch; int bestScore = 0; + // Ordinal identity is cross-side: the probe's index in the Plex list against + // the candidate's index in the MPV list. final plexOrdinal = allPlexTracks?.indexOf(plexTrack) ?? -1; for (final mpvTrack in mpvTracks) { @@ -374,6 +380,8 @@ MediaAudioTrack? findPlexTrackForMpvAudio( MediaAudioTrack? bestMatch; int bestScore = 0; + // Same cross-side ordinal rule as [findMpvTrackForPlexAudio] with the two + // lists swapped; the score arguments stay MPV-first either way. final mpvOrdinal = allMpvTracks?.indexOf(mpvTrack) ?? -1; for (final plexTrack in plexTracks) { diff --git a/lib/services/trackers/anilist/anilist_client.dart b/lib/services/trackers/anilist/anilist_client.dart index 91247283..214dcccb 100644 --- a/lib/services/trackers/anilist/anilist_client.dart +++ b/lib/services/trackers/anilist/anilist_client.dart @@ -408,6 +408,8 @@ class AnilistClient implements DisposableTrackerClient { final res = await send(); + // Rate limits are typed here and in Trakt only; MAL and Simkl surface a 429 + // as a plain TrackerApiException. if (res.statusCode == 429) { throw TrackerRateLimitException( service: TrackerService.anilist, diff --git a/lib/services/trackers/anilist/anilist_tracker.dart b/lib/services/trackers/anilist/anilist_tracker.dart index d916fd16..eb4a2b52 100644 --- a/lib/services/trackers/anilist/anilist_tracker.dart +++ b/lib/services/trackers/anilist/anilist_tracker.dart @@ -3,7 +3,6 @@ import 'package:http/http.dart' as http; import '../../../models/trackers/anime_ids.dart'; import '../../../models/trackers/tracker_context.dart'; import '../../../utils/app_logger.dart'; -import '../../settings_service.dart'; import '../anime_list_tracker_base.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -25,18 +24,9 @@ class AnilistTracker extends TrackerBase with ClientBackedTracker @override TrackerService get service => TrackerService.anilist; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableAnilistScrobble); - @override String get logLabel => 'AniList'; - @override - String get idLogName => 'anilist'; - - @override - String get ratingUnavailableName => 'AniList'; - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/anime_episode_progress_resolver.dart b/lib/services/trackers/anime_episode_progress_resolver.dart index 691d3d86..2604cec0 100644 --- a/lib/services/trackers/anime_episode_progress_resolver.dart +++ b/lib/services/trackers/anime_episode_progress_resolver.dart @@ -3,6 +3,7 @@ import '../../media/media_kind.dart'; import '../../media/media_server_client.dart'; import '../../models/trackers/anime_lists_mapping.dart'; import '../../utils/app_logger.dart'; +import 'future_coalescer.dart'; enum AnimeProgressScope { show, season, mapped } @@ -30,7 +31,7 @@ abstract interface class AnimeEpisodeProgressLookup { class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { final MediaServerClient _client; - final Map?>> _seasonProgressLoads = {}; + final KeyedFutureCoalescer?> _seasonProgressLoads = KeyedFutureCoalescer(); AnimeEpisodeProgressResolver(this._client); @@ -61,7 +62,7 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { return includeCurrentEpisode ? ResolvedAnimeProgress(progress: animeMatch.anidbEpisode) : null; } - final progressBySeason = await _seasonProgressFor(showId); + final progressBySeason = await _seasonProgressLoads.run(showId, () => _loadSeasonProgress(showId)); if (progressBySeason == null) return null; final currentAlreadyWatched = (episode.viewCount ?? 0) > 0 || !includeCurrentEpisode; @@ -97,20 +98,6 @@ class AnimeEpisodeProgressResolver implements AnimeEpisodeProgressLookup { } } - Future?> _seasonProgressFor(String showId) async { - final existing = _seasonProgressLoads[showId]; - if (existing != null) return existing; - - late final Future?> loading; - loading = _loadSeasonProgress(showId).whenComplete(() { - if (identical(_seasonProgressLoads[showId], loading)) { - final _ = _seasonProgressLoads.remove(showId); - } - }); - _seasonProgressLoads[showId] = loading; - return loading; - } - ResolvedAnimeProgress? _showProgress(Map seasons, bool currentAlreadyWatched) { if (seasons.isEmpty) return null; var watched = 0; diff --git a/lib/services/trackers/anime_list_tracker_base.dart b/lib/services/trackers/anime_list_tracker_base.dart index 55a60d50..d0f62adf 100644 --- a/lib/services/trackers/anime_list_tracker_base.dart +++ b/lib/services/trackers/anime_list_tracker_base.dart @@ -1,19 +1,18 @@ import '../../models/trackers/anime_ids.dart'; import '../../models/trackers/tracker_context.dart'; import '../../utils/app_logger.dart'; +import 'future_coalescer.dart'; import 'tracker.dart'; import 'tracker_id_resolver.dart'; mixin AnimeListTrackerBase on TrackerBase, ClientBackedTracker implements TrackerRatingSource { - final Map> _episodeCountLoads = {}; + final KeyedFutureCache _episodeCountLoads = KeyedFutureCache(); @override bool get needsFribb => true; String get logLabel; - String get idLogName; - String get ratingUnavailableName; int? animeId(AnimeIds? anime); Future loadAnimeEpisodeCount(TClient client, int animeId); @@ -81,23 +80,15 @@ mixin AnimeListTrackerBase on TrackerBa (TClient, int) _ratingTarget(TrackerRatingContext ctx) { final activeClient = client; final id = animeId(ctx.ids.anime); - if (activeClient == null || id == null) throw TrackerRatingUnavailableException(ratingUnavailableName); + if (activeClient == null || id == null) throw TrackerRatingUnavailableException(logLabel); return (activeClient, id); } - Future _episodeCount(TClient activeClient, int id) { - final existing = _episodeCountLoads[id]; - if (existing != null) return existing; - - late final Future loading; - loading = loadAnimeEpisodeCount(activeClient, id).catchError((Object e) { - if (identical(_episodeCountLoads[id], loading)) { - final _ = _episodeCountLoads.remove(id); - } - appLogger.d('$logLabel: failed to fetch anime episode count ($idLogName=$id)', error: e); - return null; - }); - _episodeCountLoads[id] = loading; - return loading; - } + Future _episodeCount(TClient activeClient, int id) => _episodeCountLoads + .run( + id, + () => loadAnimeEpisodeCount(activeClient, id), + onError: (e) => appLogger.d('$logLabel: failed to fetch anime episode count ($name=$id)', error: e), + ) + .catchError((Object _) => null); } diff --git a/lib/services/trackers/anime_lists_mapping_store.dart b/lib/services/trackers/anime_lists_mapping_store.dart index cc8f216f..6b412a60 100644 --- a/lib/services/trackers/anime_lists_mapping_store.dart +++ b/lib/services/trackers/anime_lists_mapping_store.dart @@ -1,27 +1,23 @@ -import 'dart:async'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import 'package:xml/xml.dart'; import '../../models/trackers/anime_lists_mapping.dart'; -import '../../utils/abortable_http_request.dart'; -import '../../utils/app_logger.dart'; import '../../utils/json_utils.dart'; -import '../../utils/platform_http_client_stub.dart' - if (dart.library.io) '../../utils/platform_http_client_io.dart' - as platform; -import '../base_shared_preferences_service.dart'; +import 'etag_cached_remote_store.dart'; -class AnimeListsIndex { +class AnimeListsIndex implements RemoteIndex { final Map> byTvdb; final Map> byTmdbTv; const AnimeListsIndex({required this.byTvdb, required this.byTmdbTv}); + @override bool get isEmpty => byTvdb.isEmpty && byTmdbTv.isEmpty; + + @override + String get logSummary => '${byTvdb.length} tvdb entries'; } abstract interface class AnimeListsMappingLookup { @@ -32,88 +28,25 @@ abstract interface class AnimeListsMappingLookup { Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}); } -class AnimeListsMappingStore implements AnimeListsMappingLookup { - static const String _diskFileName = 'anime-list.xml'; - static const String _prefsEtagKey = 'anime_lists_etag'; - static const String _prefsLastCheckKey = 'anime_lists_last_check'; - static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml'; - - static const Duration _refreshInterval = Duration(days: 7); - static const Duration _requestTimeout = Duration(seconds: 60); - - AnimeListsMappingStore._(); - static final AnimeListsMappingStore instance = AnimeListsMappingStore._(); - - AnimeListsIndex? _index; - Future? _loading; - bool _refreshRunning = false; - - Future _ensureLoaded() async { - final existing = _index; - if (existing != null) return existing; - final loading = _loading; - if (loading != null) return loading; - - final fresh = _loadOrFetch(); - _loading = fresh; - try { - final idx = await fresh; - if (!idx.isEmpty) { - _index = idx; - unawaited(maybeRefresh()); - } - return idx; - } finally { - _loading = null; - } - } - - Future _loadOrFetch() async { - final path = await _diskPath(); - try { - return await compute(_readAndParseAnimeLists, path); - } on FileSystemException { - appLogger.d('Anime-Lists: no disk cache, downloading from jsDelivr'); - final raw = await _download(); - if (raw == null) return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}); - return await compute(parseAnimeListsIndex, raw); - } catch (e) { - appLogger.w('Anime-Lists: parse failed - deleting disk copy so next lookup re-downloads', error: e); - await _deleteDiskCopy(); - return const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}); - } - } - - Future _download() async { - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: const {'Accept': 'application/xml,text/xml'}, - timeout: _requestTimeout, - operation: 'Anime-Lists mapping download', +class AnimeListsMappingStore extends EtagCachedRemoteStore implements AnimeListsMappingLookup { + AnimeListsMappingStore._() + : super( + diskFileName: 'anime-list.xml', + prefsEtagKey: 'anime_lists_etag', + prefsLastCheckKey: 'anime_lists_last_check', + sourceUrl: 'https://cdn.jsdelivr.net/gh/Anime-Lists/anime-lists@master/anime-list.xml', + acceptHeader: 'application/xml,text/xml', + logLabel: 'Anime-Lists', + emptyIndex: const AnimeListsIndex(byTvdb: {}, byTmdbTv: {}), + parse: parseAnimeListsIndex, + readAndParse: _readAndParseAnimeLists, ); - if (res.statusCode != 200) { - appLogger.d('Anime-Lists: download returned HTTP ${res.statusCode}'); - return null; - } - await _writeDiskCopy(res.body, etag: res.headers['etag']); - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); - return res.body; - } catch (e) { - appLogger.w('Anime-Lists: download failed', error: e); - return null; - } finally { - client.close(); - } - } + + static final AnimeListsMappingStore instance = AnimeListsMappingStore._(); @override Future lookupEpisode({int? tvdbId, int? tmdbId, int? season, int? episodeNumber}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); return lookupAnimeListEpisodeInIndex( idx, tvdbId: tvdbId, @@ -125,7 +58,7 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup { @override Future> lookupAnimeIdsForSeason({int? tvdbId, int? tmdbId, required int season}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); if (tvdbId != null) { final ids = _seasonAnimeIds(idx.byTvdb[tvdbId], AnimeListProvider.tvdb, season); if (ids.isNotEmpty) return ids; @@ -138,7 +71,7 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup { @override Future> lookupAnimeIdsForShow({int? tvdbId, int? tmdbId}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); if (tvdbId != null) { final entries = idx.byTvdb[tvdbId]; if (entries != null && entries.isNotEmpty) return {for (final entry in entries) entry.anidbId}; @@ -149,79 +82,6 @@ class AnimeListsMappingStore implements AnimeListsMappingLookup { } return const {}; } - - Future maybeRefresh() async { - if (_refreshRunning) return; - if (_index == null) return; - _refreshRunning = true; - try { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0; - final now = DateTime.now().millisecondsSinceEpoch; - if (now - lastCheck < _refreshInterval.inMilliseconds) return; - - final etag = prefs.getString(_prefsEtagKey); - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: {'If-None-Match': ?etag, 'Accept': 'application/xml,text/xml'}, - timeout: _requestTimeout, - operation: 'Anime-Lists mapping refresh', - ); - await prefs.setInt(_prefsLastCheckKey, now); - - if (res.statusCode == 304) { - appLogger.d('Anime-Lists: mapping unchanged (304)'); - return; - } - if (res.statusCode != 200) { - appLogger.d('Anime-Lists: refresh returned HTTP ${res.statusCode}'); - return; - } - - await _writeDiskCopy(res.body, etag: res.headers['etag']); - final fresh = await compute(parseAnimeListsIndex, res.body); - _index = fresh; - appLogger.d('Anime-Lists: mapping refreshed (${fresh.byTvdb.length} tvdb entries)'); - } finally { - client.close(); - } - } catch (e) { - appLogger.d('Anime-Lists: refresh failed (non-fatal)', error: e); - } finally { - _refreshRunning = false; - } - } - - Future _writeDiskCopy(String body, {String? etag}) async { - await File(await _diskPath()).writeAsString(body, flush: true); - if (etag != null) { - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setString(_prefsEtagKey, etag); - } - } - - Future _deleteDiskCopy() async { - try { - await File(await _diskPath()).delete(); - } on FileSystemException { - // Already gone. - } - } - - Future _diskPath() async { - final dir = await getApplicationSupportDirectory(); - return p.join(dir.path, _diskFileName); - } - - @visibleForTesting - void resetForTesting() { - _index = null; - _loading = null; - } } @visibleForTesting diff --git a/lib/services/trackers/etag_cached_remote_store.dart b/lib/services/trackers/etag_cached_remote_store.dart new file mode 100644 index 00000000..10db84e2 --- /dev/null +++ b/lib/services/trackers/etag_cached_remote_store.dart @@ -0,0 +1,216 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../utils/abortable_http_request.dart'; +import '../../utils/app_logger.dart'; +import '../../utils/platform_http_client_stub.dart' + if (dart.library.io) '../../utils/platform_http_client_io.dart' + as platform; +import '../base_shared_preferences_service.dart'; + +/// An index parsed out of a cached remote file. +abstract interface class RemoteIndex { + bool get isEmpty; + + /// Reported after a successful refresh, e.g. `'1234 tvdb entries'`. + String get logSummary; +} + +/// Base for the mapping stores backed by a static file on jsDelivr. +/// +/// Owns the whole lifecycle: lazy download on first use, a disk copy in the +/// app-support directory, parsing in a background isolate, and a weekly +/// conditional-GET ([maybeRefresh], If-None-Match) to pick up upstream changes. +/// Subclasses supply the source, the cache keys and the two isolate entry +/// points, and build their lookups on top of [ensureLoaded]. +abstract class EtagCachedRemoteStore { + static const Duration _refreshInterval = Duration(days: 7); + static const Duration _requestTimeout = Duration(seconds: 60); + + final String diskFileName; + final String prefsEtagKey; + final String prefsLastCheckKey; + final String sourceUrl; + final String acceptHeader; + + /// Prefixes log lines and the abortable-request operation names. + final String logLabel; + + /// Returned when nothing could be loaded; never cached. + final T emptyIndex; + + /// Parses a raw body. Top-level so it can run in a `compute` isolate. + final T Function(String raw) parse; + + /// Reads the disk copy and parses it inside the isolate. Halves peak memory + /// vs. reading the string on the main isolate and shipping it across. + final T Function(String path) readAndParse; + + EtagCachedRemoteStore({ + required this.diskFileName, + required this.prefsEtagKey, + required this.prefsLastCheckKey, + required this.sourceUrl, + required this.acceptHeader, + required this.logLabel, + required this.emptyIndex, + required this.parse, + required this.readAndParse, + }); + + T? _index; + Future? _loading; + bool _refreshRunning = false; + + /// Lazily load, downloading on first use. Subsequent calls return the + /// cached index in O(1). Concurrent callers share the same Future. + /// Schedules a background refresh after the first successful load. + @protected + Future ensureLoaded() async { + final existing = _index; + if (existing != null) return existing; + final loading = _loading; + if (loading != null) return loading; + + final fresh = _loadOrFetch(); + _loading = fresh; + try { + final idx = await fresh; + // Don't cache an empty index (network failure, no disk copy) — let the + // next lookup retry so transient offline periods self-heal. + if (!idx.isEmpty) { + _index = idx; + unawaited(maybeRefresh()); + } + return idx; + } finally { + _loading = null; + } + } + + Future _loadOrFetch() async { + final path = await _diskPath(); + try { + return await compute(readAndParse, path); + } on FileSystemException { + appLogger.d('$logLabel: no disk cache, downloading from jsDelivr'); + final raw = await _download(); + if (raw == null) return emptyIndex; + return await compute(parse, raw); + } catch (e) { + appLogger.w('$logLabel: parse failed — deleting disk copy so next lookup re-downloads', error: e); + await _deleteDiskCopy(); + return emptyIndex; + } + } + + /// GET the mapping, save it to disk, and return the body. Returns `null` + /// on any failure (offline, 4xx/5xx, timeout). + Future _download() async { + final client = platform.createPlatformClient(); + try { + final res = await sendAbortableHttpRequest( + client, + 'GET', + Uri.parse(sourceUrl), + headers: {'Accept': acceptHeader}, + timeout: _requestTimeout, + operation: '$logLabel mapping download', + ); + if (res.statusCode != 200) { + appLogger.d('$logLabel: download returned HTTP ${res.statusCode}'); + return null; + } + await _writeDiskCopy(res.body, etag: res.headers['etag']); + // Seed the weekly throttle so a same-week relaunch skips the refresh. + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setInt(prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); + return res.body; + } catch (e) { + appLogger.w('$logLabel: download failed', error: e); + return null; + } finally { + client.close(); + } + } + + /// Conditional-GET the mapping if the last check was >[_refreshInterval] ago + /// and we already have an index loaded. No-op when nothing is loaded — the + /// first lookup handles the initial download. + Future maybeRefresh() async { + if (_refreshRunning) return; + if (_index == null) return; + _refreshRunning = true; + try { + final prefs = await BaseSharedPreferencesService.sharedCache(); + final lastCheck = prefs.getInt(prefsLastCheckKey) ?? 0; + final now = DateTime.now().millisecondsSinceEpoch; + if (now - lastCheck < _refreshInterval.inMilliseconds) return; + + final etag = prefs.getString(prefsEtagKey); + final client = platform.createPlatformClient(); + try { + final res = await sendAbortableHttpRequest( + client, + 'GET', + Uri.parse(sourceUrl), + headers: {'If-None-Match': ?etag, 'Accept': acceptHeader}, + timeout: _requestTimeout, + operation: '$logLabel mapping refresh', + ); + await prefs.setInt(prefsLastCheckKey, now); + + if (res.statusCode == 304) { + appLogger.d('$logLabel: mapping unchanged (304)'); + return; + } + if (res.statusCode != 200) { + appLogger.d('$logLabel: refresh returned HTTP ${res.statusCode}'); + return; + } + + await _writeDiskCopy(res.body, etag: res.headers['etag']); + final fresh = await compute(parse, res.body); + _index = fresh; + appLogger.d('$logLabel: mapping refreshed (${fresh.logSummary})'); + } finally { + client.close(); + } + } catch (e) { + appLogger.d('$logLabel: refresh failed (non-fatal)', error: e); + } finally { + _refreshRunning = false; + } + } + + Future _writeDiskCopy(String body, {String? etag}) async { + await File(await _diskPath()).writeAsString(body, flush: true); + if (etag != null) { + final prefs = await BaseSharedPreferencesService.sharedCache(); + await prefs.setString(prefsEtagKey, etag); + } + } + + Future _deleteDiskCopy() async { + try { + await File(await _diskPath()).delete(); + } on FileSystemException { + // Already gone. + } + } + + Future _diskPath() async { + final dir = await getApplicationSupportDirectory(); + return p.join(dir.path, diskFileName); + } + + @visibleForTesting + void resetForTesting() { + _index = null; + _loading = null; + } +} diff --git a/lib/services/trackers/fribb_mapping_store.dart b/lib/services/trackers/fribb_mapping_store.dart index 2e4a5917..5653e088 100644 --- a/lib/services/trackers/fribb_mapping_store.dart +++ b/lib/services/trackers/fribb_mapping_store.dart @@ -1,17 +1,10 @@ -import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; -import 'package:path/path.dart' as p; -import 'package:path_provider/path_provider.dart'; import '../../models/trackers/fribb_mapping_row.dart'; -import '../base_shared_preferences_service.dart'; -import '../../utils/abortable_http_request.dart'; import '../../utils/app_logger.dart'; -import '../../utils/platform_http_client_stub.dart' - if (dart.library.io) '../../utils/platform_http_client_io.dart' - as platform; +import 'etag_cached_remote_store.dart'; /// Indexed view of the Fribb mapping database, queried by external ID. /// @@ -19,7 +12,7 @@ import '../../utils/platform_http_client_stub.dart' /// A single tvdb_id may map to multiple rows (split-cour anime → one per /// season); callers that have a Plex season number should filter by /// [FribbMappingRow.tvdbSeason] or [FribbMappingRow.tmdbSeason]. -class FribbIndex { +class FribbIndex implements RemoteIndex { final Map> byTvdb; final Map> byTmdb; final Map> byImdb; @@ -30,7 +23,11 @@ class FribbIndex { const FribbIndex({required this.byTvdb, required this.byTmdb, required this.byImdb, this.byMal = const {}}); + @override bool get isEmpty => byTvdb.isEmpty && byTmdb.isEmpty && byImdb.isEmpty && byMal.isEmpty; + + @override + String get logSummary => '${byTvdb.length} tvdb entries'; } abstract interface class FribbMappingLookup { @@ -39,107 +36,31 @@ abstract interface class FribbMappingLookup { Future lookupByMal(int malId); } -/// Loads and refreshes the Fribb anime-lists mapping on demand. -/// -/// On first lookup the ~5 MB JSON is downloaded from jsDelivr and cached to -/// the app-support directory. Subsequent lookups read from the cache. Parsing -/// runs in a background isolate. [maybeRefresh] does a weekly conditional-GET -/// (If-None-Match) to pick up upstream changes. -class FribbMappingStore implements FribbMappingLookup { - static const String _diskFileName = 'anime-list-mini.json'; - static const String _prefsEtagKey = 'fribb_anime_list_etag'; - static const String _prefsLastCheckKey = 'fribb_anime_list_last_check'; - - /// jsDelivr (CDN-backed). `raw.githubusercontent.com` rate-limits - /// aggressively on shared IPs and returns 429 mid-refresh. - static const String _sourceUrl = 'https://cdn.jsdelivr.net/gh/Fribb/anime-lists@master/anime-list-mini.json'; - - static const Duration _refreshInterval = Duration(days: 7); - static const Duration _requestTimeout = Duration(seconds: 60); - - FribbMappingStore._(); - static final FribbMappingStore instance = FribbMappingStore._(); - - FribbIndex? _index; - Future? _loading; - bool _refreshRunning = false; - - /// Lazily load, downloading on first use. Subsequent calls return the - /// cached index in O(1). Concurrent callers share the same Future. - /// Schedules a background refresh after the first successful load. - Future _ensureLoaded() async { - final existing = _index; - if (existing != null) return existing; - final loading = _loading; - if (loading != null) return loading; - - final fresh = _loadOrFetch(); - _loading = fresh; - try { - final idx = await fresh; - // Don't cache an empty index (network failure, no disk copy) — let the - // next lookup retry so transient offline periods self-heal. - if (!idx.isEmpty) { - _index = idx; - unawaited(maybeRefresh()); - } - return idx; - } finally { - _loading = null; - } - } - - Future _loadOrFetch() async { - final path = await _diskPath(); - try { - return await compute(_readAndParse, path); - } on FileSystemException { - appLogger.d('Fribb: no disk cache, downloading from jsDelivr'); - final raw = await _download(); - if (raw == null) return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}); - return await compute(parseFribbIndex, raw); - } catch (e) { - appLogger.w('Fribb: parse failed — deleting disk copy so next lookup re-downloads', error: e); - await _deleteDiskCopy(); - return const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}); - } - } - - /// GET the mapping, save it to disk, and return the body. Returns `null` - /// on any failure (offline, 4xx/5xx, timeout). - Future _download() async { - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: const {'Accept': 'application/json'}, - timeout: _requestTimeout, - operation: 'Fribb mapping download', +/// Loads and refreshes the Fribb anime-lists mapping on demand — the ~5 MB +/// JSON, indexed by external ID. +class FribbMappingStore extends EtagCachedRemoteStore implements FribbMappingLookup { + FribbMappingStore._() + : super( + diskFileName: 'anime-list-mini.json', + prefsEtagKey: 'fribb_anime_list_etag', + prefsLastCheckKey: 'fribb_anime_list_last_check', + // jsDelivr (CDN-backed). `raw.githubusercontent.com` rate-limits + // aggressively on shared IPs and returns 429 mid-refresh. + sourceUrl: 'https://cdn.jsdelivr.net/gh/Fribb/anime-lists@master/anime-list-mini.json', + acceptHeader: 'application/json', + logLabel: 'Fribb', + emptyIndex: const FribbIndex(byTvdb: {}, byTmdb: {}, byImdb: {}), + parse: parseFribbIndex, + readAndParse: _readAndParse, ); - if (res.statusCode != 200) { - appLogger.d('Fribb: download returned HTTP ${res.statusCode}'); - return null; - } - await _writeDiskCopy(res.body, etag: res.headers['etag']); - // Seed the weekly throttle so a same-week relaunch skips the refresh. - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setInt(_prefsLastCheckKey, DateTime.now().millisecondsSinceEpoch); - return res.body; - } catch (e) { - appLogger.w('Fribb: download failed', error: e); - return null; - } finally { - client.close(); - } - } + + static final FribbMappingStore instance = FribbMappingStore._(); /// Look up rows by Plex external IDs. Returns the first non-empty candidate /// list in preference order: tvdb → tmdb → imdb. @override Future> lookup({int? tvdbId, int? tmdbId, String? imdbId}) async { - final idx = await _ensureLoaded(); + final idx = await ensureLoaded(); if (tvdbId != null) { final hits = idx.byTvdb[tvdbId]; if (hits != null && hits.isNotEmpty) return hits; @@ -156,87 +77,9 @@ class FribbMappingStore implements FribbMappingLookup { } @override - Future lookupByMal(int malId) async => (await _ensureLoaded()).byMal[malId]; - - /// Conditional-GET the mapping if the last check was >[_refreshInterval] ago - /// and we already have an index loaded. No-op when nothing is loaded — the - /// first lookup handles the initial download. - Future maybeRefresh() async { - if (_refreshRunning) return; - if (_index == null) return; - _refreshRunning = true; - try { - final prefs = await BaseSharedPreferencesService.sharedCache(); - final lastCheck = prefs.getInt(_prefsLastCheckKey) ?? 0; - final now = DateTime.now().millisecondsSinceEpoch; - if (now - lastCheck < _refreshInterval.inMilliseconds) return; - - final etag = prefs.getString(_prefsEtagKey); - final client = platform.createPlatformClient(); - try { - final res = await sendAbortableHttpRequest( - client, - 'GET', - Uri.parse(_sourceUrl), - headers: {'If-None-Match': ?etag, 'Accept': 'application/json'}, - timeout: _requestTimeout, - operation: 'Fribb mapping refresh', - ); - await prefs.setInt(_prefsLastCheckKey, now); - - if (res.statusCode == 304) { - appLogger.d('Fribb: mapping unchanged (304)'); - return; - } - if (res.statusCode != 200) { - appLogger.d('Fribb: refresh returned HTTP ${res.statusCode}'); - return; - } - - await _writeDiskCopy(res.body, etag: res.headers['etag']); - final fresh = await compute(parseFribbIndex, res.body); - _index = fresh; - appLogger.d('Fribb: mapping refreshed (${fresh.byTvdb.length} tvdb entries)'); - } finally { - client.close(); - } - } catch (e) { - appLogger.d('Fribb: refresh failed (non-fatal)', error: e); - } finally { - _refreshRunning = false; - } - } - - Future _writeDiskCopy(String body, {String? etag}) async { - await File(await _diskPath()).writeAsString(body, flush: true); - if (etag != null) { - final prefs = await BaseSharedPreferencesService.sharedCache(); - await prefs.setString(_prefsEtagKey, etag); - } - } - - Future _deleteDiskCopy() async { - try { - await File(await _diskPath()).delete(); - } on FileSystemException { - // Already gone. - } - } - - Future _diskPath() async { - final dir = await getApplicationSupportDirectory(); - return p.join(dir.path, _diskFileName); - } - - @visibleForTesting - void resetForTesting() { - _index = null; - _loading = null; - } + Future lookupByMal(int malId) async => (await ensureLoaded()).byMal[malId]; } -/// Read the JSON from disk and parse it inside the isolate. Halves peak -/// memory vs. reading the string on the main isolate and shipping it across. FribbIndex _readAndParse(String path) { final raw = File(path).readAsStringSync(); return parseFribbIndex(raw); diff --git a/lib/services/trackers/future_coalescer.dart b/lib/services/trackers/future_coalescer.dart index e2fbf83a..46bf3f45 100644 --- a/lib/services/trackers/future_coalescer.dart +++ b/lib/services/trackers/future_coalescer.dart @@ -38,4 +38,35 @@ class KeyedFutureCoalescer { _inFlight[key] = future; return future; } + + /// Detach every in-flight future — the keyed form of [FutureCoalescer.reset]. + void clear() { + _inFlight.clear(); + } +} + +/// Keyed cache of loads: like [KeyedFutureCoalescer], but a successful future +/// stays memoized instead of being dropped on completion, and only a failure +/// evicts the key so the next call retries. [onError] fires once per failed +/// load, before the error is rethrown to every caller. +class KeyedFutureCache { + final Map> _entries = {}; + + Future run(K key, Future Function() create, {void Function(Object error)? onError}) { + final existing = _entries[key]; + if (existing != null) return existing; + + late final Future future; + future = create().catchError((Object e) { + if (identical(_entries[key], future)) _entries.remove(key); + onError?.call(e); + throw e; + }); + _entries[key] = future; + return future; + } + + void clear() { + _entries.clear(); + } } diff --git a/lib/services/trackers/mal/mal_client.dart b/lib/services/trackers/mal/mal_client.dart index 9cd09e52..595f3024 100644 --- a/lib/services/trackers/mal/mal_client.dart +++ b/lib/services/trackers/mal/mal_client.dart @@ -193,6 +193,8 @@ class MalClient implements DisposableTrackerClient { try { await _refresh(); } catch (_) { + // Reported as an API 401, not as the TrackerAuthException Trakt + // propagates from the same path. throw const TrackerApiException(service: TrackerService.mal, statusCode: 401); } res = await _send(method, path, body: body, formBody: formBody); diff --git a/lib/services/trackers/mal/mal_tracker.dart b/lib/services/trackers/mal/mal_tracker.dart index 5eb4b128..9dd35f9e 100644 --- a/lib/services/trackers/mal/mal_tracker.dart +++ b/lib/services/trackers/mal/mal_tracker.dart @@ -2,7 +2,6 @@ import 'package:http/http.dart' as http; import '../../../models/trackers/anime_ids.dart'; import '../../../utils/app_logger.dart'; -import '../../settings_service.dart'; import '../anime_list_tracker_base.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -28,18 +27,9 @@ class MalTracker extends TrackerBase with ClientBackedTracker, AnimeL @override TrackerService get service => TrackerService.mal; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableMalScrobble); - @override String get logLabel => 'MAL'; - @override - String get idLogName => 'mal'; - - @override - String get ratingUnavailableName => 'MAL'; - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/simkl/simkl_client.dart b/lib/services/trackers/simkl/simkl_client.dart index dace4a59..99e2cfe7 100644 --- a/lib/services/trackers/simkl/simkl_client.dart +++ b/lib/services/trackers/simkl/simkl_client.dart @@ -154,6 +154,8 @@ class SimklClient implements DisposableTrackerClient { allowedMethods: const {'GET', 'POST'}, ); + // Only the authenticated host may invalidate: the data host is called + // without a token, so its 401s say nothing about the session. if (mainApiHost && response.statusCode == 401) { onSessionInvalidated(); throw const TrackerAuthException( diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index deec235f..44f13b28 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -5,7 +5,6 @@ import '../../../models/trackers/tracker_context.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/external_ids.dart'; import '../../../utils/json_utils.dart'; -import '../../settings_service.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; import '../tracker_id_resolver.dart'; @@ -34,9 +33,6 @@ class SimklTracker extends TrackerBase with ClientBackedTracker imp @override bool get needsFribb => false; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableSimklScrobble); - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index 4e4665a7..44416b92 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -54,16 +54,14 @@ class TrackerRatingUnavailableException implements Exception { String toString() => 'TrackerRatingUnavailableException($trackerName)'; } -/// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient], -/// [readEnabledSetting], and [markWatched]. +/// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient] +/// and [markWatched]. abstract class TrackerBase implements Tracker { bool _isInitialized = false; bool _isEnabled = false; bool get hasActiveClient; - bool readEnabledSetting(SettingsService settings); - @override bool get canScrobble => _isEnabled && hasActiveClient; @@ -71,7 +69,8 @@ abstract class TrackerBase implements Tracker { Future initialize() async { if (_isInitialized) return; _isInitialized = true; - _isEnabled = readEnabledSetting(await SettingsService.getInstance()); + final settings = await SettingsService.getInstance(); + _isEnabled = settings.read(SettingsService.scrobblePref(service)); } @override diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index a3ba1ef0..d7530ac0 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -118,23 +118,19 @@ class TrackerCoordinator { animeProgress: _debugAnimeProgress, ); - Future markWatched(MediaItem item, MediaServerClient client) async { + Future markWatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: true); + + Future markUnwatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: false); + + Future _markManual(MediaItem item, MediaServerClient client, {required bool watched}) async { try { - await _markWatched(item, client); + await _applyManualMark(item, client, watched: watched); } catch (e) { - appLogger.d('Trackers: manual markWatched failed for ${item.id}', error: e); + appLogger.d('Trackers: manual ${watched ? 'markWatched' : 'markUnwatched'} failed for ${item.id}', error: e); } } - Future markUnwatched(MediaItem item, MediaServerClient client) async { - try { - await _markUnwatched(item, client); - } catch (e) { - appLogger.d('Trackers: manual markUnwatched failed for ${item.id}', error: e); - } - } - - Future _markWatched(MediaItem item, MediaServerClient client) async { + Future _applyManualMark(MediaItem item, MediaServerClient client, {required bool watched}) async { final kind = item.kind; if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { return; @@ -146,38 +142,18 @@ class TrackerCoordinator { final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); if (kind == MediaKind.movie || kind == MediaKind.episode) { - await _markSingleWatched(item, resolver); + await (watched ? _markSingleWatched(item, resolver) : _markSingleUnwatched(item, resolver)); return; } final episodes = []; await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes'); + final expansion = watched ? 'expanded' : 'unwatched expanded'; + appLogger.d('Trackers: manual ${kind.name} ${item.id} $expansion to ${episodes.length} episodes'); - await _markContainerEpisodesWatched(episodes, resolver); - } - - Future _markUnwatched(MediaItem item, MediaServerClient client) async { - final kind = item.kind; - if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { - return; - } - - final libraryGlobalKey = item.libraryGlobalKey; - if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return; - - final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); - - if (kind == MediaKind.movie || kind == MediaKind.episode) { - await _markSingleUnwatched(item, resolver); - return; - } - - final episodes = []; - await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes'); - - await _markContainerEpisodesUnwatched(episodes, resolver); + await (watched + ? _markContainerEpisodesWatched(episodes, resolver) + : _markContainerEpisodesUnwatched(episodes, resolver)); } Future _markContainerEpisodesWatched(List episodes, TrackerIdResolver resolver) async { @@ -189,7 +165,7 @@ class TrackerCoordinator { if (ctx == null) continue; resolved++; - await _dispatchToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: true); final key = _animeGroupKey(ctx); if (key == null) continue; @@ -200,7 +176,7 @@ class TrackerCoordinator { for (final group in animeGroups.values) { final ctx = group.context; - if (ctx != null) await _dispatchToTrackers([MalTracker.instance, AnilistTracker.instance], ctx); + if (ctx != null) await _dispatch([MalTracker.instance, AnilistTracker.instance], ctx, watched: true); } appLogger.d('Trackers: manual container resolved ${animeGroups.length} anime entries'); } @@ -220,7 +196,7 @@ class TrackerCoordinator { if (ctx == null) continue; resolved++; - await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: false); final anime = ctx.anime; if (anime == null) continue; @@ -279,7 +255,7 @@ class TrackerCoordinator { appLogger.d('Trackers: no external IDs for manually watched ${item.id}'); return; } - await _dispatchMarkWatched(ctx); + await _dispatch(_trackers, ctx, watched: true); } Future _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver) async { @@ -289,9 +265,9 @@ class TrackerCoordinator { return; } if (ctx.isMovie) { - await _dispatchMarkUnwatched(ctx); + await _dispatch(_trackers, ctx, watched: false); } else { - await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: false); } } @@ -301,7 +277,7 @@ class TrackerCoordinator { final shouldMarkWatched = ctx != null && !_thresholdCrossed && _timeline.watchedThresholdReached; _reset(); if (ctx != null && shouldMarkWatched) { - await _dispatchMarkWatched(ctx); + await _dispatch(_trackers, ctx, watched: true); } } @@ -311,7 +287,7 @@ class TrackerCoordinator { if (ctx == null || _thresholdCrossed) return; if (!_timeline.watchedThresholdReached) return; _thresholdCrossed = true; - unawaited(_dispatchMarkWatched(ctx)); + unawaited(_dispatch(_trackers, ctx, watched: true)); } void updateDuration(Duration duration) { @@ -340,40 +316,17 @@ class TrackerCoordinator { _thresholdCrossed = false; } - Future _dispatchMarkWatched(TrackerContext ctx) async { - final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); - await _dispatchToTrackers(active, ctx); - } - - Future _dispatchMarkUnwatched(TrackerContext ctx) async { - final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); - await _dispatchUnwatchedToTrackers(active, ctx); - } - bool _isActive(Tracker tracker, String? libraryGlobalKey) => tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey); - Future _dispatchToTrackers(Iterable trackers, TrackerContext ctx) async { + Future _dispatch(Iterable trackers, TrackerContext ctx, {required bool watched}) async { final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); await Future.wait( active.map((t) async { try { - await t.markWatched(ctx); + await (watched ? t.markWatched(ctx) : t.markUnwatched(ctx)); } catch (e) { - appLogger.d('${t.name}: markWatched failed', error: e); - } - }), - ); - } - - Future _dispatchUnwatchedToTrackers(Iterable trackers, TrackerContext ctx) async { - final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); - await Future.wait( - active.map((t) async { - try { - await t.markUnwatched(ctx); - } catch (e) { - appLogger.d('${t.name}: markUnwatched failed', error: e); + appLogger.d('${t.name}: ${watched ? 'markWatched' : 'markUnwatched'} failed', error: e); } }), ); diff --git a/lib/services/trackers/tracker_http_client.dart b/lib/services/trackers/tracker_http_client.dart index 8a96c167..3c8b541b 100644 --- a/lib/services/trackers/tracker_http_client.dart +++ b/lib/services/trackers/tracker_http_client.dart @@ -9,6 +9,14 @@ import '../../utils/platform_http_client_stub.dart' as platform; import 'tracker_constants.dart'; +/// Transport shared by the tracker clients: builds, times and logs a request, +/// then hands back the raw response. +/// +/// Status handling stays with each client because the rules genuinely differ: +/// MAL and Simkl accept any 2xx, Trakt a per-call set (200/201/204, plus 409 +/// for scrobble), AniList only 200 (GraphQL errors ride a 200 body); and a 401 +/// means refresh-and-retry for Trakt/MAL but a terminal session for AniList +/// and Simkl. class TrackerHttpClient { static const Set allMethods = {'GET', 'POST', 'PATCH', 'PUT', 'DELETE'}; diff --git a/lib/services/trackers/tracker_id_resolver.dart b/lib/services/trackers/tracker_id_resolver.dart index c77d0158..4a271939 100644 --- a/lib/services/trackers/tracker_id_resolver.dart +++ b/lib/services/trackers/tracker_id_resolver.dart @@ -8,6 +8,7 @@ import '../../utils/external_ids.dart'; import 'anime_episode_progress_resolver.dart'; import 'anime_lists_mapping_store.dart'; import 'fribb_mapping_store.dart'; +import 'future_coalescer.dart'; /// Paired ID output: always-present Plex external IDs (tvdb/imdb/tmdb) plus /// optional Fribb-sourced anime IDs (mal/anilist/simkl). Simkl uses [external] @@ -77,7 +78,7 @@ class TrackerIdResolver { /// Null entries mean "the server had no IDs" — cached so scrubbing on an /// un-matched item doesn't re-hit the server every position update. final Map _cache = {}; - final Map> _externalIdLoads = {}; + final KeyedFutureCache _externalIdLoads = KeyedFutureCache(); TrackerIdResolver( MediaServerClient client, { @@ -97,19 +98,8 @@ class TrackerIdResolver { /// [MediaServerClient.fetchExternalIds] surface — Plex hits /// `/library/metadata/{id}?includeGuids=1`, Jellyfin reads the inline /// `ProviderIds` map. - Future _fetchExternalIds(String itemId) { - final existing = _externalIdLoads[itemId]; - if (existing != null) return existing; - late final Future loading; - loading = _client.fetchExternalIds(itemId).catchError((Object e) { - if (identical(_externalIdLoads[itemId], loading)) { - final _ = _externalIdLoads.remove(itemId); - } - throw e; - }); - _externalIdLoads[itemId] = loading; - return loading; - } + Future _fetchExternalIds(String itemId) => + _externalIdLoads.run(itemId, () => _client.fetchExternalIds(itemId)); /// Resolve IDs for a movie. Future resolveForMovie(String itemId) async { diff --git a/lib/services/trackers/tracker_session.dart b/lib/services/trackers/tracker_session.dart index 45c42114..e105e59e 100644 --- a/lib/services/trackers/tracker_session.dart +++ b/lib/services/trackers/tracker_session.dart @@ -3,7 +3,7 @@ import 'tracker_constants.dart'; import 'tracker_exceptions.dart'; import 'tracker_session_utils.dart'; -class TrackerSession with EncodedTrackerSession { +class TrackerSession { final String accessToken; final String? refreshToken; final int? expiresAt; @@ -43,7 +43,6 @@ class TrackerSession with EncodedTrackerSession { ); } - @override Map toJson() => { 'access_token': accessToken, 'refresh_token': refreshToken, @@ -53,6 +52,8 @@ class TrackerSession with EncodedTrackerSession { 'created_at': createdAt, }; + String encode() => encodeTrackerSessionJson(toJson()); + factory TrackerSession.fromJson(Map json, {TrackerService? service}) { final session = TrackerSession( accessToken: json['access_token'] as String, diff --git a/lib/services/trackers/tracker_session_utils.dart b/lib/services/trackers/tracker_session_utils.dart index 15ec06fc..b99ee847 100644 --- a/lib/services/trackers/tracker_session_utils.dart +++ b/lib/services/trackers/tracker_session_utils.dart @@ -9,12 +9,6 @@ bool isTrackerTokenExpired(int expiresAt, {int? nowSeconds}) => bool trackerTokenNeedsRefresh(int expiresAt, {int refreshWindowSeconds = 300, int? nowSeconds}) => (nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt - refreshWindowSeconds; -mixin EncodedTrackerSession { - Map toJson(); - - String encode() => encodeTrackerSessionJson(toJson()); -} - String encodeTrackerSessionJson(Map value) => convert.json.encode(value); T decodeTrackerSessionJson(String raw, T Function(Map json) fromJson) { diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index f686c1e9..e59040b4 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -70,10 +70,10 @@ class TraktClient implements DisposableTrackerClient { _request('POST', '/scrobble/stop', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses); Future addToHistory(TraktScrobbleRequest item, {String? watchedAt}) => - _request('POST', '/sync/history', body: item.toHistoryAddBody(watchedAt: watchedAt)); + _request('POST', '/sync/history', body: item.toHistoryBody(watchedAt: watchedAt)); Future removeFromHistory(TraktScrobbleRequest item) => - _request('POST', '/sync/history/remove', body: item.toHistoryRemoveBody()); + _request('POST', '/sync/history/remove', body: item.toHistoryBody()); Future addRatings(Map body) => _request('POST', '/sync/ratings', body: body, allowStatuses: const {200, 201}); @@ -307,6 +307,8 @@ class TraktClient implements DisposableTrackerClient { var res = await _send(method, path, body: body); if (res.statusCode == 401) { + // A failed refresh propagates its TrackerAuthException; MAL's equivalent + // path flattens the same failure into TrackerApiException(401). await refresh(); res = await _send(method, path, body: body); } diff --git a/lib/services/trakt/trakt_constants.dart b/lib/services/trakt/trakt_constants.dart index c758c62c..6afc0304 100644 --- a/lib/services/trakt/trakt_constants.dart +++ b/lib/services/trakt/trakt_constants.dart @@ -59,12 +59,6 @@ enum TraktMediaKind { movie, episode; - static TraktMediaKind? tryFromMediaKindId(String type) => switch (type) { - 'movie' => movie, - 'episode' => episode, - _ => null, - }; - static TraktMediaKind fromName(String name) => values.firstWhere((v) => v.name == name, orElse: () => throw ArgumentError('Unknown TraktMediaKind: $name')); } diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index 97fdf498..e407e8a0 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -59,7 +59,7 @@ class TraktScrobbleService implements TrackerRatingSource { if (_isInitialized) return; _isInitialized = true; final settings = await SettingsService.getInstance(); - _isEnabled = settings.read(SettingsService.enableTraktScrobble); + _isEnabled = settings.read(SettingsService.scrobblePref(TrackerService.trakt)); } Future setEnabled(bool enabled) async { diff --git a/lib/services/video_filter_manager.dart b/lib/services/video_filter_manager.dart index 613a520f..7e1b3f9a 100644 --- a/lib/services/video_filter_manager.dart +++ b/lib/services/video_filter_manager.dart @@ -103,10 +103,6 @@ class VideoFilterManager { return _zoomScale; } - double adjustZoom(double delta) => setZoomScale(_zoomScale + delta); - - double resetZoom() => setZoomScale(1.0); - /// Cycle through BoxFit modes: contain → cover → fill → contain (for button) void cycleBoxFitMode() { _boxFitMode = (_boxFitMode + 1) % 3; diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart deleted file mode 100644 index b9207559..00000000 --- a/lib/services/video_pip_manager.dart +++ /dev/null @@ -1,97 +0,0 @@ -import 'dart:io'; - -import 'package:flutter/material.dart'; -import '../mpv/mpv.dart'; -import '../services/pip_service.dart'; -import '../utils/app_logger.dart'; - -class VideoPIPManager { - final Player player; - Size? _playerSize; - - VideoPIPManager({required this.player, Size? initialPlayerSize}) : _playerSize = initialPlayerSize; - - Size? get playerSize => _playerSize; - - /// Callback to prepare video filter before entering PiP - VoidCallback? onBeforeEnterPip; - - /// Update player size for PiP aspect ratio calculation - void updatePlayerSize(Size size) { - _playerSize = size; - } - - ValueNotifier get isPipActive => PipService().isPipActive; - - /// Get current video dimensions (display or storage or fallback to viewport) - Future<(int? width, int? height)> _getVideoDimensions() async { - int? width; - int? height; - - try { - final dwidth = await player.getProperty('dwidth'); - final dheight = await player.getProperty('dheight'); - if (dwidth != null && dheight != null) { - width = int.tryParse(dwidth); - height = int.tryParse(dheight); - } - } catch (e) { - appLogger.d('VideoPipManager: dwidth/dheight unavailable', error: e); - } - - if (width == null || height == null) { - try { - final videoWidth = await player.getProperty('width'); - final videoHeight = await player.getProperty('height'); - if (videoWidth != null && videoHeight != null) { - width = int.tryParse(videoWidth); - height = int.tryParse(videoHeight); - } - } catch (e) { - appLogger.d('VideoPipManager: width/height unavailable', error: e); - } - } - - width ??= _playerSize?.width.toInt(); - height ??= _playerSize?.height.toInt(); - - return (width, height); - } - - Future<(bool success, String? error)> togglePIP() async { - final supported = await PipService.isSupported(); - if (!supported) return (false, 'PiP not supported on this device'); - - // If PiP is already active, exit it - if (isPipActive.value) { - await PipService.exit(); - return (true, null); - } - - // Reset video filter to contain mode before entering PiP. Android, iOS, - // and macOS all reuse the inline video surface/layer for PiP. - if (Platform.isAndroid || Platform.isIOS || Platform.isMacOS) { - onBeforeEnterPip?.call(); - // Wait a frame for the filter change to take effect - await Future.delayed(const Duration(milliseconds: 50)); - } - - final dims = await _getVideoDimensions(); - return await PipService.enter(width: dims.$1, height: dims.$2); - } - - Future updateAutoPipState({required bool isPlaying}) async { - if (!isPlaying) { - await PipService.setAutoPipReady(ready: false); - return; - } - - final dims = await _getVideoDimensions(); - await PipService.setAutoPipReady(ready: true, width: dims.$1, height: dims.$2); - } - - /// Disable auto-PiP (called on dispose or when leaving player) - Future disableAutoPip() async { - await PipService.setAutoPipReady(ready: false); - } -} diff --git a/lib/services/watch_state_resolver.dart b/lib/services/watch_state_resolver.dart index f2949d92..819d6ed4 100644 --- a/lib/services/watch_state_resolver.dart +++ b/lib/services/watch_state_resolver.dart @@ -1,7 +1,10 @@ +import 'package:flutter/foundation.dart'; + import '../database/app_database.dart'; import '../media/media_item.dart'; import '../utils/watch_state_notifier.dart'; +@immutable class WatchStateSnapshot { final bool? isWatched; final bool hasViewOffsetMs; @@ -21,6 +24,17 @@ class WatchStateSnapshot { } return updated; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WatchStateSnapshot && + other.isWatched == isWatched && + other.hasViewOffsetMs == hasViewOffsetMs && + other.viewOffsetMs == viewOffsetMs; + + @override + int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); } class WatchStateResolver { diff --git a/lib/theme/mono_tokens.dart b/lib/theme/mono_tokens.dart index 8b8d7422..481c3165 100644 --- a/lib/theme/mono_tokens.dart +++ b/lib/theme/mono_tokens.dart @@ -3,6 +3,17 @@ import 'package:flutter/material.dart'; MonoTokens tokens(BuildContext context) => Theme.of(context).extension()!; +/// M3E connected-group geometry for item [index] of a [count]-item group: +/// large radii on the group's outer corners, small radii between adjacent +/// items. Pair with `MonoTokens.groupGap` spacing for the hairline gaps. +BorderRadius groupItemRadii(BuildContext context, int index, int count) { + final t = tokens(context); + return BorderRadius.vertical( + top: Radius.circular(index == 0 ? t.radiusLg : t.radiusXs), + bottom: Radius.circular(index == count - 1 ? t.radiusLg : t.radiusXs), + ); +} + @immutable class MonoTokens extends ThemeExtension { /// Effectively-stadium radius for pill shapes; the renderer proportionally diff --git a/lib/utils/abortable_http_request.dart b/lib/utils/abortable_http_request.dart index fce70294..2dedb64e 100644 --- a/lib/utils/abortable_http_request.dart +++ b/lib/utils/abortable_http_request.dart @@ -14,6 +14,9 @@ Future sendAbortableHttpRequest( Future? abortTrigger, String? operation, }) { + // Deliberately not `AbortController`: that type lives with the media-server + // client and throws `MediaServerHttpException`, which the tracker/Seerr + // callers of this helper must stay independent of. final abort = Completer(); void abortRequest() { if (!abort.isCompleted) abort.complete(); diff --git a/lib/utils/android_exit_diagnostics.dart b/lib/utils/android_exit_diagnostics.dart index bff08dd8..c52f19e7 100644 --- a/lib/utils/android_exit_diagnostics.dart +++ b/lib/utils/android_exit_diagnostics.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'app_logger.dart'; +import 'device_channel.dart'; enum AndroidStartupPhase { nativeOnCreate('native_on_create'), @@ -34,7 +34,6 @@ enum AndroidUiState { /// Best-effort bridge for the newest Android 11+ historical process exit. abstract final class AndroidExitDiagnostics { - static const _channel = MethodChannel('com.plezy/device'); static const _allowedReasons = {'crash', 'native_crash', 'anr', 'low_memory', 'user_requested', 'other'}; static const _allowedAbis = {'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86', 'unknown'}; static const _allowedCodecContexts = { @@ -132,7 +131,7 @@ abstract final class AndroidExitDiagnostics { static Future _persistStartupPhase(String phase) async { try { - await _channel.invokeMethod('setStartupPhase', phase); + await deviceChannel.invokeMethod('setStartupPhase', phase); } catch (_) { // Native phase persistence is best-effort. } @@ -141,7 +140,7 @@ abstract final class AndroidExitDiagnostics { static Future markUiState(AndroidUiState state) async { if (!Platform.isAndroid) return; try { - await _channel.invokeMethod('setRuntimeUiState', state.id); + await deviceChannel.invokeMethod('setRuntimeUiState', state.id); } catch (_) { // Runtime diagnostics are best-effort and must never affect navigation. } @@ -155,7 +154,7 @@ abstract final class AndroidExitDiagnostics { static Future logPreviousExit() async { if (!Platform.isAndroid) return; try { - final raw = await _channel.invokeMapMethod('getPreviousExit'); + final raw = await deviceChannel.invokeMapMethod('getPreviousExit'); final report = _validate(raw); if (report == null) return; diff --git a/lib/utils/app_logger.dart b/lib/utils/app_logger.dart index 2a82fee9..c4ee9877 100644 --- a/lib/utils/app_logger.dart +++ b/lib/utils/app_logger.dart @@ -51,10 +51,6 @@ class MemoryLogOutput extends LogOutput { _currentSize = 0; } - static int getCurrentSize() => _currentSize; - - static double getCurrentSizeMB() => _currentSize / (1024 * 1024); - @override void output(OutputEvent event) { // Only print to console — storage is done in MemoryAwareLogPrinter.log() diff --git a/lib/utils/async_singleton.dart b/lib/utils/async_singleton.dart new file mode 100644 index 00000000..d3a68401 --- /dev/null +++ b/lib/utils/async_singleton.dart @@ -0,0 +1,62 @@ +/// Memoizes a `static Future getInstance()` singleton whose construction is +/// cheap but whose initialization is async. +/// +/// The instance is published *before* initialization runs, so sync accessors +/// (`isTVSync`, `isReduced`, ...) see it immediately. Concurrent callers await +/// the one in-flight initialization, and a failed initialization rolls the +/// instance back so the next call retries — the `identical` guards keep that +/// rollback safe once a later call has replaced the memoized state. +/// +/// The `debug*` members are test hooks; owners re-expose them behind their own +/// `@visibleForTesting` forwarders. +class AsyncSingleton { + T? _instance; + Future? _initialization; + + /// Awaited before each initialization run, to hold initialization open while + /// a test exercises concurrent callers. + Future? debugGate; + + /// The memoized instance, which may still be initializing. Null before the + /// first [getInstance] call and after a failed initialization. + T? get instance => _instance; + + /// Returns the memoized instance, building it with [create] and running + /// [initialize] on it the first time. + Future getInstance(T Function() create, Future Function(T instance) initialize) async { + final existing = _instance; + if (existing != null) { + final inFlight = _initialization; + if (inFlight != null) await inFlight; + return existing; + } + + final instance = create(); + _instance = instance; + final initialization = _initialize(instance, initialize); + _initialization = initialization; + try { + await initialization; + } catch (_) { + if (identical(_instance, instance)) _instance = null; + rethrow; + } finally { + if (identical(_initialization, initialization)) _initialization = null; + } + return instance; + } + + Future _initialize(T instance, Future Function(T instance) initialize) async { + final gate = debugGate; + if (gate != null) await gate; + await initialize(instance); + } + + /// Drops the memoized state and the gate, optionally seeding [instance] so + /// sync accessors can be exercised without initializing. + void debugReset({T? instance}) { + _instance = instance; + _initialization = null; + debugGate = null; + } +} diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index ff1b7d3a..d02b8744 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -17,7 +17,6 @@ class ContentTypes { static const Set musicTypes = {artist, album, track}; static const Set videoTypes = {movie, show, season, episode}; - static const Set playableTypes = {movie, episode, clip, track}; } class ContentTypeHelper { @@ -27,17 +26,6 @@ class ContentTypeHelper { static bool isVideoContent(String type) => ContentTypes.videoTypes.contains(type.toLowerCase()); - static bool isMusicLibrary(dynamic lib) { - if (lib == null) return false; - try { - // ignore: avoid_dynamic_calls — duck-typed across library shapes - final type = (lib as dynamic).kind?.id as String?; - return type?.toLowerCase() == ContentTypes.artist; - } catch (e) { - return false; - } - } - static IconData getLibraryIcon(String type) { switch (type.toLowerCase()) { case ContentTypes.movie: diff --git a/lib/utils/continuation_pagination_coordinator.dart b/lib/utils/continuation_pagination_coordinator.dart index b98ddae6..a56f22cc 100644 --- a/lib/utils/continuation_pagination_coordinator.dart +++ b/lib/utils/continuation_pagination_coordinator.dart @@ -37,14 +37,12 @@ class ContinuationPaginationCoordinator { bool _disposed = false; bool _isLoading = false; Object? _error; - StackTrace? _errorStackTrace; int? get nextStartIndex => _nextStartIndex; int? get totalCount => _totalCount; bool get hasMore => _nextStartIndex != null; bool get isLoading => _isLoading; Object? get error => _error; - StackTrace? get errorStackTrace => _errorStackTrace; /// Invalidates all prior work, runs [request], and reports whether its result /// still belongs to the current generation. @@ -65,7 +63,6 @@ class ContinuationPaginationCoordinator { _totalCount = totalCount; _nextStartIndex = startIndex < totalCount ? startIndex : null; _error = null; - _errorStackTrace = null; onStateChanged?.call(); } @@ -99,7 +96,6 @@ class ContinuationPaginationCoordinator { _inFlightGeneration = null; _isLoading = false; _error = null; - _errorStackTrace = null; } int _beginGeneration() { @@ -110,7 +106,6 @@ class ContinuationPaginationCoordinator { _inFlightGeneration = null; _isLoading = false; _error = null; - _errorStackTrace = null; if (!_disposed) onStateChanged?.call(); return _generation; } @@ -120,7 +115,6 @@ class ContinuationPaginationCoordinator { Future _loadRemaining(int generation) async { _isLoading = true; _error = null; - _errorStackTrace = null; onStateChanged?.call(); try { @@ -148,7 +142,6 @@ class ContinuationPaginationCoordinator { } catch (exception, stackTrace) { if (!_isCurrent(generation)) return ContinuationLoadStatus.stale; _error = exception; - _errorStackTrace = stackTrace; onError?.call(exception, stackTrace); return ContinuationLoadStatus.failed; } finally { diff --git a/lib/utils/deletion_notifier.dart b/lib/utils/deletion_notifier.dart index 61cf4dd1..49b01e23 100644 --- a/lib/utils/deletion_notifier.dart +++ b/lib/utils/deletion_notifier.dart @@ -4,6 +4,7 @@ import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; import 'hierarchical_event_mixin.dart'; +import 'media_event_keys.dart'; /// Event representing a media item deletion with parent chain for hierarchical invalidation class DeletionEvent with HierarchicalEventMixin { @@ -73,11 +74,8 @@ class DeletionNotifier extends BaseNotifier { } void notifyDeletedItem({required MediaItem item, bool isDownloadOnly = false}) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('DeletionNotifier: missing serverId for ${item.id}, skipping deletion event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'DeletionNotifier', event: 'deletion'); + if (serverId == null) return; notify( DeletionEvent( itemId: item.id, diff --git a/lib/utils/desktop_window_padding.dart b/lib/utils/desktop_window_padding.dart index 7c0f8792..a3596715 100644 --- a/lib/utils/desktop_window_padding.dart +++ b/lib/utils/desktop_window_padding.dart @@ -29,6 +29,9 @@ class DesktopWindowPadding { /// Right padding for mobile devices to prevent actions from being too close to edge static const double mobileRight = 6.0; + + /// Left padding for macOS reflecting the current fullscreen state + static double get macOSLeftCurrent => FullscreenStateManager().isFullscreen ? macOSLeftFullscreen : macOSLeft; } /// Helper class for adjusting app bar widgets to account for desktop window controls @@ -60,38 +63,18 @@ class DesktopAppBarHelper { } if (context != null && SideNavigationScope.isPresent(context)) { - if (includeGestureDetector) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: leading, - ); - } - return leading; + return includeGestureDetector ? wrapWithGestureDetector(leading, opaque: true) : leading; } return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft; - final paddedWidget = Padding( - padding: .only(left: leftPadding), + padding: .only(left: DesktopWindowPadding.macOSLeftCurrent), child: leading, ); - if (includeGestureDetector) { - return GestureDetector( - behavior: HitTestBehavior.opaque, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: paddedWidget, - ); - } - - return paddedWidget; + return includeGestureDetector ? wrapWithGestureDetector(paddedWidget, opaque: true) : paddedWidget; }, ); } @@ -102,12 +85,7 @@ class DesktopAppBarHelper { return flexibleSpace; } - return GestureDetector( - behavior: HitTestBehavior.translucent, - // ignore: no-empty-block - consumes gesture to prevent macOS window dragging - onPanDown: (_) {}, - child: flexibleSpace, - ); + return wrapWithGestureDetector(flexibleSpace); } /// Calculates the leading width for SliverAppBar to account for macOS traffic lights @@ -121,9 +99,7 @@ class DesktopAppBarHelper { return null; } - final isFullscreen = FullscreenStateManager().isFullscreen; - final leftPadding = isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft; - return leftPadding + kToolbarHeight; + return DesktopWindowPadding.macOSLeftCurrent + kToolbarHeight; } /// Wraps a widget with GestureDetector on macOS to prevent window dragging @@ -175,10 +151,8 @@ class DesktopTitleBarPadding extends StatelessWidget { return ListenableBuilder( listenable: FullscreenStateManager(), builder: (context, _) { - final isFullscreen = FullscreenStateManager().isFullscreen; // In fullscreen, use minimal padding since traffic lights auto-hide - final left = - leftPadding ?? (isFullscreen ? DesktopWindowPadding.macOSLeftFullscreen : DesktopWindowPadding.macOSLeft); + final left = leftPadding ?? DesktopWindowPadding.macOSLeftCurrent; final right = rightPadding ?? 0.0; if (left == 0.0 && right == 0.0) { diff --git a/lib/utils/device_channel.dart b/lib/utils/device_channel.dart new file mode 100644 index 00000000..056884fa --- /dev/null +++ b/lib/utils/device_channel.dart @@ -0,0 +1,5 @@ +import 'package:flutter/services.dart'; + +/// Native device bridge (TV detection, device name, performance signals, +/// process-exit diagnostics). Implemented per platform under `com.plezy/device`. +const MethodChannel deviceChannel = MethodChannel('com.plezy/device'); diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 1974b75d..aff85f82 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -3,6 +3,7 @@ import '../media/ids.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; +import '../focus/focusable_action_bar.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -19,6 +20,7 @@ import 'app_logger.dart'; import 'content_utils.dart'; import 'dialogs.dart'; import 'download_version_utils.dart'; +import 'platform_detector.dart'; import 'snackbar_helper.dart'; @visibleForTesting @@ -159,14 +161,7 @@ Future showDownloadOptionsAndQueue( } if (filter == DownloadFilter.unwatched && kind == MediaKind.show && context.mounted) { - final syncChoice = await showOptionPickerDialog<_SyncChoice>( - context, - title: t.downloads.downloadNow, - options: [ - (icon: Symbols.download_rounded, label: t.downloads.downloadOnce, value: _SyncChoice.downloadOnce), - (icon: Symbols.sync_rounded, label: t.downloads.keepSynced, value: _SyncChoice.keepSynced), - ], - ); + final syncChoice = await _showSyncChoiceDialog(context); if (syncChoice == null || !context.mounted) return null; keepSynced = syncChoice == _SyncChoice.keepSynced; } @@ -239,22 +234,12 @@ Future showListDownloadOptionsAndQueue( final selectedFilter = await showOptionPickerDialog( context, title: t.downloads.downloadNow, - options: [ - (icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: DownloadFilter.all), - (icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: DownloadFilter.unwatched), - ], + options: _filterOptions(DownloadFilter.all, DownloadFilter.unwatched), ); if (selectedFilter == null || !context.mounted) return null; - final syncChoice = await showOptionPickerDialog<_SyncChoice>( - context, - title: t.downloads.downloadNow, - options: [ - (icon: Symbols.download_rounded, label: t.downloads.downloadOnce, value: _SyncChoice.downloadOnce), - (icon: Symbols.sync_rounded, label: t.downloads.keepSynced, value: _SyncChoice.keepSynced), - ], - ); + final syncChoice = await _showSyncChoiceDialog(context); if (syncChoice == null || !context.mounted) return null; final serverId = rootMetadata.serverId ?? client.serverId; @@ -294,36 +279,21 @@ Future showListDownloadOptionsAndQueue( ); } -/// Shows the shared list-download dialog for a playlist. -Future showPlaylistDownloadOptionsAndQueue( - BuildContext context, { - required MediaItem playlistMetadata, - required List items, - required MediaServerClient client, - required DownloadProvider downloadProvider, -}) => showListDownloadOptionsAndQueue( - context, - rootMetadata: playlistMetadata, - targetType: ContentTypes.playlist, - items: items, - client: client, - downloadProvider: downloadProvider, -); +/// The all/unwatched option rows, shared by the pickers that differ only in +/// how they spell those two values. +List<({IconData? icon, String label, T value})> _filterOptions(T all, T unwatched) => [ + (icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: all), + (icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: unwatched), +]; -/// Shows the shared list-download dialog for a collection. -Future showCollectionDownloadOptionsAndQueue( - BuildContext context, { - required MediaItem collectionMetadata, - required List items, - required MediaServerClient client, - required DownloadProvider downloadProvider, -}) => showListDownloadOptionsAndQueue( +/// Asks whether to download once or keep the target synced. +Future<_SyncChoice?> _showSyncChoiceDialog(BuildContext context) => showOptionPickerDialog<_SyncChoice>( context, - rootMetadata: collectionMetadata, - targetType: ContentTypes.collection, - items: items, - client: client, - downloadProvider: downloadProvider, + title: t.downloads.downloadNow, + options: [ + (icon: Symbols.download_rounded, label: t.downloads.downloadOnce, value: _SyncChoice.downloadOnce), + (icon: Symbols.sync_rounded, label: t.downloads.keepSynced, value: _SyncChoice.keepSynced), + ], ); Future _showEpisodeCountDialog( @@ -390,10 +360,7 @@ Future editSyncRuleFilter( final selected = await showOptionPickerDialog( context, title: t.downloads.editSyncFilter, - options: [ - (icon: Symbols.download_rounded, label: t.downloads.allEpisodes, value: SyncRuleFilter.all), - (icon: Symbols.visibility_off_rounded, label: t.downloads.unwatchedOnly, value: SyncRuleFilter.unwatched), - ], + options: _filterOptions(SyncRuleFilter.all, SyncRuleFilter.unwatched), ); if (selected == null || selected == currentFilter || !context.mounted) return false; @@ -557,3 +524,44 @@ Future removeSyncRuleAndSnack( showSuccessSnackBar(context, syncRuleRemovalMessage(removed)); } } + +/// The download / manage-sync-rule app-bar pair shared by the collection and +/// playlist detail screens: one entry that downloads (or edits the existing +/// rule) and, when a rule exists, one that removes it. Both are hidden on +/// Apple TV, which has no downloads UI. +/// +/// [hasRule] stays caller-computed so each screen keeps its own +/// `context.select` short-circuit, and [showDownload] carries the screen's +/// own visibility predicate for the first entry. +List buildSyncRuleActions( + BuildContext context, { + required String ruleKey, + required String displayTitle, + required bool hasRule, + required bool showDownload, + required VoidCallback onDownload, +}) { + if (PlatformDetector.isAppleTV()) return const []; + return [ + if (showDownload) + FocusableAction( + icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, + tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, + onPressed: hasRule + ? () => manageSyncRule(context, downloadProvider: context.read(), globalKey: ruleKey) + : onDownload, + iconColor: hasRule ? Colors.teal : null, + ), + if (hasRule) + FocusableAction( + icon: Symbols.sync_disabled_rounded, + tooltip: t.downloads.removeSyncRule, + onPressed: () => removeSyncRuleAndSnack( + context, + downloadProvider: context.read(), + globalKey: ruleKey, + displayTitle: displayTitle, + ), + ), + ]; +} diff --git a/lib/utils/hierarchical_event_mixin.dart b/lib/utils/hierarchical_event_mixin.dart index 3d3a47f9..d2bc0b85 100644 --- a/lib/utils/hierarchical_event_mixin.dart +++ b/lib/utils/hierarchical_event_mixin.dart @@ -23,10 +23,6 @@ mixin HierarchicalEventMixin { /// Check if this event affects a specific item by id. bool affectsItem(String itemId) => this.itemId == itemId || parentChain.contains(itemId); - /// Check if this event affects a specific globalKey. - bool affectsGlobalKey(String globalKey) => - this.globalKey == globalKey || parentChain.any((pk) => buildGlobalKey(serverId, pk) == globalKey); - /// Check if this event affects any item in a collection. bool affectsAnyOf(Iterable itemIds) { if (itemIds.contains(itemId)) return true; diff --git a/lib/utils/hub_icons.dart b/lib/utils/hub_icons.dart new file mode 100644 index 00000000..b79b1d40 --- /dev/null +++ b/lib/utils/hub_icons.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../media/media_hub.dart'; + +/// Leading icon for a hub row, shared by every surface that renders hubs from +/// the same backend rows (Discover and a library's Recommended tab). +/// +/// Continue Watching is matched on the hub key first so synthesized rows and +/// section-specific `*.inprogress.*` hubs are covered, then on title for +/// backends whose resume row is only recognizable by name (Plex "On Deck"). +/// Everything else is keyword-matched on the title; the first match wins, so +/// the more specific keywords are checked before the broader ones. +IconData hubIconFor(MediaHub hub) { + final title = hub.title.toLowerCase(); + + if (hub.isContinueWatchingHub || title.contains('continue watching') || title.contains('on deck')) { + return Symbols.play_circle_rounded; + } + for (final (keywords, icon) in _titleKeywordIcons) { + if (keywords.any(title.contains)) return icon; + } + return _defaultHubIcon; +} + +const _defaultHubIcon = Symbols.auto_awesome_rounded; + +/// Title keywords in match order — see [hubIconFor]. +const _titleKeywordIcons = <(List, IconData)>[ + // Trending/Popular + (['trending'], Symbols.trending_up_rounded), + (['popular', 'imdb'], Symbols.whatshot_rounded), + // Seasonal/Time-based + (['seasonal'], Symbols.calendar_month_rounded), + (['newly', 'new release'], Symbols.new_releases_rounded), + (['recently released', 'recent'], Symbols.schedule_rounded), + // Top/Rated + (['top rated', 'highest rated'], Symbols.star_rounded), + (['top '], Symbols.military_tech_rounded), + // Genre-specific + (['thriller'], Symbols.warning_amber_rounded), + (['comedy', 'comedier'], Symbols.mood_rounded), + (['action'], Symbols.flash_on_rounded), + (['drama'], Symbols.theater_comedy_rounded), + (['fantasy'], Symbols.auto_fix_high_rounded), + (['science', 'sci-fi'], Symbols.rocket_launch_rounded), + (['horror', 'skräck'], Symbols.nights_stay_rounded), + (['romance', 'romantic'], Symbols.favorite_border_rounded), + (['adventure', 'äventyr'], Symbols.explore_rounded), + // Watchlist/Playlists + (['playlist', 'watchlist'], Symbols.playlist_play_rounded), + (['unwatched', 'unplayed'], Symbols.visibility_off_rounded), + (['watched', 'played'], Symbols.visibility_rounded), + // Network/Studio + (['network', 'more from'], Symbols.tv_rounded), + // Actor/Director + (['actor', 'director'], Symbols.person_rounded), + // Decades (80s, 90s, etc.) + (['80', '90', '00'], Symbols.history_rounded), + // Rediscover/Start Watching + (['rediscover', 'start watching'], Symbols.play_arrow_rounded), + // Broad library-hub keywords, last so the specific rows above keep their icons. + (['rated'], Symbols.star_rounded), + (['recommended'], Symbols.thumb_up_rounded), + (['genre'], Symbols.category_rounded), +]; diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index ebfc3ba3..a3fef3d2 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -18,12 +18,8 @@ class ScreenBreakpoints { static bool isTablet(double width) => width >= mobile && width < desktop; - static bool isWideTablet(double width) => width >= wideTablet && width < desktop; - static bool isDesktop(double width) => width >= desktop && width < largeDesktop; - static bool isLargeDesktop(double width) => width >= largeDesktop; - static bool isDesktopOrLarger(double width) => width >= desktop; static bool isWideTabletOrLarger(double width) => width >= wideTablet; @@ -31,7 +27,6 @@ class ScreenBreakpoints { /// Animation and notification durations. class AppDurations { - static const Duration animFast = Duration(milliseconds: 200); static const Duration animMedium = Duration(milliseconds: 300); static const Duration animSlow = Duration(milliseconds: 500); static const Duration snackBarDefault = Duration(seconds: 3); diff --git a/lib/utils/live_tv_grouping.dart b/lib/utils/live_tv_grouping.dart index 38d61801..6866ac8d 100644 --- a/lib/utils/live_tv_grouping.dart +++ b/lib/utils/live_tv_grouping.dart @@ -45,15 +45,15 @@ List groupLiveTvChannelsBySource(List channel } String liveTvChannelSourceKey(LiveTvChannel channel) { - final serverId = _nonEmpty(channel.serverId) ?? ''; - final providerSource = _nonEmpty(channel.favoriteSource) ?? _nonEmpty(channel.lineup) ?? ''; - final dvrSource = _nonEmpty(channel.liveDvrKey) ?? ''; + final serverId = liveTvNonEmpty(channel.serverId) ?? ''; + final providerSource = liveTvNonEmpty(channel.favoriteSource) ?? liveTvNonEmpty(channel.lineup) ?? ''; + final dvrSource = liveTvNonEmpty(channel.liveDvrKey) ?? ''; return '$serverId\u0000$providerSource\u0000$dvrSource'; } String liveTvChannelSourceLabel(LiveTvChannel channel) { - final serverLabel = _nonEmpty(channel.serverName) ?? _nonEmpty(channel.serverId) ?? 'Live TV'; - final sourceTitle = _nonEmpty(channel.liveTvSourceTitle); + final serverLabel = liveTvNonEmpty(channel.serverName) ?? liveTvNonEmpty(channel.serverId) ?? 'Live TV'; + final sourceTitle = liveTvNonEmpty(channel.liveTvSourceTitle); if (sourceTitle == null || sourceTitle == serverLabel) return serverLabel; return '$serverLabel - $sourceTitle'; } @@ -62,8 +62,8 @@ String _deduplicatedLabel(LiveTvChannelGroup group) { if (group.channels.isEmpty) return group.label; final first = group.channels.first; final suffixes = [ - _nonEmpty(first.liveTvSourceTitle), - _nonEmpty(first.liveDvrKey), + liveTvNonEmpty(first.liveTvSourceTitle), + liveTvNonEmpty(first.liveDvrKey), liveTvProviderIdentifierForChannel(first), ]; String? suffix; @@ -76,8 +76,3 @@ String _deduplicatedLabel(LiveTvChannelGroup group) { if (suffix == null || group.label.contains(suffix)) return group.label; return '${group.label} - $suffix'; } - -String? _nonEmpty(String? value) { - final trimmed = value?.trim(); - return trimmed == null || trimmed.isEmpty ? null : trimmed; -} diff --git a/lib/utils/live_tv_matching.dart b/lib/utils/live_tv_matching.dart index 69f4dc8f..e1d48c03 100644 --- a/lib/utils/live_tv_matching.dart +++ b/lib/utils/live_tv_matching.dart @@ -2,14 +2,14 @@ import '../models/livetv_channel.dart'; import '../models/livetv_program.dart'; bool liveTvProgramMatchesChannel(LiveTvProgram program, LiveTvChannel channel) { - final programChannel = _nonEmpty(program.channelIdentifier); + final programChannel = liveTvNonEmpty(program.channelIdentifier); if (programChannel == null) return false; if (programChannel != channel.key && programChannel != channel.identifier) return false; if (!_nullableIdsMatch(program.serverId, channel.serverId)) return false; if (!_nullableIdsMatch(program.liveDvrKey, channel.liveDvrKey)) return false; - final programProvider = _nonEmpty(program.providerIdentifier); + final programProvider = liveTvNonEmpty(program.providerIdentifier); final channelProvider = liveTvProviderIdentifierForChannel(channel); if (programProvider != null && channelProvider != null && programProvider != channelProvider) return false; @@ -17,26 +17,27 @@ bool liveTvProgramMatchesChannel(LiveTvProgram program, LiveTvChannel channel) { } String? liveTvProviderIdentifierForChannel(LiveTvChannel channel) { - final source = _nonEmpty(channel.favoriteSource); + final source = liveTvNonEmpty(channel.favoriteSource); if (source != null) { final uri = Uri.tryParse(source); - if (uri != null && uri.pathSegments.isNotEmpty) return _nonEmpty(uri.pathSegments.last); + if (uri != null && uri.pathSegments.isNotEmpty) return liveTvNonEmpty(uri.pathSegments.last); final slashIndex = source.lastIndexOf('/'); if (slashIndex >= 0 && slashIndex < source.length - 1) { - return _nonEmpty(source.substring(slashIndex + 1)); + return liveTvNonEmpty(source.substring(slashIndex + 1)); } } - return _nonEmpty(channel.lineup); + return liveTvNonEmpty(channel.lineup); } bool _nullableIdsMatch(String? a, String? b) { - final left = _nonEmpty(a); - final right = _nonEmpty(b); + final left = liveTvNonEmpty(a); + final right = liveTvNonEmpty(b); return left == null || right == null || left == right; } -String? _nonEmpty(String? value) { +/// Trimmed [value], or null when it is null, empty, or whitespace-only. +String? liveTvNonEmpty(String? value) { final trimmed = value?.trim(); return trimmed == null || trimmed.isEmpty ? null : trimmed; } diff --git a/lib/utils/managed_http_client.dart b/lib/utils/managed_http_client.dart index bbdf8770..31644e1b 100644 --- a/lib/utils/managed_http_client.dart +++ b/lib/utils/managed_http_client.dart @@ -240,6 +240,9 @@ class _ManagedStreamedResponseWithUrl extends http.StreamedResponse implements h final Uri url; } +/// Deliberately not `AbortController`: this layer stays a plain [http.Client] +/// with no media-server dependency, and it needs two independent latches +/// (aborted vs. drained) plus the response canceller. class _TrackedRequest { _TrackedRequest(this.url); diff --git a/lib/utils/media_event_keys.dart b/lib/utils/media_event_keys.dart new file mode 100644 index 00000000..5a1491c4 --- /dev/null +++ b/lib/utils/media_event_keys.dart @@ -0,0 +1,48 @@ +import '../media/ids.dart'; +import '../media/media_item.dart'; +import 'app_logger.dart'; +import 'global_key_utils.dart'; + +/// Builds the id filter for a screen showing [items]. +/// +/// Each item contributes itself plus its parent and grandparent, because an +/// event on a season or show also changes how its episodes render. +Set hierarchicalEventIds(Iterable items) { + final keys = {}; + for (final item in items) { + keys.add(item.id); + if (item.parentId != null) keys.add(item.parentId!); + if (item.grandparentId != null) keys.add(item.grandparentId!); + } + return keys; +} + +/// The [hierarchicalEventIds] filter expressed as `serverId:ratingKey` keys. +/// +/// Items without a server id fall back to [fallbackServerId]; if that is also +/// missing the whole filter collapses to `null`, which callers use to fall back +/// to id-only matching rather than silently under-matching. +Set? hierarchicalEventGlobalKeys(Iterable items, {String? fallbackServerId}) { + final keys = {}; + for (final item in items) { + final rawServerId = item.serverId ?? fallbackServerId; + if (rawServerId == null) return null; + + final serverId = ServerId(rawServerId); + keys.add(buildGlobalKey(serverId, item.id)); + if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!)); + if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!)); + } + return keys; +} + +/// The [ServerId] an event emitted for [item] should carry, or `null` — after +/// warning as `: … skipping event` — when the item carries +/// none, since an event without a server id cannot be routed to subscribers. +ServerId? serverIdForEvent(MediaItem item, {required String notifier, required String event}) { + final serverId = serverIdOrNull(item.serverId); + if (serverId == null) { + appLogger.w('$notifier: missing serverId for ${item.id}, skipping $event event'); + } + return serverId; +} diff --git a/lib/utils/media_image_helper.dart b/lib/utils/media_image_helper.dart index 822dc064..aa056548 100644 --- a/lib/utils/media_image_helper.dart +++ b/lib/utils/media_image_helper.dart @@ -344,31 +344,4 @@ class MediaImageHelper { return true; } - - /// Optimized URL for clear-logo overlays ([ImageType.logo]). - static String logoUrl({ - required MediaServerClient? client, - required String? thumbPath, - required BuildContext context, - required double containerWidth, - required double containerHeight, - }) => _typedUrl(client, thumbPath, context, containerWidth, containerHeight, ImageType.logo); - - static String _typedUrl( - MediaServerClient? client, - String? thumbPath, - BuildContext context, - double containerWidth, - double containerHeight, - ImageType type, - ) { - return getOptimizedImageUrl( - client: client, - thumbPath: thumbPath, - maxWidth: containerWidth, - maxHeight: containerHeight, - devicePixelRatio: effectiveDevicePixelRatio(context), - imageType: type, - ); - } } diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index fc6cfb13..9e0a0a4e 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -14,7 +14,7 @@ import '../services/settings_service.dart'; import '../utils/global_key_utils.dart'; import 'catalog_navigation_helper.dart'; import 'music_navigation.dart'; -import 'plex_library_section_helpers.dart'; +import 'plex_library_section_utils.dart'; import 'video_player_navigation.dart'; /// Result of media navigation indicating what action was taken @@ -192,11 +192,13 @@ Future navigateToMediaItem( ); // Handle library section items (shared whole-library entries) — Plex-only; - // [PlexLibrarySection.isLibrarySection] reads the stashed `key` from `raw`. - if (mi.isLibrarySection) { - final sectionKey = mi.librarySectionKey; - if (sectionKey != null && mi.serverId != null) { - final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), sectionKey); + // `PlexMappers` stashes the section path in `raw['key']`. Jellyfin "views" + // never appear inside a [MediaItem], so the gate never fires for them. + final rawKey = mi.raw?['key']; + if (rawKey is String && rawKey.startsWith('/library/sections/')) { + final sectionId = plexLibrarySectionIdFromString(rawKey); + if (sectionId != null && mi.serverId != null) { + final libraryGlobalKey = buildGlobalKey(ServerId(mi.serverId!), '$sectionId'); MainScreenFocusScope.of(context, listen: false)?.selectLibrary?.call(libraryGlobalKey); return MediaNavigationResult.librarySelected; } diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index c0a2f732..5daf4001 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -10,6 +10,7 @@ import 'future_extensions.dart'; import 'isolate_helper.dart'; import 'log_redaction_manager.dart'; import 'managed_http_client.dart'; +import 'url_utils.dart'; import '../exceptions/media_server_exceptions.dart'; // Platform-specific imports are conditional @@ -78,7 +79,16 @@ class AbortController { /// timeouts, logging, and optional endpoint failover. class MediaServerHttpClient { final http.Client _client; + + /// Requests owned by this client, aborted at the transport on shutdown so an + /// in-flight body raises [http.RequestAbortedException] instead of truncating. final Set _activeAborts = {}; + + /// Not delegated to [ManagedHttpClient]'s own closing guard: that reports + /// shutdown as an [http.ClientException], which maps to + /// [MediaServerHttpErrorType.connectionError] and so reads as transient. + /// Failover, pagination and download retry all branch on + /// [MediaServerHttpException.isCancellation]. bool _closing = false; MediaServerHttpClient({ @@ -152,48 +162,19 @@ class MediaServerHttpClient { }) => _send('DELETE', path, queryParameters: queryParameters, headers: headers, timeout: timeout, abort: abort); /// Fetch raw bytes (e.g. images, BIF files, subtitles). - Future getBytes( - String url, { - Map? headers, - Duration? timeout, - AbortController? abort, - }) async { - if (_closing) { - throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); - } - - final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); - final requestAbort = AbortController(); - _activeAborts.add(requestAbort); - final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll({...defaultHeaders, ...?headers}); - - final sw = Stopwatch()..start(); - try { - final streamed = await _withAbortOnTimeout( - _client.send(request), - timeout ?? connectTimeout, - operation: 'GET ${uri.path} connect', - abort: requestAbort, - ); - - final bytes = await _withAbortOnTimeout( - streamed.stream.toBytes(), - timeout ?? receiveTimeout, - operation: 'GET ${uri.path} receive', - abort: requestAbort, - ); - - sw.stop(); - _logResponse('GET', uri, streamed.statusCode, sw.elapsedMilliseconds); - return bytes; - } catch (e) { - requestAbort.abort(); - sw.stop(); - throw MediaServerHttpException.from(e, uri: uri); - } finally { - _activeAborts.remove(requestAbort); - } + Future getBytes(String url, {Map? headers, Duration? timeout, AbortController? abort}) { + return _perform( + 'GET', + url, + headers: headers, + timeout: timeout, + abort: abort, + consume: (streamed, scope) async { + final bytes = await scope.receive(streamed.stream.toBytes()); + scope.logResponse(streamed.statusCode); + return bytes; + }, + ); } /// Stream-download a URL directly into a file. @@ -203,64 +184,48 @@ class MediaServerHttpClient { Map? headers, Duration? timeout, AbortController? abort, - }) async { - if (_closing) { - throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); - } + }) { + final tempFile = File('$filePath.download'); + return _perform( + 'GET', + url, + label: 'download', + headers: headers, + timeout: timeout, + abort: abort, + // Also clears a temp file left by an earlier attempt when this one never + // got past connect. + onError: () async { + if (await tempFile.exists()) { + try { + await tempFile.delete(); + } catch (_) {} + } + }, + consume: (streamed, scope) async { + if (streamed.statusCode < 200 || streamed.statusCode >= 300) { + await streamed.stream.drain(); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + requestUri: scope.uri, + message: 'HTTP ${streamed.statusCode}', + ); + } - final uri = _isAbsoluteUrl(url) ? Uri.parse(url) : _buildUri(url, null); - final requestAbort = AbortController(); - _activeAborts.add(requestAbort); - final request = http.AbortableRequest('GET', uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll({...defaultHeaders, ...?headers}); - - try { - final streamed = await _withAbortOnTimeout( - _client.send(request), - timeout ?? connectTimeout, - operation: 'download ${uri.path} connect', - abort: requestAbort, - ); - - if (streamed.statusCode < 200 || streamed.statusCode >= 300) { - await streamed.stream.drain(); - throw MediaServerHttpException( - type: MediaServerHttpErrorType.unknown, - statusCode: streamed.statusCode, - requestUri: uri, - message: 'HTTP ${streamed.statusCode}', - ); - } - - final file = File(filePath); - await file.parent.create(recursive: true); - final tempFile = File('$filePath.download'); - if (await tempFile.exists()) await tempFile.delete(); - final sink = tempFile.openWrite(); - try { - await _withAbortOnTimeout( - streamed.stream.pipe(sink), - timeout ?? receiveTimeout, - operation: 'download ${uri.path} receive', - abort: requestAbort, - ); - } finally { - await sink.close(); - } - if (await file.exists()) await file.delete(); - await tempFile.rename(filePath); - } catch (e) { - requestAbort.abort(); - final tempFile = File('$filePath.download'); - if (await tempFile.exists()) { + final file = File(filePath); + await file.parent.create(recursive: true); + if (await tempFile.exists()) await tempFile.delete(); + final sink = tempFile.openWrite(); try { - await tempFile.delete(); - } catch (_) {} - } - throw MediaServerHttpException.from(e, uri: uri); - } finally { - _activeAborts.remove(requestAbort); - } + await scope.receive(streamed.stream.pipe(sink)); + } finally { + await sink.close(); + } + if (await file.exists()) await file.delete(); + await tempFile.rename(filePath); + }, + ); } void close() { @@ -287,69 +252,90 @@ class MediaServerHttpClient { Object? body, Duration? timeout, AbortController? abort, + }) { + return _perform( + method, + path, + queryParameters: queryParameters, + headers: headers, + body: body, + timeout: timeout, + abort: abort, + consume: (streamed, scope) async { + final effectiveUri = switch (streamed) { + http.BaseResponseWithUrl(:final url) => url, + _ => scope.uri, + }; + + final bytes = await scope.receive(streamed.stream.toBytes()); + scope.logResponse(streamed.statusCode); + + dynamic data; + try { + data = await _decodeBody(bytes, streamed.headers); + } catch (e) { + final body = await _decodeTextBody(bytes); + throw MediaServerHttpException( + type: MediaServerHttpErrorType.unknown, + statusCode: streamed.statusCode, + responseData: body, + requestUri: scope.uri, + message: 'Failed to decode response body: $e', + ); + } + return MediaServerResponse( + statusCode: streamed.statusCode, + data: data, + headers: streamed.headers, + requestUri: scope.uri, + effectiveUri: effectiveUri, + ); + }, + ); + } + + /// Run one request: closing guard, abort registration, connect phase and + /// failure wrapping. [consume] reads the body through its scope, which + /// carries the same timeout and abort wiring into the receive phase; + /// [onError] runs after the abort and before the failure is wrapped. Every + /// exit path deregisters the request from [_activeAborts]. + Future _perform( + String method, + String url, { + String? label, + Map? queryParameters, + Map? headers, + Object? body, + Duration? timeout, + AbortController? abort, + Future Function()? onError, + required Future Function(http.StreamedResponse streamed, _RequestScope scope) consume, }) async { if (_closing) { throw MediaServerHttpException(type: MediaServerHttpErrorType.cancelled, message: 'HTTP client is closing'); } - final uri = _isAbsoluteUrl(path) - ? _appendQuery(Uri.parse(path), queryParameters) - : _buildUri(path, queryParameters); - - final mergedHeaders = {...defaultHeaders, ...?headers}; + final uri = _resolveUri(url, queryParameters); + final operation = label ?? method; final requestAbort = AbortController(); _activeAborts.add(requestAbort); final request = http.AbortableRequest(method, uri, abortTrigger: _abortTrigger(requestAbort, abort)); - request.headers.addAll(mergedHeaders); + request.headers.addAll({...defaultHeaders, ...?headers}); _setBody(request, body); - final sw = Stopwatch()..start(); + final scope = _RequestScope(this, uri, operation, requestAbort, timeout ?? receiveTimeout); try { final streamed = await _withAbortOnTimeout( _client.send(request), timeout ?? connectTimeout, - operation: '$method ${uri.path} connect', + operation: '$operation ${uri.path} connect', abort: requestAbort, ); - final effectiveUri = switch (streamed) { - http.BaseResponseWithUrl(:final url) => url, - _ => uri, - }; - - final bytes = await _withAbortOnTimeout( - streamed.stream.toBytes(), - timeout ?? receiveTimeout, - operation: '$method ${uri.path} receive', - abort: requestAbort, - ); - - sw.stop(); - _logResponse(method, uri, streamed.statusCode, sw.elapsedMilliseconds); - - dynamic data; - try { - data = await _decodeBody(bytes, streamed.headers); - } catch (e) { - final body = await _decodeTextBody(bytes); - throw MediaServerHttpException( - type: MediaServerHttpErrorType.unknown, - statusCode: streamed.statusCode, - responseData: body, - requestUri: uri, - message: 'Failed to decode response body: $e', - ); - } - return MediaServerResponse( - statusCode: streamed.statusCode, - data: data, - headers: streamed.headers, - requestUri: uri, - effectiveUri: effectiveUri, - ); + return await consume(streamed, scope); } catch (e) { requestAbort.abort(); - sw.stop(); + await onError?.call(); throw MediaServerHttpException.from(e, uri: uri); } finally { _activeAborts.remove(requestAbort); @@ -400,42 +386,21 @@ class MediaServerHttpClient { return _appendQuery(Uri.parse('$base$cleanPath'), queryParameters); } + /// Resolve a request target: absolute URLs keep their own host and query, + /// relative paths go through [baseUrl]. + Uri _resolveUri(String url, Map? queryParameters) => + _isAbsoluteUrl(url) ? _appendQuery(Uri.parse(url), queryParameters) : _buildUri(url, queryParameters); + /// Append query parameters to an already-parsed URI. Uri _appendQuery(Uri uri, Map? queryParameters) { if (queryParameters == null || queryParameters.isEmpty) return uri; - final query = MediaServerHttpClient.encodeQueryParameters(queryParameters); + final query = encodeQueryParameters(queryParameters); if (query.isEmpty) return uri; final existing = uri.query; final combined = existing.isEmpty ? query : '$existing&$query'; return uri.replace(query: combined); } - /// Encode query params with `%20` for spaces (not `+`). - /// Null values are omitted and iterable values are emitted as repeated keys. - static String encodeQueryParameters(Map? params) { - if (params == null || params.isEmpty) return ''; - final parts = []; - - void add(String key, Object? value) { - if (value == null) return; - if (value is Iterable) { - for (final item in value) { - add(key, item); - } - return; - } - parts.add( - '${Uri.encodeComponent(key)}=' - '${Uri.encodeComponent(value.toString())}', - ); - } - - for (final entry in params.entries) { - add(entry.key, entry.value); - } - return parts.join('&'); - } - static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://'); /// Set the request body, choosing encoding based on the body type. @@ -452,14 +417,11 @@ class MediaServerHttpClient { return; } + // Content type comes from the caller's headers (Jellyfin/Plex put + // `application/json` in their defaults); `request.body` falls back to + // text/plain. Don't add one here — `request.headers` is case-insensitive, + // and the setter above has already filled the key in either way. request.body = jsonEncode(body); - // http.BaseRequest's headers map is case-sensitive; Jellyfin returns 415 - // if both `Content-Type` (from defaults) and `content-type` (added below) - // end up coexisting, so check both casings before adding. - final hasContentType = request.headers.keys.any((k) => k.toLowerCase() == 'content-type'); - if (!hasContentType) { - request.headers['content-type'] = 'application/json'; - } } /// Decode the response body: lenient UTF-8, then JSON parse if applicable. @@ -500,6 +462,28 @@ class MediaServerHttpClient { } } +/// The live request handed to a [MediaServerHttpClient._perform] body handler. +/// Its stopwatch starts with the connect phase, so [logResponse] reports the +/// full round trip regardless of how the body was read. +class _RequestScope { + _RequestScope(this._owner, this.uri, this._operation, this._abort, this._receiveTimeout); + + final MediaServerHttpClient _owner; + final Uri uri; + final String _operation; + final AbortController _abort; + final Duration _receiveTimeout; + final Stopwatch _sw = Stopwatch()..start(); + + Future receive(Future future) => + _owner._withAbortOnTimeout(future, _receiveTimeout, operation: '$_operation ${uri.path} receive', abort: _abort); + + void logResponse(int statusCode) { + _sw.stop(); + _owner._logResponse(_operation, uri, statusCode, _sw.elapsedMilliseconds); + } +} + /// Shared [MediaServerHttpClient] instance for ad-hoc requests (update checks, /// log uploads, image fetches, etc). No base URL or default Plex headers. final httpClient = MediaServerHttpClient(); diff --git a/lib/utils/music_navigation.dart b/lib/utils/music_navigation.dart index a5904911..3558da41 100644 --- a/lib/utils/music_navigation.dart +++ b/lib/utils/music_navigation.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../navigation/profile_navigation_scope.dart'; import '../screens/music/album_detail_screen.dart'; @@ -14,7 +13,6 @@ import '../theme/mono_tokens.dart'; import 'app_logger.dart'; import 'platform_detector.dart'; import 'provider_extensions.dart'; -import 'snackbar_helper.dart'; /// Route name of the now-playing screen — the mini-player's route observer /// suppresses itself while this (or the video player) is in the stack. @@ -90,17 +88,7 @@ void _autoOpenNowPlayingOnTv(BuildContext context) { }); } -/// True when a real music playback engine is bound. On the stub this shows -/// the standard "not supported yet" notice and returns false — check it -/// BEFORE fetching tracks so the stub never costs a server round-trip. -bool ensureMusicPlaybackAvailable(BuildContext context) { - if (context.read().isAvailable) return true; - showAppSnackBar(context, t.messages.musicNotSupported); - return false; -} - -/// Start playback of [tracks] via the session [MusicPlaybackService], -/// surfacing the "not supported yet" notice while the stub is bound. +/// Start playback of [tracks] via the session [MusicPlaybackService]. Future playTracks( BuildContext context, { required List tracks, @@ -108,7 +96,6 @@ Future playTracks( required MusicPlayContext playContext, bool shuffle = false, }) async { - if (!ensureMusicPlaybackAvailable(context)) return; await context.read().playFromList( tracks: tracks, startTrack: startTrack, @@ -118,11 +105,54 @@ Future playTracks( if (context.mounted) _autoOpenNowPlayingOnTv(context); } +/// Fetch a track list with [fetch], then play it — the shape every music +/// entry point that needs a server round-trip before playback repeats: +/// [MusicPlaybackService.beginPlayIntent] → fetch → mounted/intent re-check → +/// [playTracks]. Guarding the round-trip with the intent keeps a slow fetch +/// from replacing a queue the user started later. +/// +/// [onError] reports a failed fetch and runs only while the intent is still +/// current and [context] mounted; passing null instead lets the failure +/// propagate to the caller's own error boundary. [onEmpty] handles a +/// successful but empty fetch; passing null hands the empty list to +/// [playTracks] unchanged. +Future playFetchedTracks( + BuildContext context, { + required Future> Function() fetch, + required MusicPlayContext playContext, + void Function(Object error, StackTrace stackTrace)? onError, + VoidCallback? onEmpty, + MediaItem? startTrack, + bool shuffle = false, +}) async { + final service = context.read(); + final intent = service.beginPlayIntent(); + final List tracks; + try { + tracks = await fetch(); + } catch (error, stackTrace) { + if (!service.isPlayIntentCurrent(intent)) return; + if (onError == null) rethrow; + if (!context.mounted) return; + onError(error, stackTrace); + return; + } + if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; + if (tracks.isEmpty && onEmpty != null) { + onEmpty(); + return; + } + await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle); +} + /// Play [track] within its album queue: fetch the album's tracks and start /// at [track]. Falls back to single-track playback when the track has no /// album, isn't found in it, or the album fetch fails. +/// +/// Hand-written rather than routed through [playFetchedTracks]: the fallback +/// must play under the *same* intent as the album fetch, so a stale fallback +/// can never supersede a newer request. Future playTrackWithAlbumContext(BuildContext context, MediaItem track) async { - if (!ensureMusicPlaybackAvailable(context)) return; final service = context.read(); final intent = service.beginPlayIntent(); @@ -161,7 +191,6 @@ Future playTrackWithAlbumContext(BuildContext context, MediaItem track) as /// Only call when the seed's server advertises /// `ServerCapabilities.instantMix`. Future playInstantMix(BuildContext context, MediaItem seed) async { - if (!ensureMusicPlaybackAvailable(context)) return; await context.read().playInstantMix(seed); if (context.mounted) _autoOpenNowPlayingOnTv(context); } diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 663dbff8..ca05ca19 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -5,6 +5,9 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'async_singleton.dart'; +import 'device_channel.dart'; + const _androidFeatureTelevision = 'android.hardware.type.television'; const _androidFeatureLeanback = 'android.software.leanback'; const _androidFeatureFireTv = 'amazon.hardware.fire_tv'; @@ -30,10 +33,9 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable fea /// Service for detecting if the app is running on Android TV or Apple TV. class TvDetectionService { - static TvDetectionService? _instance; - static Future? _initialization; + static final AsyncSingleton _singleton = AsyncSingleton(); @visibleForTesting - static Future? debugDetectionGate; + static set debugDetectionGate(Future? value) => _singleton.debugGate = value; static bool? _debugAppleTVOverride; bool _detected = false; bool _forceTv = false; @@ -46,35 +48,12 @@ class TvDetectionService { /// Get the singleton instance, initializing if needed. /// Pass [forceTv] to combine a user override with the system-feature check. - static Future getInstance({bool forceTv = false}) async { - final existing = _instance; - if (existing != null) { - final initialization = _initialization; - if (initialization != null) await initialization; - return existing; - } - - final instance = TvDetectionService._(); - _instance = instance; - final initialization = instance._detect(forceTv); - _initialization = initialization; - try { - await initialization; - } catch (_) { - if (identical(_instance, instance)) _instance = null; - rethrow; - } finally { - if (identical(_initialization, initialization)) _initialization = null; - } - return instance; - } + static Future getInstance({bool forceTv = false}) => + _singleton.getInstance(TvDetectionService._, (instance) => instance._detect(forceTv)); static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); - static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device'); Future _detect(bool forceTv) async { - final gate = debugDetectionGate; - if (gate != null) await gate; if (_initialized) return; final deviceInfo = DeviceInfoPlugin(); @@ -112,8 +91,6 @@ class TvDetectionService { bool get isTV => _isTV; - List get tvDetectionReasons => _effectiveDetectionReasons; - List get _effectiveDetectionReasons { final reasons = [..._detectionReasons]; if (_forceTv && !reasons.contains('force_tv')) reasons.add('force_tv'); @@ -122,7 +99,7 @@ class TvDetectionService { Future _getNativeAndroidTvDetection() async { try { - final result = await _deviceChannel.invokeMapMethod('getTvDetection'); + final result = await deviceChannel.invokeMapMethod('getTvDetection'); if (result == null) return null; final reasonsValue = result['reasons']; final reasons = reasonsValue is Iterable ? reasonsValue.whereType().toList() : []; @@ -141,7 +118,7 @@ class TvDetectionService { static Future getAndroidDeviceName() async { if (!Platform.isAndroid) return null; try { - final name = (await _deviceChannel.invokeMethod('getDeviceName'))?.trim(); + final name = (await deviceChannel.invokeMethod('getDeviceName'))?.trim(); return (name == null || name.isEmpty) ? null : name; } on MissingPluginException { return null; @@ -157,10 +134,10 @@ class TvDetectionService { } /// Synchronous access after initialization (returns false if not initialized) - static bool isTVSync() => _debugAppleTVOverride ?? _instance?._isTV ?? false; + static bool isTVSync() => _debugAppleTVOverride ?? _singleton.instance?._isTV ?? false; /// Synchronous Apple TV check (returns false if not initialized or not tvOS). - static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _instance?._isAppleTV == true); + static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _singleton.instance?._isAppleTV == true); @visibleForTesting static void debugSetAppleTVOverride(bool? value) { @@ -169,16 +146,14 @@ class TvDetectionService { @visibleForTesting static void debugReset() { - _instance = null; - _initialization = null; - debugDetectionGate = null; + _singleton.debugReset(); _debugAppleTVOverride = null; } - static List tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const []; + static List tvDetectionReasonsSync() => _singleton.instance?._effectiveDetectionReasons ?? const []; /// Convenience setter that forwards to the singleton if available. - static void setForceTVSync(bool value) => _instance?.setForceTv(value); + static void setForceTVSync(bool value) => _singleton.instance?.setForceTv(value); } class PlatformDetector { diff --git a/lib/utils/plex_cache_parser.dart b/lib/utils/plex_cache_parser.dart index a88d1de5..c47e39c7 100644 --- a/lib/utils/plex_cache_parser.dart +++ b/lib/utils/plex_cache_parser.dart @@ -18,10 +18,4 @@ class PlexCacheParser { if (list == null || list.isEmpty) return null; return list.first as Map; } - - static List? extractChapters(Map? cached) { - final metadata = extractFirstMetadata(cached); - if (metadata == null) return null; - return metadata['Chapter'] as List?; - } } diff --git a/lib/utils/plex_library_section_helpers.dart b/lib/utils/plex_library_section_helpers.dart deleted file mode 100644 index fec97cce..00000000 --- a/lib/utils/plex_library_section_helpers.dart +++ /dev/null @@ -1,31 +0,0 @@ -import '../media/media_item.dart'; - -/// Plex-only helpers for navigating to a "library section" hub entry. -/// -/// Plex's home/discover hubs occasionally surface library-section rows -/// (`/library/sections/{id}/all`) alongside individual items; the -/// `PlexMappers` adapter stashes the section key in [MediaItem.raw] under -/// `'key'` so navigation code can detect and route to the library screen -/// instead of the media-detail screen. -/// -/// Jellyfin's analogue is the dedicated `MediaLibrary` shape — Jellyfin -/// "views" never appear inside a [MediaItem], so these helpers correctly -/// return `false`/`null` for any Jellyfin item. -extension PlexLibrarySection on MediaItem { - /// Whether this item represents a Plex library section (shared - /// whole-library entry, not a media item). - bool get isLibrarySection { - final key = raw?['key']; - return key is String && key.startsWith('/library/sections/'); - } - - /// Extract the library section id from the stashed Plex `raw['key']`. - /// Returns `null` for non-section items or items without a parsable id. - String? get librarySectionKey { - if (!isLibrarySection) return null; - final key = raw?['key'] as String?; - if (key == null) return null; - final match = RegExp(r'/library/sections/(\d+)').firstMatch(key); - return match?.group(1); - } -} diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart index f352f03d..e1fae749 100644 --- a/lib/utils/url_utils.dart +++ b/lib/utils/url_utils.dart @@ -14,6 +14,36 @@ String stripTrailingSlash(String input) { return trimmed; } +/// Encode query params with `%20` for spaces (not `+`). +/// Null values are omitted and iterable values are emitted as repeated keys. +/// +/// Used instead of `Uri.queryParameters` (which emits `+` for spaces) wherever +/// the server rejects `+` — Plex's transcode endpoints and Seerr's TMDB-backed +/// `/search` proxy both do. +String encodeQueryParameters(Map? params) { + if (params == null || params.isEmpty) return ''; + final parts = []; + + void add(String key, Object? value) { + if (value == null) return; + if (value is Iterable) { + for (final item in value) { + add(key, item); + } + return; + } + parts.add( + '${Uri.encodeComponent(key)}=' + '${Uri.encodeComponent(value.toString())}', + ); + } + + for (final entry in params.entries) { + add(entry.key, entry.value); + } + return parts.join('&'); +} + final RegExp _schemePattern = RegExp(r'^[A-Za-z][A-Za-z\d+.-]*://'); /// Canonicalizes a server base URL: trims, strips one trailing `/`, and diff --git a/lib/utils/watch_state_notifier.dart b/lib/utils/watch_state_notifier.dart index 77226245..7e6813bc 100644 --- a/lib/utils/watch_state_notifier.dart +++ b/lib/utils/watch_state_notifier.dart @@ -5,6 +5,7 @@ import 'app_logger.dart'; import 'base_notifier.dart'; import 'global_key_utils.dart'; import 'hierarchical_event_mixin.dart'; +import 'media_event_keys.dart'; enum WatchStateChangeType { watched, unwatched, progressUpdate, removedFromContinueWatching } @@ -98,11 +99,8 @@ class WatchStateNotifier extends BaseNotifier { /// Helper to emit a watched/unwatched event from a [MediaItem]. void notifyWatched({required MediaItem item, bool isNowWatched = true, String? cacheServerId}) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping watched event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'watched'); + if (serverId == null) return; notify( WatchStateEvent( itemId: item.id, @@ -127,11 +125,8 @@ class WatchStateNotifier extends BaseNotifier { String? cacheServerId, double watchedThreshold = 0.9, }) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping progress event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'progress'); + if (serverId == null) return; final isNowWatched = isWatchedProgress(positionMs: viewOffset, durationMs: duration, threshold: watchedThreshold); notify( @@ -151,11 +146,8 @@ class WatchStateNotifier extends BaseNotifier { /// Helper to emit a Continue Watching removal event. void notifyRemovedFromContinueWatching({required MediaItem item}) { - final serverId = serverIdOrNull(item.serverId); - if (serverId == null) { - appLogger.w('WatchStateNotifier: missing serverId for ${item.id}, skipping continue-watching removal event'); - return; - } + final serverId = serverIdForEvent(item, notifier: 'WatchStateNotifier', event: 'continue-watching removal'); + if (serverId == null) return; notify( WatchStateEvent( itemId: item.id, diff --git a/lib/watch_together/providers/watch_together_provider.dart b/lib/watch_together/providers/watch_together_provider.dart index 644d898e..79df6f7c 100644 --- a/lib/watch_together/providers/watch_together_provider.dart +++ b/lib/watch_together/providers/watch_together_provider.dart @@ -155,11 +155,6 @@ class WatchTogetherProvider with ChangeNotifier { bool get hasCurrentPlayback => currentMediaRatingKey != null && currentMediaServerId != null && currentMediaTitle != null; - /// Set the display name for this user - void setDisplayName(String name) { - _displayName = name; - } - String? _buildPlaybackKey(String? ratingKey, ServerId? serverId) { if (ratingKey == null || serverId == null) return null; return '$serverId:$ratingKey'; diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index cdc05bdb..80081b84 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -227,38 +227,60 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta ); } + /// Persists [code] as a recent room for the active profile and refreshes the + /// list. A null [controlMode] leaves any stored mode untouched. + Future _recordRecentRoom(String code, {ControlMode? controlMode}) async { + final profileId = _profileId; + if (profileId == null || profileId.isEmpty) return; + await RecentRoomsService.addOrUpdateRoom( + code, + profileId: profileId, + endpoint: _relayEndpoint, + controlMode: controlMode, + ); + setStateIfMounted(() => _recentRooms = _loadRecentRooms()); + } + + /// Runs [action] behind a busy flag, logging [logMessage] and showing + /// [failureMessage] in a snackbar when it throws. + Future _runSessionAction({ + required void Function(bool busy) setBusy, + required String logMessage, + required String failureMessage, + required Future Function() action, + }) async { + setState(() => setBusy(true)); + try { + await action(); + } catch (e) { + appLogger.e(logMessage, error: e); + if (mounted) { + showErrorSnackBar(context, '$failureMessage: $e'); + } + } finally { + if (mounted) { + setState(() => setBusy(false)); + } + } + } + Future _createSession() async { final controlMode = await _showControlModeDialog(); if (controlMode == null || !mounted) return; - setState(() => _isCreating = true); - - try { - final sessionId = await widget.watchTogether.createSession( - controlMode: controlMode, - relayEndpoint: _relayEndpoint, - displayName: _plexDisplayName, - ); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom( - sessionId, - profileId: profileId, - endpoint: _relayEndpoint, + await _runSessionAction( + setBusy: (busy) => _isCreating = busy, + logMessage: 'Failed to create session', + failureMessage: t.watchTogether.failedToCreate, + action: () async { + final sessionId = await widget.watchTogether.createSession( controlMode: controlMode, + relayEndpoint: _relayEndpoint, + displayName: _plexDisplayName, ); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to create session', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToCreate}: $e'); - } - } finally { - if (mounted) { - setState(() => _isCreating = false); - } - } + await _recordRecentRoom(sessionId, controlMode: controlMode); + }, + ); } Future _showControlModeDialog() { @@ -287,52 +309,32 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta final sessionId = await showJoinSessionDialog(context); if (sessionId == null || !mounted) return; - setState(() => _isJoining = true); - - try { - await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom(sessionId, profileId: profileId, endpoint: _relayEndpoint); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to join session', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); - } - } finally { - if (mounted) { - setState(() => _isJoining = false); - } - } + await _runSessionAction( + setBusy: (busy) => _isJoining = busy, + logMessage: 'Failed to join session', + failureMessage: t.watchTogether.failedToJoin, + action: () async { + await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName); + await _recordRecentRoom(sessionId); + }, + ); } Future _enterRoom(RecentRoom room) async { - setState(() => _enteringRoomCode = room.code); - - try { - await widget.watchTogether.enterRoom( - room.code, - relayEndpoint: _relayEndpoint, - controlMode: room.controlMode ?? ControlMode.anyone, - displayName: _plexDisplayName, - ); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom(room.code, profileId: profileId, endpoint: _relayEndpoint); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to enter room', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); - } - } finally { - if (mounted) { - setState(() => _enteringRoomCode = null); - } - } + await _runSessionAction( + setBusy: (busy) => _enteringRoomCode = busy ? room.code : null, + logMessage: 'Failed to enter room', + failureMessage: t.watchTogether.failedToJoin, + action: () async { + await widget.watchTogether.enterRoom( + room.code, + relayEndpoint: _relayEndpoint, + controlMode: room.controlMode ?? ControlMode.anyone, + displayName: _plexDisplayName, + ); + await _recordRecentRoom(room.code); + }, + ); } Future _renameRoom(RecentRoom room) async { diff --git a/lib/watch_together/services/guest_playback_reconciler.dart b/lib/watch_together/services/guest_playback_reconciler.dart index da907683..189f07be 100644 --- a/lib/watch_together/services/guest_playback_reconciler.dart +++ b/lib/watch_together/services/guest_playback_reconciler.dart @@ -120,7 +120,6 @@ class GuestPlaybackReconciler { Timer? _statusRefreshTimer; PlaybackState? get latestState => _latestState; - bool get isCorrecting => _correcting; // --------------------------------------------------------------------- // Public inputs diff --git a/lib/watch_together/services/host_playback_coordinator.dart b/lib/watch_together/services/host_playback_coordinator.dart index 5765d20d..84213a78 100644 --- a/lib/watch_together/services/host_playback_coordinator.dart +++ b/lib/watch_together/services/host_playback_coordinator.dart @@ -118,7 +118,6 @@ class HostPlaybackCoordinator { bool _disposed = false; PlaybackPhase get phase => _phase; - Set get incompatiblePeers => Set.unmodifiable(_incompatiblePeers); // --------------------------------------------------------------------- // Public inputs diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart index bc591b7c..a49b0fc9 100644 --- a/lib/watch_together/services/watch_together_peer_service.dart +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -301,16 +301,7 @@ class WatchTogetherPeerService with KeepaliveMixin { final rejectedSetup = _setupCompleter; if (rejectedSetup == null || rejectedSetup.isCompleted) return; - final leaveCompleter = Completer(); - _setupCompleter = leaveCompleter; - _setupRequestType = RelayProtocol.leave; - _sendRaw({ - 'type': RelayProtocol.leave, - 'sessionId': _sessionId, - 'peerId': _myPeerId, - 'reconnectToken': _reconnectToken, - 'protocolVersion': _relayProtocolVersion, - }); + final leaveCompleter = _announce(RelayProtocol.leave); unawaited(() async { try { await leaveCompleter.future.namedTimeout( @@ -803,16 +794,7 @@ class WatchTogetherPeerService with KeepaliveMixin { ); } - final releaseCompleter = Completer(); - _setupCompleter = releaseCompleter; - _setupRequestType = _isHost ? RelayProtocol.endSession : RelayProtocol.leave; - _sendRaw({ - 'type': _isHost ? RelayProtocol.endSession : RelayProtocol.leave, - 'sessionId': _sessionId, - 'peerId': _myPeerId, - 'reconnectToken': _reconnectToken, - 'protocolVersion': _relayProtocolVersion, - }); + final releaseCompleter = _announce(_isHost ? RelayProtocol.endSession : RelayProtocol.leave); await releaseCompleter.future.namedTimeout( const Duration(seconds: 10), operation: _isHost ? 'WatchTogether end session' : 'WatchTogether leave session', diff --git a/lib/widgets/app_menu.dart b/lib/widgets/app_menu.dart index 05f00b45..4e93c7d9 100644 --- a/lib/widgets/app_menu.dart +++ b/lib/widgets/app_menu.dart @@ -390,16 +390,15 @@ class _AppMenuItemTileState extends State> with FocusableT @override void initState() { super.initState(); - initFocusNode(); effectiveFocusNode.addListener(_updateFocusedState); } @override void didUpdateWidget(AppMenuItemTile oldWidget) { + final rebinds = oldWidget.focusNode != widget.focusNode; + if (rebinds) effectiveFocusNode.removeListener(_updateFocusedState); super.didUpdateWidget(oldWidget); - if (oldWidget.focusNode != widget.focusNode) { - effectiveFocusNode.removeListener(_updateFocusedState); - updateFocusNode(oldWidget.focusNode); + if (rebinds) { effectiveFocusNode.addListener(_updateFocusedState); _isFocused = effectiveFocusNode.hasFocus; } @@ -408,7 +407,6 @@ class _AppMenuItemTileState extends State> with FocusableT @override void dispose() { effectiveFocusNode.removeListener(_updateFocusedState); - disposeFocusNode(); super.dispose(); } diff --git a/lib/widgets/catalog_source_logo.dart b/lib/widgets/catalog_source_logo.dart index c0ad7e8d..f3b0044e 100644 --- a/lib/widgets/catalog_source_logo.dart +++ b/lib/widgets/catalog_source_logo.dart @@ -3,33 +3,28 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../models/catalog/catalog_item.dart'; -/// Brand mark of a catalog source — or any service SVG via -/// [CatalogSourceLogo.asset] — tinted with the ambient icon color. Uses -/// [SvgTheme.currentColor] so SVGs with multiple explicit fills (AniList -/// keeps its brand-blue L while the A follows the theme) render correctly -/// alongside single-color wordmarks. +/// Brand mark of a service, tinted with the ambient icon color. The single +/// table of brand asset paths: every surface that shows a service logo goes +/// through here, including services that do not participate in the Explore +/// catalog. Uses [SvgTheme.currentColor] so SVGs with multiple explicit fills +/// (AniList keeps its brand-blue L while the A follows the theme) render +/// correctly alongside single-color wordmarks. class CatalogSourceLogo extends StatelessWidget { - final CatalogSourceId? id; - final String? assetPath; + final CatalogSourceId id; final double size; - const CatalogSourceLogo(CatalogSourceId this.id, {super.key, this.size = 20}) : assetPath = null; - - /// For services that do not participate in the Explore catalog. - const CatalogSourceLogo.asset(String this.assetPath, {super.key, this.size = 20}) : id = null; + const CatalogSourceLogo(this.id, {super.key, this.size = 20}); @override Widget build(BuildContext context) { - final asset = - assetPath ?? - switch (id!) { - CatalogSourceId.plex => 'assets/plex_chevron.svg', - CatalogSourceId.trakt => 'assets/trakt_circlemark.svg', - CatalogSourceId.mal => 'assets/mal_mark.svg', - CatalogSourceId.anilist => 'assets/anilist_mark.svg', - CatalogSourceId.simkl => 'assets/simkl_mark.svg', - CatalogSourceId.seerr => 'assets/seerr_mark.svg', - }; + final asset = switch (id) { + CatalogSourceId.plex => 'assets/plex_chevron.svg', + CatalogSourceId.trakt => 'assets/trakt_circlemark.svg', + CatalogSourceId.mal => 'assets/mal_mark.svg', + CatalogSourceId.anilist => 'assets/anilist_mark.svg', + CatalogSourceId.simkl => 'assets/simkl_mark.svg', + CatalogSourceId.seerr => 'assets/seerr_mark.svg', + }; final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; return SvgPicture.asset( asset, diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 282f54d4..10b8b939 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -4,20 +4,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../connection/connection_registry.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../mixins/mounted_set_state_mixin.dart'; -import '../../models/plex/plex_home.dart'; -import '../../profiles/active_plex_identity.dart'; -import '../../profiles/active_profile_provider.dart'; -import '../../profiles/plex_home_service.dart'; -import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../services/base_peer_service.dart'; +import '../../services/companion_remote/companion_remote_host_controller.dart'; import '../../services/settings_service.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; @@ -93,26 +88,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi Future _initCryptoAndDiscover() async { try { - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - if (!mounted) return; - final home = await _resolveHome(identity?.account.id); - if (!mounted) return; - await _provider.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); + await ensureCompanionRemoteCryptoFromContext(context); } catch (e) { appLogger.e('CompanionRemote: crypto init failed', error: e); } @@ -137,11 +113,6 @@ class _DiscoveryViewState extends State with ControllerDisposerMi } } - Future _resolveHome(String? connectionId) { - if (connectionId == null) return Future.value(); - return context.read().materializePlexHomeForConnection(connectionId); - } - void _startDiscovery() { final stream = _provider.discoverHosts(); if (stream == null) return; diff --git a/lib/widgets/device_code_dialog.dart b/lib/widgets/device_code_dialog.dart index b161a772..95d7c7c1 100644 --- a/lib/widgets/device_code_dialog.dart +++ b/lib/widgets/device_code_dialog.dart @@ -1,16 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'package:url_launcher/url_launcher.dart'; -import '../focus/focusable_button.dart'; -import '../focus/focusable_wrapper.dart'; import '../i18n/strings.g.dart'; import '../models/trackers/device_code.dart'; import '../utils/snackbar_helper.dart'; -import 'app_icon.dart'; -import 'dialog_action_button.dart'; -import 'loading_indicator_box.dart'; +import 'pending_auth_dialog.dart'; /// Shared device-code activation dialog for Trakt and Simkl (RFC 8628). /// @@ -25,11 +19,6 @@ class DeviceCodeDialog extends StatelessWidget { const DeviceCodeDialog({super.key, required this.code, required this.serviceName, required this.onCancel}); - Future _open() async { - final url = code.verificationUrlComplete ?? code.verificationUrl; - await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); - } - Future _copy(BuildContext context) async { await Clipboard.setData(ClipboardData(text: code.userCode)); if (!context.mounted) return; @@ -39,71 +28,32 @@ class DeviceCodeDialog extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return AlertDialog( - title: Text(t.services.deviceCode.title(service: serviceName)), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text(t.services.deviceCode.body(url: code.verificationUrl), style: theme.textTheme.bodyMedium), - const SizedBox(height: 16), - Center( - child: FocusableWrapper( - onSelect: () => _copy(context), - semanticLabel: t.services.deviceCode.copyCode, - semanticValue: code.userCode, - descendantsAreFocusable: false, - useBackgroundFocus: true, - borderRadius: 8, - child: InkWell( - canRequestFocus: false, - onTap: () => _copy(context), - borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - child: Text( - code.userCode, - style: theme.textTheme.displaySmall?.copyWith( - fontFeatures: const [FontFeature.tabularFigures()], - letterSpacing: 4, - fontWeight: .w600, - ), - ), + return PendingAuthDialog( + title: t.services.deviceCode.title(service: serviceName), + body: t.services.deviceCode.body(url: code.verificationUrl), + url: code.verificationUrlComplete ?? code.verificationUrl, + openLabel: t.services.deviceCode.openToActivate(service: serviceName), + onCancel: onCancel, + children: [ + Center( + child: CopyTapRegion( + onCopy: () => _copy(context), + semanticLabel: t.services.deviceCode.copyCode, + semanticValue: code.userCode, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), + child: Text( + code.userCode, + style: theme.textTheme.displaySmall?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + letterSpacing: 4, + fontWeight: .w600, ), ), ), ), - const SizedBox(height: 16), - SizedBox( - width: double.infinity, - child: FocusableButton( - onPressed: _open, - useBackgroundFocus: true, - child: FilledButton.icon( - icon: const AppIcon(Symbols.open_in_new_rounded), - label: Text(t.services.deviceCode.openToActivate(service: serviceName)), - onPressed: _open, - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - const LoadingIndicatorBox(size: 16), - const SizedBox(width: 12), - Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)), - ], - ), - ], - ), - actions: [ - DialogActionButton( - onPressed: () { - onCancel(); - Navigator.of(context).pop(); - }, - label: t.common.cancel, ), + const SizedBox(height: 16), ], ); } diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 2dbef406..989ce6ad 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -10,7 +10,6 @@ import '../media/media_kind.dart'; import '../models/download_models.dart'; import '../utils/dialogs.dart'; import '../utils/global_key_utils.dart'; -import 'clickable_cursor.dart'; import 'download_status_icon.dart'; /// Represents a node in the download tree @@ -439,7 +438,10 @@ class _DownloadTreeViewState extends State { /// Pause all active (downloading and queued) children of a container node void _pauseAllChildren(DownloadTreeNode node) { - final keys = _getActiveChildKeys(node); + final keys = _leafKeys( + node, + where: (leaf) => leaf.status == DownloadStatus.downloading || leaf.status == DownloadStatus.queued, + ); for (final key in keys) { widget.onPause?.call(key); } @@ -447,38 +449,12 @@ class _DownloadTreeViewState extends State { /// Resume all paused children of a container node void _resumeAllChildren(DownloadTreeNode node) { - final keys = _getPausedChildKeys(node); + final keys = _leafKeys(node, where: (leaf) => leaf.status == DownloadStatus.paused); for (final key in keys) { widget.onResume?.call(key); } } - /// Get all active (downloading or queued) child keys from a container node - List _getActiveChildKeys(DownloadTreeNode node) { - final List keys = []; - for (final child in node.children) { - if (child.hasChildren) { - keys.addAll(_getActiveChildKeys(child)); - } else if (child.status == DownloadStatus.downloading || child.status == DownloadStatus.queued) { - keys.add(child.key); - } - } - return keys; - } - - /// Get all paused child keys from a container node - List _getPausedChildKeys(DownloadTreeNode node) { - final List keys = []; - for (final child in node.children) { - if (child.hasChildren) { - keys.addAll(_getPausedChildKeys(child)); - } else if (child.status == DownloadStatus.paused) { - keys.add(child.key); - } - } - return keys; - } - /// Delete all children of a container node via the container's globalKey /// so deleteDownload's transitive show/season path cleans up all maps. void _deleteAllChildren(DownloadTreeNode node) { @@ -489,23 +465,22 @@ class _DownloadTreeViewState extends State { } // Container globalKey unresolvable; fall back to per-leaf delete. - for (final key in _getAllChildKeys(node)) { + for (final key in _leafKeys(node)) { widget.onDelete?.call(key); } } - /// Get all leaf node keys from a container node - List _getAllChildKeys(DownloadTreeNode node) { + /// Get the keys of every leaf below a container node, in tree order. + /// [where] filters which leaves are collected; unset collects all of them. + List _leafKeys(DownloadTreeNode node, {bool Function(DownloadTreeNode leaf)? where}) { final List keys = []; - for (final child in node.children) { if (child.hasChildren) { - keys.addAll(_getAllChildKeys(child)); - } else { + keys.addAll(_leafKeys(child, where: where)); + } else if (where == null || where(child)) { keys.add(child.key); } } - return keys; } } @@ -556,6 +531,11 @@ class _FlatNode { const _FlatNode({required this.node, required this.depth}); } +/// A single action button of a tree row: the guards that decide which actions +/// exist live in one place ([_DownloadTreeItemState._actions]), so the focus +/// node count and the rendered buttons can never disagree. +typedef _RowAction = ({IconData icon, String tooltip, VoidCallback onPressed}); + /// A single tree item with focusable row content and action buttons class _DownloadTreeItem extends StatefulWidget { final DownloadTreeNode node; @@ -628,7 +608,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { void didUpdateWidget(_DownloadTreeItem oldWidget) { super.didUpdateWidget(oldWidget); // Reinitialize focus nodes if action count changed - if (_getActionCount() != _buttonFocusNodes.length) { + if (_actions().length != _buttonFocusNodes.length) { _disposeButtonFocusNodes(); _initButtonFocusNodes(); } @@ -641,7 +621,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { } void _initButtonFocusNodes() { - final actionCount = _getActionCount(); + final actionCount = _actions().length; for (int i = 0; i < actionCount; i++) { _buttonFocusNodes.add(FocusNode(debugLabel: 'download_action_$i')); } @@ -661,40 +641,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { super.dispose(); } - int _getActionCount() { - final isContainer = - widget.node.type == DownloadNodeType.show || - widget.node.type == DownloadNodeType.season || - widget.node.type == DownloadNodeType.album; - if (isContainer) { - return _getContainerActionCount(); - } - return _getItemActionCount(); - } - - int _getItemActionCount() { - int count = 0; - final status = widget.node.status; - if (status == DownloadStatus.downloading && widget.onPause != null) count++; - if (status == DownloadStatus.paused && widget.onResume != null) count++; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) count++; - if (status == DownloadStatus.failed && widget.onRetry != null) count++; - if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && - widget.onDelete != null) { - count++; - } - return count; - } - - int _getContainerActionCount() { - int count = 0; - final status = widget.node.status; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) count++; - if (status == DownloadStatus.paused && widget.onResume != null) count++; - if (widget.onDelete != null) count++; - return count; - } - void _focusFirstButton() { if (_buttonFocusNodes.isNotEmpty) { _buttonFocusNodes.first.requestFocus(); @@ -709,7 +655,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { Widget build(BuildContext context) { final theme = Theme.of(context); final canExpand = widget.node.hasChildren; - final hasActions = _buttonFocusNodes.isNotEmpty; + final actions = _actions(); return Padding( padding: .only(left: widget.depth * 16.0), @@ -718,7 +664,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { autofocus: widget.autofocus, onSelect: canExpand ? widget.onToggleExpansion : null, onNavigateLeft: widget.onNavigateLeft, - onNavigateRight: hasActions ? _focusFirstButton : null, + onNavigateRight: actions.isNotEmpty ? _focusFirstButton : null, onBack: widget.onBack, borderRadius: 8.0, disableScale: true, @@ -734,7 +680,11 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { Expanded(child: _buildRowContent(theme, canExpand)), // Action buttons - if (hasActions) _buildActions(), + if (actions.isNotEmpty) + Row( + mainAxisSize: .min, + children: [for (int i = 0; i < actions.length; i++) _buildActionButton(actions[i], i)], + ), ], ), ), @@ -755,7 +705,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { const SizedBox(width: 8), // Status icon - _buildStatusIcon(_effectiveStatus), + DownloadStatusIcon(status: _effectiveStatus, size: 20), const SizedBox(width: 12), @@ -826,180 +776,121 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { ); } - Widget _buildStatusIcon(DownloadStatus status) { - return DownloadStatusIcon(status: status, size: 20); - } - String _getNodeSummary() { final total = widget.node.children.length; final completed = widget.node.completedChildrenCount; return '$completed/$total completed'; } - Widget _buildActions() { + /// The actions this row offers, in render order. Single source of truth: + /// both the button widgets and the focus nodes sizing come from this list, + /// so they cannot drift apart. Uses the raw node status, not + /// [_effectiveStatus] (which only remaps the row content). + List<_RowAction> _actions() { + final status = widget.node.status; final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season || widget.node.type == DownloadNodeType.album; + final actions = <_RowAction>[]; - final actions = isContainer ? _buildContainerActions() : _buildItemActions(); + if (isContainer) { + // Pause all button + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { + actions.add(( + icon: Symbols.pause_rounded, + tooltip: t.downloads.pauseAll, + onPressed: () => widget.pauseAllChildren(widget.node), + )); + } - return Row(mainAxisSize: .min, children: actions); - } + // Resume all button + if (status == DownloadStatus.paused && widget.onResume != null) { + actions.add(( + icon: Symbols.play_arrow_rounded, + tooltip: t.downloads.resumeAll, + onPressed: () => widget.resumeAllChildren(widget.node), + )); + } + + // Delete all button + if (widget.onDelete != null) { + actions.add(( + icon: Symbols.delete_sweep_rounded, + tooltip: t.downloads.deleteAll, + onPressed: () async { + if (await _confirmDelete()) widget.deleteAllChildren(widget.node); + }, + )); + } + + return actions; + } - List _buildItemActions() { final globalKey = widget.node.key; - final status = widget.node.status; - final actions = []; - int buttonIndex = 0; // Pause button for downloading items if (status == DownloadStatus.downloading && widget.onPause != null) { - actions.add( - _buildActionButton( - icon: Symbols.pause_rounded, - tooltip: t.common.pause, - onPressed: () => widget.onPause!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add((icon: Symbols.pause_rounded, tooltip: t.common.pause, onPressed: () => widget.onPause!(globalKey))); } // Resume button for paused items if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: t.common.resume, - onPressed: () => widget.onResume!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.play_arrow_rounded, + tooltip: t.common.resume, + onPressed: () => widget.onResume!(globalKey), + )); } // Cancel button for downloading/queued items if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) { - actions.add( - _buildActionButton( - icon: Symbols.close_rounded, - tooltip: t.common.cancel, - onPressed: () => widget.onCancel!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.close_rounded, + tooltip: t.common.cancel, + onPressed: () => widget.onCancel!(globalKey), + )); } // Retry button for failed items if (status == DownloadStatus.failed && widget.onRetry != null) { - actions.add( - _buildActionButton( - icon: Symbols.refresh_rounded, - tooltip: t.downloads.retryDownload, - onPressed: () => widget.onRetry!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.refresh_rounded, + tooltip: t.downloads.retryDownload, + onPressed: () => widget.onRetry!(globalKey), + )); } // Delete button for completed/failed/cancelled items if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_rounded, - tooltip: t.common.delete, - onPressed: () async { - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: widget.node.title), - ); - if (confirmed) widget.onDelete!(globalKey); - }, - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.delete_rounded, + tooltip: t.common.delete, + onPressed: () async { + if (await _confirmDelete()) widget.onDelete!(globalKey); + }, + )); } return actions; } - List _buildContainerActions() { - final status = widget.node.status; - final actions = []; - int buttonIndex = 0; - - // Pause all button - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { - actions.add( - _buildActionButton( - icon: Symbols.pause_rounded, - tooltip: t.downloads.pauseAll, - onPressed: () => widget.pauseAllChildren(widget.node), - buttonIndex: buttonIndex++, - ), - ); - } - - // Resume all button - if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: t.downloads.resumeAll, - onPressed: () => widget.resumeAllChildren(widget.node), - buttonIndex: buttonIndex++, - ), - ); - } - - // Delete all button - if (widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_sweep_rounded, - tooltip: t.downloads.deleteAll, - onPressed: () async { - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: widget.node.title), - ); - if (confirmed) widget.deleteAllChildren(widget.node); - }, - buttonIndex: buttonIndex++, - ), - ); - } - - return actions; + Future _confirmDelete() { + return showDeleteConfirmation( + context, + title: t.downloads.deleteDownload, + message: t.downloads.deleteConfirm(title: widget.node.title), + ); } - Widget _buildActionButton({ - required IconData icon, - required String tooltip, - required VoidCallback onPressed, - required int buttonIndex, - }) { - // Guard against race condition where action count changed between didUpdateWidget and build - if (buttonIndex >= _buttonFocusNodes.length) { - return Tooltip( - message: tooltip, - child: ClickableCursor( - child: GestureDetector( - onTap: onPressed, - child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), - ), - ), - ); - } - + Widget _buildActionButton(_RowAction action, int buttonIndex) { final isFirst = buttonIndex == 0; final isLast = buttonIndex == _buttonFocusNodes.length - 1; return FocusableWrapper( focusNode: _buttonFocusNodes[buttonIndex], - onSelect: onPressed, + onSelect: action.onPressed, onNavigateLeft: isFirst ? _focusRow : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(), onNavigateRight: isLast ? null : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(), onBack: widget.onBack, @@ -1008,10 +899,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { useBackgroundFocus: true, autoScroll: false, child: Tooltip( - message: tooltip, + message: action.tooltip, child: GestureDetector( - onTap: onPressed, - child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), + onTap: action.onPressed, + child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(action.icon, fill: 1, size: 20)), ), ), ); diff --git a/lib/widgets/focus_builders.dart b/lib/widgets/focus_builders.dart index b38a99a4..12eb3113 100644 --- a/lib/widgets/focus_builders.dart +++ b/lib/widgets/focus_builders.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../focus/card_focus_scope.dart'; -import '../focus/focus_glow_overlay.dart'; +import '../focus/focus_chrome.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; import 'clickable_cursor.dart'; @@ -63,96 +62,13 @@ class FocusBuilders { ); } - /// Builds a card-style focusable widget with scale and border decoration. + /// Builds a card-style wrapper with scale and border decoration but no [Focus] + /// node — focus lives on an enclosing rail or screen that passes [isFocused] + /// down. /// - /// Used by FocusableMediaCard and _LockedHubItemWrapper. - /// - /// Parameters: - /// - [context]: Build context for theming - /// - [focusNode]: The focus node for this widget (optional for locked wrappers) - /// - [isFocused]: Whether this widget currently has focus - /// - [onKeyEvent]: Callback for handling key events (optional for locked wrappers) - /// - [onTap]: Callback for tap/click events - /// - [onLongPress]: Callback for long press events - /// - [borderRadius]: Border radius for the focus decoration - /// - [child]: The content to display inside the card - static Widget buildFocusableCard({ - required BuildContext context, - FocusNode? focusNode, - required bool isFocused, - KeyEventResult Function(FocusNode, KeyEvent)? onKeyEvent, - VoidCallback? onTap, - VoidCallback? onLongPress, - double borderRadius = FocusTheme.defaultBorderRadius, - double focusScale = FocusTheme.focusScale, - bool useFocusGlow = false, - bool delegateFocusBorder = false, - Size? glowSize, - required Widget child, - }) { - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - // In touch mode, no item ever shows focus effects — skip animated wrappers - // entirely. This saves ~2 element levels per card on ARM32 Android phones. - if (!isKeyboardMode) { - final gestureWidget = (onTap != null || onLongPress != null) - ? ClickableCursor( - child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child), - ) - : child; - if (focusNode != null && onKeyEvent != null) { - return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget); - } - return gestureWidget; - } - - final duration = FocusTheme.getAnimationDuration(context); - final showFocus = isFocused && isKeyboardMode; - // Glow (full-bleed cards) renders in an overlay above siblings so it stays - // symmetric; the in-card decoration only carries the border. - Widget card = delegateFocusBorder - ? CardFocusScope(showFocus: showFocus, child: child) - : AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: FocusTheme.focusDecoration(context, isFocused: showFocus, borderRadius: borderRadius), - child: child, - ); - if (useFocusGlow) { - card = FocusGlowOverlay( - isFocused: showFocus, - borderRadius: borderRadius, - color: FocusTheme.getFocusBorderColor(context), - glowSize: glowSize, - child: card, - ); - } - - final focusedWidget = AnimatedScale( - scale: showFocus ? focusScale : 1.0, - duration: duration, - curve: Curves.easeOutCubic, - child: card, - ); - - // Wrap in GestureDetector if tap/long press handlers provided - final gestureWidget = (onTap != null || onLongPress != null) - ? ClickableCursor( - child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget), - ) - : focusedWidget; - - // Wrap in Focus if focus node and key event handler provided - if (focusNode != null && onKeyEvent != null) { - return Focus(focusNode: focusNode, onKeyEvent: onKeyEvent, child: gestureWidget); - } - - return gestureWidget; - } - - /// Builds a simple locked wrapper (no Focus widget) with scale and border decoration. - /// - /// Used by _LockedHubItemWrapper where focus is managed at a higher level. + /// Used by the hub row, the TV browse rail, the cast strip and the extras row. + /// Cards that own their focus node use [FocusableWrapper] instead; both share + /// the same chrome through [buildFocusChrome]. /// /// Parameters: /// - [context]: Build context for theming @@ -173,19 +89,40 @@ class FocusBuilders { Size? glowSize, required Widget child, }) { - return buildFocusableCard( - context: context, - focusNode: null, - isFocused: isFocused, - onKeyEvent: null, - onTap: onTap, - onLongPress: onLongPress, - borderRadius: borderRadius, - focusScale: focusScale, - useFocusGlow: useFocusGlow, - delegateFocusBorder: delegateFocusBorder, - glowSize: glowSize, - child: child, + final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + + // In touch mode, no item ever shows focus effects — skip animated wrappers + // entirely. This saves ~2 element levels per card on ARM32 Android phones. + if (!isKeyboardMode) { + return (onTap != null || onLongPress != null) + ? ClickableCursor( + child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: child), + ) + : child; + } + + final duration = FocusTheme.getAnimationDuration(context); + final focusedWidget = AnimatedScale( + scale: isFocused ? focusScale : 1.0, + duration: duration, + curve: Curves.easeOutCubic, + child: buildFocusChrome( + context, + showFocus: isFocused, + duration: duration, + borderRadius: borderRadius, + useFocusGlow: useFocusGlow, + delegateFocusBorder: delegateFocusBorder, + glowSize: glowSize, + child: child, + ), ); + + // Wrap in GestureDetector if tap/long press handlers provided + return (onTap != null || onLongPress != null) + ? ClickableCursor( + child: GestureDetector(onTap: onTap, onLongPress: onLongPress, child: focusedWidget), + ) + : focusedWidget; } } diff --git a/lib/widgets/focusable_filter_chip.dart b/lib/widgets/focusable_filter_chip.dart index 8c2afb92..feccce88 100644 --- a/lib/widgets/focusable_filter_chip.dart +++ b/lib/widgets/focusable_filter_chip.dart @@ -56,24 +56,6 @@ class _FocusableFilterChipState extends State with Focusabl @override String get debugLabel => 'filter_chip_${widget.label}'; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableFilterChip oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { return handleChipKeyEvent( node, diff --git a/lib/widgets/focusable_list_tile.dart b/lib/widgets/focusable_list_tile.dart index 04419d3f..709234b4 100644 --- a/lib/widgets/focusable_list_tile.dart +++ b/lib/widgets/focusable_list_tile.dart @@ -82,24 +82,6 @@ class _FocusableListTileState extends State with FocusableTil @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { // When hovered/focused with a custom hoverColor, use onError-style foreground @@ -212,24 +194,6 @@ class _FocusableRadioListTileState extends State> @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableRadioListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { return ClickableCursor( @@ -316,24 +280,6 @@ class _FocusableSwitchListTileState extends State @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableSwitchListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { return ClickableCursor( @@ -398,24 +344,6 @@ class _FocusableCheckboxListTileState extends State @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableCheckboxListTile oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - @override Widget build(BuildContext context) { return ClickableCursor( diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart index bbf26ed5..619107d9 100644 --- a/lib/widgets/focusable_tab_chip.dart +++ b/lib/widgets/focusable_tab_chip.dart @@ -91,24 +91,6 @@ class _FocusableTabChipState extends State with FocusableChipS @override String get debugLabel => 'tab_chip_${widget.label}'; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableTabChip oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { return handleChipKeyEvent( node, diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 1475720c..2acf8646 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -230,9 +230,6 @@ class HubSectionState extends State with MountedSetStateMixin, Skele }); } - /// Check if this hub currently has focus - bool get hasFocusedItem => _hubFocusNode.hasFocus; - /// Get the number of items in this hub int get itemCount => _totalItemCount; diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index 6f1b6cd0..c72590c4 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -1,21 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../focus/dpad_navigator.dart'; +import '../focus/dpad_reorder_mixin.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; -import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_library.dart'; import '../media/media_server_client.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; -import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/content_utils.dart'; import '../utils/dialogs.dart'; @@ -182,48 +179,24 @@ Future _handleLibraryMenuAction(BuildContext context, String action, Media } } -Future _performLibraryAction( +/// Runs a library admin action, wrapping it in progress/success/failure +/// snackbars. +/// +/// [resolveClient] picks the client flavour: `getPlexClientForLibrary` for the +/// Plex-only endpoints (scan / analyze / empty trash), `getMediaClientForLibrary` +/// for ops that exist on the backend-neutral [MediaServerClient] interface +/// (currently just refresh metadata). Both resolvers require the library's exact +/// owning server and throw the same error when it isn't available. +Future _performLibraryAction( BuildContext context, { - required MediaLibrary library, - required Future Function(PlexClient client) action, + required T Function(BuildContext context) resolveClient, + required Future Function(T client) action, required String progressMessage, required String successMessage, required String Function(Object error) failureMessage, }) async { try { - final client = context.getPlexClientForLibrary(library); - - if (context.mounted) { - showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); - } - - await action(client); - - if (context.mounted) { - showSuccessSnackBar(context, successMessage); - } - } catch (e) { - appLogger.e('Library action failed', error: e); - if (context.mounted) { - showErrorSnackBar(context, failureMessage(e)); - } - } -} - -/// Backend-neutral counterpart to [_performLibraryAction] for ops that exist -/// on the [MediaServerClient] interface (currently just refresh metadata). -/// Resolves the client through `getMediaClientForLibrary` so the action requires -/// the library's exact owning server. -Future _performMediaLibraryAction( - BuildContext context, { - required MediaLibrary library, - required Future Function(MediaServerClient client) action, - required String progressMessage, - required String successMessage, - required String Function(Object error) failureMessage, -}) async { - try { - final client = context.getMediaClientForLibrary(library); + final client = resolveClient(context); if (context.mounted) { showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); @@ -245,7 +218,7 @@ Future _performMediaLibraryAction( Future _scanLibrary(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.scanLibrary(library.id), progressMessage: t.messages.libraryScanning(title: library.title), successMessage: t.messages.libraryScanStarted(title: library.title), @@ -254,9 +227,9 @@ Future _scanLibrary(BuildContext context, MediaLibrary library) { } Future _refreshLibraryMetadata(BuildContext context, MediaLibrary library) { - return _performMediaLibraryAction( + return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getMediaClientForLibrary(library), action: (client) => client.refreshLibraryMetadata(library.id), progressMessage: t.messages.metadataRefreshing(title: library.title), successMessage: t.messages.metadataRefreshStarted(title: library.title), @@ -267,7 +240,7 @@ Future _refreshLibraryMetadata(BuildContext context, MediaLibrary library) Future _emptyLibraryTrash(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.emptyLibraryTrash(library.id), progressMessage: t.libraries.emptyingTrash(title: library.title), successMessage: t.libraries.trashEmptied(title: library.title), @@ -278,7 +251,7 @@ Future _emptyLibraryTrash(BuildContext context, MediaLibrary library) { Future _analyzeLibrary(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.analyzeLibrary(library.id), progressMessage: t.libraries.analyzing(title: library.title), successMessage: t.libraries.analysisStarted(title: library.title), @@ -309,19 +282,41 @@ class _LibraryManagementSheet extends StatefulWidget { State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState(); } -class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { +class _LibraryManagementSheetState extends State<_LibraryManagementSheet> + with DpadReorderListMixin { late List _tempLibraries; - // Keyboard navigation state - int _focusedIndex = 0; - int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button - int? _movingIndex; // Non-null when in move mode - int? _originalIndex; // Original position before move (for cancel) - List? _originalOrder; // Original order before move (for cancel) final FocusNode _listFocusNode = FocusNode(); final ScrollController _dialogScrollController = ScrollController(); final ScrollController _sheetScrollController = ScrollController(); - bool _backKeyDownSeen = false; + + // Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button. + @override + List get reorderItems => _tempLibraries; + + @override + set reorderItems(List value) => _tempLibraries = value; + + @override + int get lastReorderColumn => 2; + + /// Only the TV dialog scrolls the focused row into view; the bottom sheet + /// list is not keyboard-driven. + @override + ScrollController? get reorderScrollController => widget.isDialog ? _dialogScrollController : null; + + @override + void onReorderMoveConfirmed() => widget.onReorder(_tempLibraries); + + @override + void onReorderColumnActivated(int column, int index) { + final library = _tempLibraries[index]; + if (column == 1) { + widget.onToggleVisibility(library); + } else if (column == 2) { + _showLibraryMenuBottomSheet(context, library); + } + } @override void initState() { @@ -337,157 +332,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { super.dispose(); } - void _ensureFocusedVisible() { - if (!widget.isDialog) return; - if (!_dialogScrollController.hasClients) return; - - const double itemHeight = 72.0; // Material ListTile with subtitle - const double listTopPadding = 8.0; - final double targetTop = listTopPadding + (_focusedIndex * itemHeight); - final double targetBottom = targetTop + itemHeight; - - final double viewportTop = _dialogScrollController.offset; - final double viewportHeight = _dialogScrollController.position.viewportDimension; - final double viewportBottom = viewportTop + viewportHeight; - - // Already fully visible — skip - if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; - - // Place item at ~25% from top of viewport - final double destination = (targetTop - viewportHeight * 0.25).clamp( - 0.0, - _dialogScrollController.position.maxScrollExtent, - ); - - _dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - // Track back key down/up pairing. If focus was elsewhere during KeyDown - // (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray - // KeyUp that would incorrectly pop the dialog. Consume it instead. - if (key.isBackKey) { - if (event is KeyDownEvent) { - _backKeyDownSeen = true; - } else if (event is KeyUpEvent && !_backKeyDownSeen) { - return KeyEventResult.handled; - } - if (event is KeyUpEvent) { - _backKeyDownSeen = false; - } - } - - final backResult = handleBackKeyAction(event, () { - if (_movingIndex != null) { - // Cancel move - restore original position - setState(() { - if (_originalOrder != null) { - _tempLibraries = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - } else { - OverlaySheetController.popAdaptive(context); - } - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (!event.isActionable) return KeyEventResult.ignored; - - if (_movingIndex != null) { - // Move mode - arrows reorder the item - if (key.isUpKey && _movingIndex! > 0) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! - 1, item); - _movingIndex = _movingIndex! - 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! + 1, item); - _movingIndex = _movingIndex! + 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - // Confirm move - apply the reorder - widget.onReorder(_tempLibraries); - setState(() { - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - return KeyEventResult.handled; - } - } else { - // Navigation mode - if (key.isUpKey && _focusedIndex > 0) { - setState(() { - _focusedIndex--; - _focusedColumn = 0; // Reset to row when changing rows - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _focusedIndex < _tempLibraries.length - 1) { - setState(() { - _focusedIndex++; - _focusedColumn = 0; // Reset to row when changing rows - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isLeftKey && _focusedColumn > 0) { - setState(() => _focusedColumn--); - return KeyEventResult.handled; - } - if (key.isRightKey && _focusedColumn < 2) { - setState(() => _focusedColumn++); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - if (_focusedColumn == 0) { - // Enter move mode - setState(() { - _movingIndex = _focusedIndex; - _originalIndex = _focusedIndex; - _originalOrder = List.from(_tempLibraries); - }); - } else if (_focusedColumn == 1) { - // Toggle visibility - final library = _tempLibraries[_focusedIndex]; - widget.onToggleVisibility(library); - } else if (_focusedColumn == 2) { - // Show options menu - final library = _tempLibraries[_focusedIndex]; - _showLibraryMenuBottomSheet(context, library); - } - return KeyEventResult.handled; - } - } - - // Block d-pad keys at boundaries so focus doesn't escape the dialog - if (key.isDpadDirection) { - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - void _reorderLibraries(int oldIndex, int newIndex) { setState(() { final library = _tempLibraries.removeAt(oldIndex); @@ -527,7 +371,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { if (widget.isDialog) { return Dialog( child: PopScope( - canPop: false, // Prevent system back from double-popping; handled by _handleKeyEvent + canPop: false, // Prevent system back from double-popping; handled by handleReorderKeyEvent // ignore: no-empty-block - required callback, blocks system back on Android TV onPopInvokedWithResult: (didPop, result) {}, child: Scaffold( @@ -551,8 +395,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, - child: _buildFlatLibraryListDialog(hiddenLibraryKeys), + onKeyEvent: handleReorderKeyEvent, + child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys), ), ), ), @@ -567,7 +411,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleReorderKeyEvent, child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys), ), ), @@ -575,37 +419,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ); } - /// Build library list for dialog (TV) using ListView with scroll-into-view support - Widget _buildFlatLibraryListDialog(Set hiddenLibraryKeys) { - final showServerNames = _hasMultipleServers(); - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return ReorderableListView.builder( - scrollController: _dialogScrollController, - onReorderItem: _reorderLibraries, - itemCount: _tempLibraries.length, - padding: const EdgeInsets.symmetric(vertical: 8), - buildDefaultDragHandles: false, - itemBuilder: (context, index) { - final library = _tempLibraries[index]; - final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; - - return _buildLibraryTile( - library, - index, - hiddenLibraryKeys, - showServerName: showServerName, - isFocused: isFocused, - isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, - ); - }, - ); - } - - /// Build flat library list with a server subtitle when multiple servers are connected + /// Build flat library list with a server subtitle when multiple servers are + /// connected. The TV dialog passes [_dialogScrollController] so focused rows + /// can be scrolled into view; the bottom sheet passes its own controller. Widget _buildFlatLibraryList(ScrollController scrollController, Set hiddenLibraryKeys) { final showServerNames = _hasMultipleServers(); final isKeyboardMode = InputModeTracker.isKeyboardMode(context); @@ -619,8 +435,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { itemBuilder: (context, index) { final library = _tempLibraries[index]; final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; + final isFocused = isKeyboardMode && index == focusedIndex; + final isMoving = index == movingIndex; return _buildLibraryTile( library, index, @@ -628,7 +444,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { showServerName: showServerName, isFocused: isFocused, isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, + focusedColumn: isFocused ? focusedColumn : null, ); }, ); diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index f5505b93..f9dfcdcf 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -654,8 +654,7 @@ class _MediaCardList extends StatelessWidget { if (mi.kind == MediaKind.track) return mi.trackArtistTitle; if (mi.parentIndex != null && mi.index != null) { - final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards); - return showEp ? 'S${mi.parentIndex} E${mi.index}' : 'S${mi.parentIndex}'; + return 'S${mi.parentIndex}${_episodeNumberSuffix(mi)}'; } if (mi.displaySubtitle != null) { @@ -683,33 +682,10 @@ class _MediaCardList extends StatelessWidget { return ''; } - Widget _buildEpisodeSubtitle(BuildContext context, MediaItem mi) { - final style = Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted.withValues(alpha: 0.85), - fontSize: _subtitleFontSize, - ); - final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; - final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards); - final episodeNum = (showEp && mi.index != null) ? ' E${mi.index}' : ''; - return Row( - children: [ - if (enableDetailLinks) - _ClickableText( - text: 'S${mi.parentIndex}', - style: style, - onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline), - ) - else - ExcludeSemantics(child: Text('S${mi.parentIndex}', style: style)), - ExcludeSemantics(child: Text('$episodeNum · ', style: style)), - Expanded( - child: ExcludeSemantics( - child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: style), - ), - ), - ], - ); - } + TextStyle? _subtitleStyle(BuildContext context) => Theme.of(context).textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted.withValues(alpha: 0.85), + fontSize: _subtitleFontSize, + ); @override Widget build(BuildContext context) { @@ -798,19 +774,17 @@ class _MediaCardList extends StatelessWidget { (item as MediaItem).isEpisode && (item as MediaItem).parentIndex != null && (item as MediaItem).parentId != null) ...[ - _buildEpisodeSubtitle(context, item as MediaItem), + _buildEpisodeSubtitleRow( + context, + item as MediaItem, + style: _subtitleStyle(context), + enableDetailLinks: enableDetailLinks, + isOffline: isOffline, + ), const SizedBox(height: 4), ] else if (subtitle != null) ...[ ExcludeSemantics( - child: Text( - subtitle, - maxLines: 1, - overflow: .ellipsis, - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: tokens(context).textMuted.withValues(alpha: 0.85), - fontSize: _subtitleFontSize, - ), - ), + child: Text(subtitle, maxLines: 1, overflow: .ellipsis, style: _subtitleStyle(context)), ), const SizedBox(height: 4), ], @@ -916,33 +890,16 @@ Widget _buildPosterImage( double? knownHeight, Animation? artworkDim, }) { - String? posterUrl; - if (item is MediaPlaylist) { - posterUrl = item.displayImagePath; - - if (cardShapeOverride == CardShape.square) { - return OptimizedMediaImage( - client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), - imagePath: posterUrl, - width: knownWidth ?? double.infinity, - height: knownHeight ?? double.infinity, - fit: BoxFit.cover, - placeholder: _buildPosterLoadingPlaceholder, - fallbackIcon: Symbols.playlist_play_rounded, - imageType: ImageType.square, - localFilePath: localPosterPath, - artworkDim: artworkDim, - ); - } - - return OptimizedMediaImage.playlist( + return OptimizedMediaImage( client: isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)), - imagePath: posterUrl, + imagePath: item.displayImagePath, width: knownWidth ?? double.infinity, height: knownHeight ?? double.infinity, fit: BoxFit.cover, placeholder: _buildPosterLoadingPlaceholder, + fallbackIcon: Symbols.playlist_play_rounded, + imageType: cardShapeOverride == CardShape.square ? ImageType.square : ImageType.poster, localFilePath: localPosterPath, artworkDim: artworkDim, ); @@ -955,7 +912,7 @@ Widget _buildPosterImage( final primaryPosterUrl = item.posterThumb(mode: episodePosterMode, mixedHubContext: mixedHubContext); final posterFallbackUrl = item.posterThumbFallback(mode: episodePosterMode, mixedHubContext: mixedHubContext); final useRememberedFallback = posterFallbackUrl != null && _hasFailedPosterUrl(primaryPosterUrl); - posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl; + final posterUrl = useRememberedFallback ? posterFallbackUrl : primaryPosterUrl; final mediaClient = isOffline ? null : context.tryGetMediaClientWithFallback(serverIdOrNull(item.serverId)); final fallbackIcon = _mediaPosterFallbackIcon(item); final imageType = switch (cardShapeOverride) { @@ -965,77 +922,53 @@ Widget _buildPosterImage( null => MediaImageHelper.cardImageType(item, episodePosterMode, mixedHubContext: mixedHubContext), }; + OptimizedMediaImage buildImage( + String? path, + ImageType type, { + String? localFilePath, + Widget Function(BuildContext, String, dynamic)? errorWidget, + }) => OptimizedMediaImage( + client: mediaClient, + imagePath: path, + width: knownWidth ?? double.infinity, + height: knownHeight ?? double.infinity, + fit: BoxFit.cover, + placeholder: _buildPosterLoadingPlaceholder, + fallbackIcon: fallbackIcon, + errorWidget: errorWidget, + imageType: type, + localFilePath: localFilePath, + artworkDim: artworkDim, + ); + + // Remember the dead primary URL so later builds go straight to the fallback. + Widget Function(BuildContext, String, dynamic)? retryWithFallback(ImageType type) { + if (posterFallbackUrl == null || useRememberedFallback) return null; + return (_, _, _) { + _rememberFailedPosterUrl(primaryPosterUrl); + return buildImage(posterFallbackUrl, type); + }; + } + Widget image; // Square 1:1 artwork for music (artists/albums/tracks) if (imageType == ImageType.square) { - image = OptimizedMediaImage( - client: mediaClient, - imagePath: posterUrl, - width: knownWidth ?? double.infinity, - height: knownHeight ?? double.infinity, - fit: BoxFit.cover, - placeholder: _buildPosterLoadingPlaceholder, - fallbackIcon: fallbackIcon, - errorWidget: posterFallbackUrl == null || useRememberedFallback - ? null - : (_, _, _) { - _rememberFailedPosterUrl(primaryPosterUrl); - return OptimizedMediaImage( - client: mediaClient, - imagePath: posterFallbackUrl, - width: knownWidth ?? double.infinity, - height: knownHeight ?? double.infinity, - fit: BoxFit.cover, - placeholder: _buildPosterLoadingPlaceholder, - fallbackIcon: fallbackIcon, - imageType: ImageType.square, - artworkDim: artworkDim, - ); - }, - imageType: ImageType.square, + image = buildImage( + posterUrl, + ImageType.square, localFilePath: localPosterPath, - artworkDim: artworkDim, + errorWidget: retryWithFallback(ImageType.square), ); } else if (imageType == ImageType.thumb) { // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) - image = OptimizedMediaImage.thumb( - client: mediaClient, - imagePath: posterUrl, - width: knownWidth ?? double.infinity, - height: knownHeight ?? double.infinity, - fit: BoxFit.cover, - placeholder: _buildPosterLoadingPlaceholder, - fallbackIcon: fallbackIcon, - localFilePath: localPosterPath, - artworkDim: artworkDim, - ); + image = buildImage(posterUrl, ImageType.thumb, localFilePath: localPosterPath); } else { - image = OptimizedMediaImage.poster( - client: mediaClient, - imagePath: posterUrl, - width: knownWidth ?? double.infinity, - height: knownHeight ?? double.infinity, - fit: BoxFit.cover, - placeholder: _buildPosterLoadingPlaceholder, - fallbackIcon: fallbackIcon, - errorWidget: posterFallbackUrl == null || useRememberedFallback - ? null - : (_, _, _) { - _rememberFailedPosterUrl(primaryPosterUrl); - return OptimizedMediaImage.poster( - client: mediaClient, - imagePath: posterFallbackUrl, - width: knownWidth ?? double.infinity, - height: knownHeight ?? double.infinity, - fit: BoxFit.cover, - placeholder: _buildPosterLoadingPlaceholder, - fallbackIcon: fallbackIcon, - artworkDim: artworkDim, - ); - }, + image = buildImage( + posterUrl, + ImageType.poster, localFilePath: localPosterPath, - artworkDim: artworkDim, + errorWidget: retryWithFallback(ImageType.poster), ); } @@ -1114,29 +1047,19 @@ class _MediaCardHelpers { // For episodes, show "S# · Episode Title" with clickable season link if (mi.isEpisode && mi.parentIndex != null) { - final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; - final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards); - final episodeSuffix = (showEp && mi.index != null) ? ' E${mi.index}' : ''; if (enableDetailLinks && mi.parentId != null) { - return Row( - children: [ - _ClickableText( - text: 'S${mi.parentIndex}', - style: subtitleStyle, - onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline), - ), - ExcludeSemantics(child: Text('$episodeSuffix · ', style: subtitleStyle)), - Expanded( - child: ExcludeSemantics( - child: Text(episodeTitle, maxLines: 1, overflow: .ellipsis, style: subtitleStyle), - ), - ), - ], + return _buildEpisodeSubtitleRow( + context, + mi, + style: subtitleStyle, + enableDetailLinks: true, + isOffline: isOffline, ); } + final episodeTitle = mi.displaySubtitle ?? mi.displayTitle; return ExcludeSemantics( child: Text( - 'S${mi.parentIndex}$episodeSuffix · $episodeTitle', + 'S${mi.parentIndex}${_episodeNumberSuffix(mi)} · $episodeTitle', maxLines: 1, overflow: .ellipsis, style: subtitleStyle, @@ -1169,6 +1092,41 @@ class _MediaCardHelpers { } } +/// "S# E# · Episode title" with the season number linking to the season. +Widget _buildEpisodeSubtitleRow( + BuildContext context, + MediaItem mi, { + required TextStyle? style, + required bool enableDetailLinks, + required bool isOffline, +}) { + final seasonLabel = 'S${mi.parentIndex}'; + return Row( + children: [ + if (enableDetailLinks) + _ClickableText( + text: seasonLabel, + style: style, + onTap: () => _navigateToFocusedDetail(context, mi, isOffline: isOffline), + ) + else + ExcludeSemantics(child: Text(seasonLabel, style: style)), + ExcludeSemantics(child: Text('${_episodeNumberSuffix(mi)} · ', style: style)), + Expanded( + child: ExcludeSemantics( + child: Text(mi.displaySubtitle ?? mi.displayTitle, maxLines: 1, overflow: .ellipsis, style: style), + ), + ), + ], + ); +} + +/// Empty unless [SettingsService.showEpisodeNumberOnCards] is on. +String _episodeNumberSuffix(MediaItem mi) { + final showEp = SettingsService.instance.read(SettingsService.showEpisodeNumberOnCards); + return (showEp && mi.index != null) ? ' E${mi.index}' : ''; +} + /// Whether the card renders any pointer detail link for this item. bool _hasPointerDetailLinks(MediaItem mi) { if (_hasClickableTitle(mi)) return true; diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 3923beb1..9b937f4b 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -23,6 +23,7 @@ import '../services/offline_watch_sync_service.dart'; import '../services/playlist_items_loader.dart'; import '../services/watch_actions.dart'; import '../models/transcode_quality_preset.dart'; +import '../utils/content_utils.dart'; import '../utils/download_version_utils.dart'; import '../utils/download_utils.dart'; import '../utils/quality_preset_labels.dart'; @@ -295,15 +296,13 @@ class MediaContextMenuState extends State { _MenuAction(value: 'delete', icon: Symbols.delete_rounded, label: t.common.delete, destructive: true), ); } else { - // Music (artist/album/track) playback + navigation actions. Play is - // always offered — the shared music_navigation helpers surface the - // "not supported yet" notice while the stub service is bound. Queue - // insertion only exists once a real playback engine is available. + // Music (artist/album/track) playback + navigation actions. Queue + // insertion only exists where a playback session is bound. final isMusicKind = mediaKind != null && mediaKind.isMusic; if (isMusicKind) { menuActions.add(_MenuAction(value: 'music_play', icon: Symbols.play_arrow_rounded, label: t.common.play)); - final musicAvailable = context.read()?.isAvailable ?? false; + final musicAvailable = context.read() != null; if (musicAvailable) { menuActions.add( _MenuAction(value: 'music_play_next', icon: Symbols.playlist_play_rounded, label: t.music.playNext), @@ -1060,22 +1059,11 @@ class MediaContextMenuState extends State { await playTrackWithAlbumContext(context, item); return; } - // Availability gate before the container fetch so the stub costs no - // server round-trip. - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - List tracks; - try { - tracks = await _musicTracksForItem(item); - } catch (_) { - if (!service.isPlayIntentCurrent(intent)) return; - rethrow; - } - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - await playTracks( + // No onError: a failed container fetch falls through to this menu's own + // error boundary, which logs it and shows the snackbar. + await playFetchedTracks( context, - tracks: tracks, + fetch: () => _musicTracksForItem(item), playContext: MusicPlayContext( id: item.id, title: item.displayTitle, @@ -1086,8 +1074,8 @@ class MediaContextMenuState extends State { Future _handleMusicEnqueue(BuildContext context, {required bool playNext}) async { final service = context.read(); - // Menu entries are hidden on the stub; defensive re-check. - if (service == null || !service.isAvailable) return; + // Menu entries are hidden without a session; defensive re-check. + if (service == null) return; final queueSessionRevision = service.queueSessionRevision; List tracks; try { @@ -1146,53 +1134,29 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - if (result == '_create_new') { - final playlistName = await showTextInputDialog( - context, + await _addItemToContainer( + context, + kind: 'playlist', + item: item, + client: client, + result: result, + createPrompt: ( title: t.playlists.create, - labelText: t.playlists.playlistName, - hintText: t.playlists.enterPlaylistName, - ); - - if (playlistName == null || playlistName.isEmpty || !context.mounted) { - return; - } - - appLogger.d('Creating playlist "$playlistName" seeded with item ${item.id}'); - final newPlaylist = await client.createPlaylist(title: playlistName, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (newPlaylist != null) { - appLogger.d('Successfully created playlist: ${newPlaylist.title}'); - showSuccessSnackBar(context, t.playlists.created); - // Trigger refresh of playlists tab - LibraryRefreshNotifier().notifyPlaylistsChanged(); - } else { - appLogger.e('Failed to create playlist - API returned null'); - showErrorSnackBar(context, t.playlists.errorCreating); - } - } - } else { - appLogger.d('Adding item ${item.id} to playlist $result'); - final success = await client.addToPlaylist(playlistId: result, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (success) { - appLogger.d('Successfully added item(s) to playlist $result'); - showSuccessSnackBar(context, t.playlists.itemAdded); - // Trigger refresh of playlists tab - LibraryRefreshNotifier().notifyPlaylistsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, result); - } else { - appLogger.e('Failed to add item(s) to playlist $result - API returned false'); - showErrorSnackBar(context, t.playlists.errorAdding); - } - } - } + label: t.playlists.playlistName, + hint: t.playlists.enterPlaylistName, + ), + create: (name) => client.createPlaylist(title: name, items: [item]), + createdLog: (playlist) => 'Successfully created playlist: ${playlist.title}', + eagerSyncId: (_) => null, + add: () => client.addToPlaylist(playlistId: result, items: [item]), + messages: ( + created: t.playlists.created, + createError: t.playlists.errorCreating, + added: t.playlists.itemAdded, + addError: t.playlists.errorAdding, + ), + notifyChanged: () => LibraryRefreshNotifier().notifyPlaylistsChanged(), + ); } catch (e, stackTrace) { appLogger.e('Error in add to playlist flow', error: e, stackTrace: stackTrace); if (context.mounted) { @@ -1250,59 +1214,34 @@ class MediaContextMenuState extends State { if (result == null || !context.mounted) return; - if (result == '_create_new') { - final collectionName = await showTextInputDialog( - context, + await _addItemToContainer( + context, + kind: 'collection', + item: item, + client: client, + result: result, + createPrompt: ( title: t.common.createNew, - labelText: t.collections.collectionName, - hintText: t.collections.enterCollectionName, - ); - - if (collectionName == null || collectionName.isEmpty || !context.mounted) { - return; - } - - appLogger.d('Creating collection "$collectionName" seeded with item ${item.id}'); - final newCollectionId = await client.createCollection( + label: t.collections.collectionName, + hint: t.collections.enterCollectionName, + ), + create: (name) => client.createCollection( libraryId: resolvedLibraryId, - title: collectionName, + title: name, items: [item], itemKind: itemKind, - ); - - if (!context.mounted) return; - - if (context.mounted) { - if (newCollectionId != null) { - appLogger.d('Successfully created collection with ID: $newCollectionId'); - showSuccessSnackBar(context, t.collections.created); - // Trigger refresh of collections tab - LibraryRefreshNotifier().notifyCollectionsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, newCollectionId); - } else { - appLogger.e('Failed to create collection - API returned null'); - showErrorSnackBar(context, t.collections.errorAddingToCollection); - } - } - } else { - appLogger.d('Adding item ${item.id} to collection $result'); - final success = await client.addToCollection(collectionId: result, items: [item]); - - if (!context.mounted) return; - - if (context.mounted) { - if (success) { - appLogger.d('Successfully added item(s) to collection $result'); - showSuccessSnackBar(context, t.collections.addedToCollection); - // Trigger refresh of collections tab - LibraryRefreshNotifier().notifyCollectionsChanged(); - _triggerEagerSyncIfRuleExists(context, client.serverId, result); - } else { - appLogger.e('Failed to add item(s) to collection $result - API returned false'); - showErrorSnackBar(context, t.collections.errorAddingToCollection); - } - } - } + ), + createdLog: (id) => 'Successfully created collection with ID: $id', + eagerSyncId: (id) => id, + add: () => client.addToCollection(collectionId: result, items: [item]), + messages: ( + created: t.collections.created, + createError: t.collections.errorAddingToCollection, + added: t.collections.addedToCollection, + addError: t.collections.errorAddingToCollection, + ), + notifyChanged: () => LibraryRefreshNotifier().notifyCollectionsChanged(), + ); } catch (e, stackTrace) { appLogger.e('Error in add to collection flow', error: e, stackTrace: stackTrace); if (context.mounted) { @@ -1311,6 +1250,70 @@ class MediaContextMenuState extends State { } } + /// Create-or-add tail shared by the "Add to playlist" and "Add to collection" + /// flows. [result] is the picker selection: an existing container id, or the + /// `_create_new` sentinel to prompt for a name and create one via [create]. + /// [eagerSyncId] maps a freshly created container to the id to eager-sync, or + /// `null` to skip it. + Future _addItemToContainer( + BuildContext context, { + required String kind, + required MediaItem item, + required MediaServerClient client, + required String result, + required ({String title, String label, String hint}) createPrompt, + required Future Function(String name) create, + required String Function(T created) createdLog, + required String? Function(T created) eagerSyncId, + required Future Function() add, + required ({String created, String createError, String added, String addError}) messages, + required VoidCallback notifyChanged, + }) async { + if (result == '_create_new') { + final name = await showTextInputDialog( + context, + title: createPrompt.title, + labelText: createPrompt.label, + hintText: createPrompt.hint, + ); + + if (name == null || name.isEmpty || !context.mounted) return; + + appLogger.d('Creating $kind "$name" seeded with item ${item.id}'); + final created = await create(name); + + if (!context.mounted) return; + + if (created != null) { + appLogger.d(createdLog(created)); + showSuccessSnackBar(context, messages.created); + notifyChanged(); + final syncId = eagerSyncId(created); + if (syncId != null) { + _triggerEagerSyncIfRuleExists(context, client.serverId, syncId); + } + } else { + appLogger.e('Failed to create $kind - API returned null'); + showErrorSnackBar(context, messages.createError); + } + } else { + appLogger.d('Adding item ${item.id} to $kind $result'); + final success = await add(); + + if (!context.mounted) return; + + if (success) { + appLogger.d('Successfully added item(s) to $kind $result'); + showSuccessSnackBar(context, messages.added); + notifyChanged(); + _triggerEagerSyncIfRuleExists(context, client.serverId, result); + } else { + appLogger.e('Failed to add item(s) to $kind $result - API returned false'); + showErrorSnackBar(context, messages.addError); + } + } + } + Future _showRatingSheet(BuildContext context, MediaItem item, MediaServerClient client) async { if (!mounted) return; // Presented from the menu's own context so a screen-level @@ -1402,29 +1405,15 @@ class MediaContextMenuState extends State { Future _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async { // Match PlaylistDetailScreen: fail the availability gate before paying // for a full playlist fetch, then hand the tracks to the music session. - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - - List tracks; - try { - tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id); - } catch (e, st) { - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - return; - } - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - if (tracks.isEmpty) { - showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); - return; - } - - await playTracks( + await playFetchedTracks( context, - tracks: tracks, + fetch: () => fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id), playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist), + onError: (e, st) { + appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + }, + onEmpty: () => showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems), shuffle: shuffle, ); } @@ -1512,7 +1501,7 @@ class MediaContextMenuState extends State { } /// Handle download collection action — opens the same sync/one-time dialog - /// as playlists, wired to [showCollectionDownloadOptionsAndQueue]. + /// as playlists, wired to [showListDownloadOptionsAndQueue]. Future _handleDownloadCollection(BuildContext context) async { final collection = _mediaItem!; final downloadProvider = Provider.of(context, listen: false); @@ -1527,9 +1516,10 @@ class MediaContextMenuState extends State { ); if (!context.mounted) return; - final result = await showCollectionDownloadOptionsAndQueue( + final result = await showListDownloadOptionsAndQueue( context, - collectionMetadata: collection, + rootMetadata: collection, + targetType: ContentTypes.collection, items: items, client: client, downloadProvider: downloadProvider, @@ -1571,9 +1561,10 @@ class MediaContextMenuState extends State { serverName: playlist.serverName, ); - final result = await showPlaylistDownloadOptionsAndQueue( + final result = await showListDownloadOptionsAndQueue( context, - playlistMetadata: playlistMetadata, + rootMetadata: playlistMetadata, + targetType: ContentTypes.playlist, items: items, client: client, downloadProvider: downloadProvider, diff --git a/lib/widgets/music/track_row.dart b/lib/widgets/music/track_row.dart index b0e2c72b..aba4bcf5 100644 --- a/lib/widgets/music/track_row.dart +++ b/lib/widgets/music/track_row.dart @@ -123,24 +123,6 @@ class _TrackRowState extends State with ContextMenuTapMixin, @override FocusNode? get widgetFocusNode => widget.focusNode; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(TrackRow oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - void _handleFocusChange(bool hasFocus) { setState(() { _hasFocus = hasFocus; diff --git a/lib/widgets/oauth_proxy_dialog.dart b/lib/widgets/oauth_proxy_dialog.dart index 2b497c92..26170cf7 100644 --- a/lib/widgets/oauth_proxy_dialog.dart +++ b/lib/widgets/oauth_proxy_dialog.dart @@ -1,17 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:material_symbols_icons/symbols.dart'; import 'package:qr_flutter/qr_flutter.dart'; -import 'package:url_launcher/url_launcher.dart'; import '../i18n/strings.g.dart'; -import '../focus/focusable_button.dart'; -import '../focus/focusable_wrapper.dart'; import '../services/trackers/oauth_proxy_client.dart'; import '../utils/snackbar_helper.dart'; -import 'app_icon.dart'; -import 'dialog_action_button.dart'; -import 'loading_indicator_box.dart'; +import 'pending_auth_dialog.dart'; /// Sign-in dialog for OAuth-proxy flows (MAL, AniList). /// @@ -25,10 +19,6 @@ class OAuthProxyDialog extends StatelessWidget { const OAuthProxyDialog({super.key, required this.start, required this.serviceName, required this.onCancel}); - Future _open() async { - await launchUrl(Uri.parse(start.url), mode: LaunchMode.externalApplication); - } - Future _copyUrl(BuildContext context) async { await Clipboard.setData(ClipboardData(text: start.url)); if (!context.mounted) return; @@ -38,81 +28,42 @@ class OAuthProxyDialog extends StatelessWidget { @override Widget build(BuildContext context) { final theme = Theme.of(context); - return AlertDialog( - title: Text(t.services.oauthProxy.title(service: serviceName)), - content: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text(t.services.oauthProxy.body, style: theme.textTheme.bodyMedium), - const SizedBox(height: 16), - // QrImageView doesn't support intrinsic sizing; wrap in SizedBox so - // AlertDialog's IntrinsicWidth walk sees a concrete width. - Center( - child: SizedBox.square( - dimension: 220, - child: ClipRRect( - borderRadius: BorderRadius.circular(8), - child: QrImageView(data: start.url, size: 220, version: QrVersions.auto, backgroundColor: Colors.white), - ), - ), - ), - const SizedBox(height: 16), - FocusableWrapper( - onSelect: () => _copyUrl(context), - semanticLabel: t.services.oauthProxy.copyUrl, - semanticValue: start.url, - descendantsAreFocusable: false, - borderRadius: 8, - useBackgroundFocus: true, - child: InkWell( - canRequestFocus: false, - onTap: () => _copyUrl(context), + return PendingAuthDialog( + title: t.services.oauthProxy.title(service: serviceName), + body: t.services.oauthProxy.body, + url: start.url, + openLabel: t.services.oauthProxy.openToSignIn(service: serviceName), + onCancel: onCancel, + children: [ + // QrImageView doesn't support intrinsic sizing; wrap in SizedBox so + // AlertDialog's IntrinsicWidth walk sees a concrete width. + Center( + child: SizedBox.square( + dimension: 220, + child: ClipRRect( borderRadius: BorderRadius.circular(8), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - child: Text( - start.url, - style: theme.textTheme.bodySmall?.copyWith( - fontFamily: 'monospace', - color: theme.colorScheme.onSurfaceVariant, - ), - textAlign: TextAlign.center, - ), - ), + child: QrImageView(data: start.url, size: 220, version: QrVersions.auto, backgroundColor: Colors.white), ), ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: FocusableButton( - onPressed: _open, - useBackgroundFocus: true, - child: FilledButton.icon( - icon: const AppIcon(Symbols.open_in_new_rounded), - label: Text(t.services.oauthProxy.openToSignIn(service: serviceName)), - onPressed: _open, - ), - ), - ), - const SizedBox(height: 16), - Row( - children: [ - const LoadingIndicatorBox(size: 16), - const SizedBox(width: 12), - Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)), - ], - ), - ], - ), - actions: [ - DialogActionButton( - onPressed: () { - onCancel(); - Navigator.of(context).pop(); - }, - label: t.common.cancel, ), + const SizedBox(height: 16), + CopyTapRegion( + onCopy: () => _copyUrl(context), + semanticLabel: t.services.oauthProxy.copyUrl, + semanticValue: start.url, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Text( + start.url, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: theme.colorScheme.onSurfaceVariant, + ), + textAlign: TextAlign.center, + ), + ), + ), + const SizedBox(height: 8), ], ); } diff --git a/lib/widgets/overlay_sheet.dart b/lib/widgets/overlay_sheet.dart index 78470c5e..a14db069 100644 --- a/lib/widgets/overlay_sheet.dart +++ b/lib/widgets/overlay_sheet.dart @@ -99,7 +99,15 @@ class OverlaySheetController { /// Re-focus the first focusable descendant within the sheet. /// Useful after internal page changes via setState. void refocus() { - _state._refocus(); + _state._autoFocus(clearSelectSuppression: false); + } + + /// Sizing applied when a caller supplies no explicit constraints: capped + /// width on desktop, three quarters of the screen height everywhere. + static BoxConstraints _defaultSheetConstraints(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final isDesktop = size.width > 600; + return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); } /// Show a sheet using the overlay system if available, otherwise fall back @@ -129,13 +137,7 @@ class OverlaySheetController { } // Apply the same default constraints the overlay system uses so sheets // shown without an OverlaySheetHost still have sensible sizing on desktop. - final effectiveConstraints = - constraints ?? - () { - final size = MediaQuery.sizeOf(context); - final isDesktop = size.width > 600; - return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); - }(); + final effectiveConstraints = constraints ?? _defaultSheetConstraints(context); openSheetCount.value++; try { return await showModalBottomSheet( @@ -146,6 +148,7 @@ class OverlaySheetController { constraints: effectiveConstraints, backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface, barrierColor: Colors.black54, + isDismissible: barrierDismissible, isScrollControlled: isScrollControlled, showDragHandle: showDragHandle, ); @@ -188,28 +191,16 @@ class OverlaySheetController { showDragHandle: showDragHandle, ); } - final effectiveConstraints = - constraints ?? - () { - final size = MediaQuery.sizeOf(context); - final isDesktop = size.width > 600; - return BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); - }(); BackKeyCoordinator.clear(); - openSheetCount.value++; - try { - return await showModalBottomSheet( - context: context, - builder: (context) => SafeArea(top: false, child: builder(context)), - constraints: effectiveConstraints, - backgroundColor: backgroundColor ?? Theme.of(context).colorScheme.surface, - isDismissible: barrierDismissible, - isScrollControlled: isScrollControlled, - showDragHandle: showDragHandle, - ); - } finally { - openSheetCount.value--; - } + return showAdaptive( + context, + builder: builder, + constraints: constraints, + backgroundColor: backgroundColor, + barrierDismissible: barrierDismissible, + isScrollControlled: isScrollControlled, + showDragHandle: showDragHandle, + ); } /// Close the sheet entirely. Uses overlay controller if available, @@ -457,7 +448,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr return _lastPointerPosition?.dx; } - void _autoFocus() { + void _autoFocus({bool clearSelectSuppression = true}) { final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false); // First post-frame: the FocusScope is now built and the node is attached. @@ -486,33 +477,13 @@ class _OverlaySheetHostState extends State with SingleTickerPr // select inside the sheet from being eaten). // - Long press: key still held → keep flag so KeyRepeat/KeyUp events // from the long press are correctly suppressed. - if (!HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) { + if (clearSelectSuppression && !HardwareKeyboard.instance.logicalKeysPressed.any((k) => k.isSelectKey)) { SelectKeyUpSuppressor.clearSuppression(); } }); }); } - void _refocus() { - final focusDescendant = InputModeTracker.isKeyboardMode(context, listen: false); - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_isOpen) return; - _sheetFocusScopeNode.requestFocus(); - if (!focusDescendant) return; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !_isOpen) return; - final topEntry = _pageStack.isNotEmpty ? _pageStack.last : null; - final initialNode = topEntry?.initialFocusNode; - if (initialNode != null && initialNode.context != null) { - initialNode.requestFocus(); - } else { - _focusFirstDescendant(); - } - }); - }); - } - void _focusFirstDescendant() { final descendants = _sheetFocusScopeNode.traversalDescendants.toList(); if (descendants.isNotEmpty) { @@ -634,8 +605,7 @@ class _OverlaySheetHostState extends State with SingleTickerPr final isTV = PlatformDetector.isTV(); final showHandle = _showDragHandle && !isTV && !isTop; - final effectiveConstraints = - _constraints ?? BoxConstraints(maxWidth: isDesktop ? 700 : double.infinity, maxHeight: size.height * 0.75); + final effectiveConstraints = _constraints ?? OverlaySheetController._defaultSheetConstraints(context); // Slide direction depends on alignment: bottom sheets slide up, top sheets slide down. // Use a pixel transform instead of FractionalTranslation so mouse-tracker diff --git a/lib/widgets/pending_auth_dialog.dart b/lib/widgets/pending_auth_dialog.dart new file mode 100644 index 00000000..f47d1a56 --- /dev/null +++ b/lib/widgets/pending_auth_dialog.dart @@ -0,0 +1,119 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; +import 'package:url_launcher/url_launcher.dart'; + +import '../focus/focusable_button.dart'; +import '../focus/focusable_wrapper.dart'; +import '../i18n/strings.g.dart'; +import 'app_icon.dart'; +import 'dialog_action_button.dart'; +import 'loading_indicator_box.dart'; + +/// Shell for the "waiting for out-of-band authorization" dialogs. +/// +/// Shows [body], the service-specific [children], a button that launches [url] +/// in the browser, and a "waiting for authorization…" spinner while the poll +/// loop runs. Dismissing calls [onCancel] so the provider can abort the poll. +class PendingAuthDialog extends StatelessWidget { + final String title; + final String body; + + /// Sits between the body text and the launch button, and carries its own + /// trailing spacing. + final List children; + final String url; + final String openLabel; + final VoidCallback onCancel; + + const PendingAuthDialog({ + super.key, + required this.title, + required this.body, + required this.children, + required this.url, + required this.openLabel, + required this.onCancel, + }); + + Future _open() async { + await launchUrl(Uri.parse(url), mode: LaunchMode.externalApplication); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return AlertDialog( + title: Text(title), + content: Column( + mainAxisSize: .min, + crossAxisAlignment: .start, + children: [ + Text(body, style: theme.textTheme.bodyMedium), + const SizedBox(height: 16), + ...children, + SizedBox( + width: double.infinity, + child: FocusableButton( + onPressed: _open, + useBackgroundFocus: true, + child: FilledButton.icon( + icon: const AppIcon(Symbols.open_in_new_rounded), + label: Text(openLabel), + onPressed: _open, + ), + ), + ), + const SizedBox(height: 16), + Row( + children: [ + const LoadingIndicatorBox(size: 16), + const SizedBox(width: 12), + Expanded(child: Text(t.services.deviceCode.waitingForAuthorization, style: theme.textTheme.bodySmall)), + ], + ), + ], + ), + actions: [ + DialogActionButton( + onPressed: () { + onCancel(); + Navigator.of(context).pop(); + }, + label: t.common.cancel, + ), + ], + ); + } +} + +/// Tap/D-pad target that copies the value it displays to the clipboard. +class CopyTapRegion extends StatelessWidget { + final VoidCallback onCopy; + final String semanticLabel; + + /// Announced after [semanticLabel] so screen readers read out the value that + /// will be copied instead of just the action. + final String? semanticValue; + final Widget child; + + const CopyTapRegion({ + super.key, + required this.onCopy, + required this.semanticLabel, + this.semanticValue, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return FocusableWrapper( + onSelect: onCopy, + semanticLabel: semanticLabel, + semanticValue: semanticValue, + descendantsAreFocusable: false, + useBackgroundFocus: true, + borderRadius: 8, + child: InkWell(canRequestFocus: false, onTap: onCopy, borderRadius: BorderRadius.circular(8), child: child), + ); + } +} diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index e1b8c74e..b12727fd 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -12,20 +11,18 @@ import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; +import '../models/catalog/catalog_item.dart'; import '../providers/trackers_provider.dart'; -import '../providers/trakt_account_provider.dart'; -import '../services/trackers/anilist/anilist_tracker.dart'; -import '../services/trackers/mal/mal_tracker.dart'; -import '../services/trackers/simkl/simkl_tracker.dart'; +import '../screens/settings/tracker_service_info.dart'; import '../services/trackers/tracker.dart'; import '../services/trackers/tracker_constants.dart'; import '../services/trackers/tracker_id_resolver.dart'; -import '../services/trakt/trakt_scrobble_service.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'backend_badge.dart'; import 'bottom_sheet_header.dart'; +import 'catalog_source_logo.dart'; import 'clickable_cursor.dart'; class RatingBottomSheet extends StatefulWidget { @@ -92,9 +89,10 @@ class _RatingBottomSheetState extends State { final size = MediaQuery.sizeOf(context); final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74); - return Consumer2( - builder: (context, trakt, trackers, _) { - final allTrackerSources = _trackerSources(trakt, trackers); + // Trakt's account provider is watched by [_trackerSources] via `context`. + return Consumer( + builder: (context, trackers, _) { + final allTrackerSources = _trackerSources(context); final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList(); _updateTrackerSourceMap(trackerSources); _resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected; @@ -227,7 +225,7 @@ class _RatingBottomSheetState extends State { return _RatingRow( focusNode: focusNode, autofocus: autofocus, - leading: _TrackerLogo(source.logoAsset), + leading: CatalogSourceLogo(source.logoSource, size: 24), title: source.title, subtitle: source.username != null ? t.services.connectedAs(username: source.username!) : source.connectedLabel, loading: loading, @@ -247,58 +245,20 @@ class _RatingBottomSheetState extends State { ); } - List<_TrackerRatingSource> _trackerSources(TraktAccountProvider trakt, TrackersProvider trackers) { - final sources = <_TrackerRatingSource>[]; - if (trakt.isConnected) { - sources.add( + /// Snapshot of every connected tracker, in the shared display order. Must be + /// called from a build so the provider reads register a dependency. + List<_TrackerRatingSource> _trackerSources(BuildContext context) => [ + for (final info in TrackerServiceInfo.all) + if (info.isConnected(context)) _TrackerRatingSource( - service: TrackerService.trakt, - title: t.trakt.title, - username: trakt.username, + service: info.service, + title: info.displayName, + username: info.username(context), connectedLabel: t.trakt.connected, - logoAsset: 'assets/trakt_circlemark.svg', - ratingSource: TraktScrobbleService.instance, + logoSource: info.logoSource, + ratingSource: info.ratingSource, ), - ); - } - if (trackers.isMalConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.mal, - title: t.services.names.mal, - username: trackers.malUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/mal_mark.svg', - ratingSource: MalTracker.instance, - ), - ); - } - if (trackers.isAnilistConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.anilist, - title: t.services.names.anilist, - username: trackers.anilistUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/anilist_mark.svg', - ratingSource: AnilistTracker.instance, - ), - ); - } - if (trackers.isSimklConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.simkl, - title: t.services.names.simkl, - username: trackers.simklUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/simkl_mark.svg', - ratingSource: SimklTracker.instance, - ), - ); - } - return sources; - } + ]; void _updateTrackerSourceMap(List<_TrackerRatingSource> sources) { _trackerSourcesByKey @@ -618,7 +578,7 @@ class _TrackerRatingSource { final String title; final String? username; final String connectedLabel; - final String logoAsset; + final CatalogSourceId logoSource; final TrackerRatingSource ratingSource; const _TrackerRatingSource({ @@ -626,7 +586,7 @@ class _TrackerRatingSource { required this.title, required this.username, required this.connectedLabel, - required this.logoAsset, + required this.logoSource, required this.ratingSource, }); } @@ -889,15 +849,3 @@ class _FavoriteControl extends StatelessWidget { ); } } - -class _TrackerLogo extends StatelessWidget { - final String asset; - - const _TrackerLogo(this.asset); - - @override - Widget build(BuildContext context) { - final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; - return SvgPicture.asset(asset, width: 24, height: 24, theme: SvgTheme(currentColor: color)); - } -} diff --git a/lib/widgets/search_input_field.dart b/lib/widgets/search_input_field.dart new file mode 100644 index 00000000..78e726e3 --- /dev/null +++ b/lib/widgets/search_input_field.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../focus/focusable_button.dart'; +import '../focus/focusable_text_field.dart'; +import 'app_icon.dart'; +import 'pill_input_decoration.dart'; + +/// The pill search field the search screens put above their results, with the +/// clear affordance that appears once there is text: RIGHT out of the field +/// lands on it, LEFT goes back, and both escape down into the results. +/// +/// [onBack] stays null unless the host wants the back key — a pushed route +/// needs it for its own pop. +class SearchInputField extends StatefulWidget { + final TextEditingController controller; + final FocusNode focusNode; + final String hintText; + + /// Names the clear button's focus node. + final String debugLabel; + + final TvTextInputController? tvTextInputController; + final VoidCallback? onNavigateLeft; + final VoidCallback? onNavigateDown; + final VoidCallback? onEditingComplete; + final VoidCallback? onBack; + + const SearchInputField({ + super.key, + required this.controller, + required this.focusNode, + required this.hintText, + required this.debugLabel, + this.tvTextInputController, + this.onNavigateLeft, + this.onNavigateDown, + this.onEditingComplete, + this.onBack, + }); + + @override + State createState() => _SearchInputFieldState(); +} + +class _SearchInputFieldState extends State { + late final FocusNode _clearFocusNode = FocusNode(debugLabel: '${widget.debugLabel}.clear'); + + @override + void dispose() { + _clearFocusNode.dispose(); + super.dispose(); + } + + void _clearSearch() { + widget.controller.clear(); + widget.focusNode.requestFocus(); + } + + @override + Widget build(BuildContext context) { + final hasText = widget.controller.text.isNotEmpty; + return Padding( + padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16), + child: Stack( + alignment: Alignment.centerRight, + children: [ + FocusableTextField( + controller: widget.controller, + focusNode: widget.focusNode, + tvTextInputController: widget.tvTextInputController, + textInputAction: TextInputAction.search, + onNavigateLeft: widget.onNavigateLeft, + onNavigateRight: hasText ? _clearFocusNode.requestFocus : null, + onNavigateDown: widget.onNavigateDown, + onEditingComplete: widget.onEditingComplete, + onBack: widget.onBack, + decoration: pillInputDecoration( + context, + hintText: widget.hintText, + prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1), + suffixIcon: hasText ? const SizedBox(width: 48) : null, + ), + ), + if (hasText) + FocusableButton( + focusNode: _clearFocusNode, + onPressed: _clearSearch, + onNavigateLeft: widget.focusNode.requestFocus, + onNavigateDown: widget.onNavigateDown, + autoScroll: false, + child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart index f8af24c2..bdf62aab 100644 --- a/lib/widgets/setting_tile.dart +++ b/lib/widgets/setting_tile.dart @@ -13,8 +13,46 @@ import 'settings_section.dart'; /// Eliminates the field-mirror + setState + manual reload pattern that used to /// surround every settings row. -class _TileBase { - static SettingsService get _svc => SettingsService.instance; +/// Shared commit path for every tile: persist [value] under [pref], then hand +/// it to the tile's optional [onAfterWrite] callback. +Future _writeAndNotify(Pref pref, T value, FutureOr Function(T)? onAfterWrite) async { + await SettingsService.instance.write(pref, value); + if (onAfterWrite != null) await onAfterWrite(value); +} + +/// Shared scaffold for the tiles that render a tappable settings row: same +/// leading icon, title style and row density everywhere. [trailing] defaults +/// to the chevron used by every row that opens a dialog. +class _SettingRow extends StatelessWidget { + final IconData icon; + final String title; + final Widget? subtitle; + final Widget? trailing; + final VoidCallback onTap; + final FocusNode? focusNode; + + const _SettingRow({ + required this.icon, + required this.title, + required this.onTap, + this.subtitle, + this.trailing, + this.focusNode, + }); + + @override + Widget build(BuildContext context) { + return FocusableListTile( + focusNode: focusNode, + leading: AppIcon(icon, fill: 1), + title: Text(title, style: settingsOptionTitleStyle(context)), + subtitle: subtitle, + trailing: trailing ?? const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: onTap, + dense: settingsRowDense(context), + visualDensity: settingsRowVisualDensity(context), + ); + } } /// SwitchListTile bound to a [Pref]. @@ -40,9 +78,8 @@ class SettingSwitchTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), + valueListenable: SettingsService.instance.listenable(pref), builder: (_, value, _) => FocusableSwitchListTile( focusNode: focusNode, secondary: AppIcon(icon, fill: 1), @@ -51,13 +88,7 @@ class SettingSwitchTile extends StatelessWidget { value: value, dense: settingsRowDense(context), visualDensity: settingsRowVisualDensity(context), - onChanged: enabled - ? (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - } - : null, + onChanged: enabled ? (v) => _writeAndNotify(pref, v, onAfterWrite) : null, ), ); } @@ -86,15 +117,13 @@ class SettingNavigationTile extends StatelessWidget { @override Widget build(BuildContext context) { - return FocusableListTile( + return _SettingRow( focusNode: focusNode, - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + icon: icon, + title: title, subtitle: subtitle != null ? Text(subtitle!) : null, trailing: AppIcon(trailingIcon, fill: 1), onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), ); } } @@ -126,16 +155,12 @@ class SettingNumberTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, value, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showNumericInputDialog( context: context, title: title, @@ -144,11 +169,7 @@ class SettingNumberTile extends StatelessWidget { min: min, max: max, currentValue: value, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); @@ -156,16 +177,12 @@ class SettingNumberTile extends StatelessWidget { } /// ListTile that opens [showSelectionDialog] and writes the chosen value. -/// [encode]/[decode] map between the [Pref] storage type and the option -/// type [T] (e.g. enum-stored-as-string preset → [TranscodeQualityPreset]). -class SettingSelectionTile extends StatelessWidget { - final Pref pref; +class SettingSelectionTile extends StatelessWidget { + final Pref pref; final IconData icon; final String title; final String Function(T) subtitleBuilder; final List> options; - final T Function(S) decode; - final S Function(T) encode; final FutureOr Function(T)? onAfterWrite; const SettingSelectionTile({ @@ -175,39 +192,28 @@ class SettingSelectionTile extends StatelessWidget { required this.title, required this.subtitleBuilder, required this.options, - required this.decode, - required this.encode, this.onAfterWrite, }); @override Widget build(BuildContext context) { - final svc = _TileBase._svc; - return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, raw, _) { - final value = decode(raw); - return FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), - subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), - onTap: () async { - final picked = await showSelectionDialog( - context: context, - title: title, - options: options, - currentValue: value, - ); - if (picked == null) return; - await svc.write(pref, encode(picked)); - final callback = onAfterWrite; - if (callback != null) await callback(picked); - }, - ); - }, + return ValueListenableBuilder( + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, + subtitle: Text(subtitleBuilder(value)), + onTap: () async { + final picked = await showSelectionDialog( + context: context, + title: title, + options: options, + currentValue: value, + ); + if (picked == null) return; + await _writeAndNotify(pref, picked, onAfterWrite); + }, + ), ); } } @@ -233,42 +239,30 @@ class SettingRegexTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, value, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, subtitle: Text(subtitle), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showRegexInputDialog( context: context, title: title, currentValue: value, defaultValue: defaultValue, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); } } -/// SegmentedSetting bound to a [Pref]. Use [encode]/[decode] when the -/// stored type differs from the segment type (e.g. bool stored, segments -/// over enum). -class SettingSegmentedTile extends StatelessWidget { - final Pref pref; +/// SegmentedSetting bound to a [Pref]. +class SettingSegmentedTile extends StatelessWidget { + final Pref pref; final IconData icon; final String title; final List> segments; - final T Function(S) decode; - final S Function(T) encode; final FutureOr Function(T)? onAfterWrite; const SettingSegmentedTile({ @@ -277,30 +271,20 @@ class SettingSegmentedTile extends StatelessWidget { required this.icon, required this.title, required this.segments, - required this.decode, - required this.encode, this.onAfterWrite, }); @override Widget build(BuildContext context) { - final svc = _TileBase._svc; - return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, raw, _) { - final value = decode(raw); - return SegmentedSetting( - icon: icon, - title: title, - segments: segments, - selected: value, - onChanged: (v) async { - await svc.write(pref, encode(v)); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, - ); - }, + return ValueListenableBuilder( + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => SegmentedSetting( + icon: icon, + title: title, + segments: segments, + selected: value, + onChanged: (v) => _writeAndNotify(pref, v, onAfterWrite), + ), ); } } @@ -325,12 +309,11 @@ class SettingColorTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, hex, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, hex, _) => _SettingRow( + icon: icon, + title: title, subtitle: subtitle != null ? Text(subtitle!) : null, trailing: Container( width: 28, @@ -341,17 +324,11 @@ class SettingColorTile extends StatelessWidget { border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), ), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showColorInputDialog( context: context, title: title, currentHex: hex, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart index 8d8fd6fc..84dcd37a 100644 --- a/lib/widgets/settings_section.dart +++ b/lib/widgets/settings_section.dart @@ -62,13 +62,6 @@ class SettingsGroup extends StatelessWidget { this.margin = const EdgeInsets.symmetric(horizontal: 16), }); - BorderRadius _radiusFor(int i, MonoTokens t) { - return BorderRadius.vertical( - top: Radius.circular(i == 0 ? t.radiusLg : t.radiusXs), - bottom: Radius.circular(i == children.length - 1 ? t.radiusLg : t.radiusXs), - ); - } - @override Widget build(BuildContext context) { final t = tokens(context); @@ -85,7 +78,7 @@ class SettingsGroup extends StatelessWidget { Material( color: t.surface, clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: _radiusFor(i, t)), + shape: RoundedRectangleBorder(borderRadius: groupItemRadii(context, i, children.length)), child: children[i], ), ], diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 53b96d54..dabc5cf7 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -54,6 +54,20 @@ final class _LibraryItemRow extends _LibraryNavRow { const _LibraryItemRow({required super.section, required this.library, this.showServerName = false}); } +/// SELECT activates the rail row, RIGHT hands off to the content area. +KeyEventResult _handleRailItemKey(KeyEvent event, {required VoidCallback onSelect, VoidCallback? onNavigateRight}) { + if (event is! KeyDownEvent) return KeyEventResult.ignored; + if (event.logicalKey.isSelectKey) { + onSelect(); + return KeyEventResult.handled; + } + if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { + onNavigateRight(); + return KeyEventResult.handled; + } + return KeyEventResult.ignored; +} + /// Reusable navigation rail item widget that handles focus, selection, and interaction class NavigationRailItem extends StatelessWidget { final IconData icon; @@ -63,6 +77,9 @@ class NavigationRailItem extends StatelessWidget { /// Playing item's equalizer). Should be at most [iconSize] tall/wide. final Widget? iconWidget; final Widget label; + + /// Widget rendered after the [label] (e.g. a section header's chevron). + final Widget? trailing; final bool isSelected; final bool isCollapsed; final bool useSimpleLayout; @@ -74,6 +91,11 @@ class NavigationRailItem extends StatelessWidget { final double horizontalPadding; final bool suppressSelectedBackground; + /// Background tint while keyboard-focused, and its stronger variant used + /// when the item also shows its selected background. + final double focusAlpha; + final double selectedFocusAlpha; + /// Called when RIGHT arrow is pressed to navigate to content area. final VoidCallback? onNavigateRight; @@ -83,6 +105,7 @@ class NavigationRailItem extends StatelessWidget { this.selectedIcon, this.iconWidget, required this.label, + this.trailing, required this.isSelected, this.isCollapsed = false, this.useSimpleLayout = false, @@ -93,6 +116,8 @@ class NavigationRailItem extends StatelessWidget { this.iconSize = 22, this.horizontalPadding = 17, this.suppressSelectedBackground = false, + this.focusAlpha = 0.12, + this.selectedFocusAlpha = 0.15, this.onNavigateRight, }); @@ -108,18 +133,7 @@ class NavigationRailItem extends StatelessWidget { return Focus( focusNode: focusNode, autofocus: autofocus, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - onTap(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight && onNavigateRight != null) { - onNavigateRight!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + onKeyEvent: (node, event) => _handleRailItemKey(event, onSelect: onTap, onNavigateRight: onNavigateRight), child: Material( color: Colors.transparent, child: InkWell( @@ -129,8 +143,10 @@ class NavigationRailItem extends StatelessWidget { child: Container( decoration: BoxDecoration( color: () { - if (isCollapsed) return focused ? t.text.withValues(alpha: 0.12) : null; - if (focused) return t.text.withValues(alpha: showSelectedBackground ? 0.15 : 0.12); + if (isCollapsed) return focused ? t.text.withValues(alpha: focusAlpha) : null; + if (focused) { + return t.text.withValues(alpha: showSelectedBackground ? selectedFocusAlpha : focusAlpha); + } if (showSelectedBackground) return t.text.withValues(alpha: 0.1); return null; }(), @@ -162,6 +178,7 @@ class NavigationRailItem extends StatelessWidget { return AnimatedOpacity(opacity: opacity, duration: t.fast, child: label); }(), ), + ?trailing, ], ), ), @@ -975,107 +992,44 @@ class SideNavigationRailState extends State with MountedSetS }) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; - final isLibrariesSelected = widget.selectedTab == NavigationTabId.libraries && widget.selectedLibraryKey == null; - final librariesFocusNode = _focusTracker.get(_kLibraries); - final showLibrariesSelectedBackground = isLibrariesSelected && !widget.isSidebarFocused; + final isLibrariesTabSelected = widget.selectedTab == NavigationTabId.libraries; final allEmpty = visibleRows.isEmpty && hiddenLibraryCount == 0; return Column( crossAxisAlignment: .start, children: [ - ListenableBuilder( - listenable: librariesFocusNode, - builder: (context, _) => Focus( - focusNode: librariesFocusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - setState(() { - _librariesExpanded = !_librariesExpanded; - }); - return KeyEventResult.handled; - } - // RIGHT arrow navigates to content area - if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) { - widget.onNavigateToContent!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: Material( - color: Colors.transparent, - child: InkWell( - canRequestFocus: false, - onTap: () { - setState(() { - _librariesExpanded = !_librariesExpanded; - }); - }, - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - child: Container( - decoration: BoxDecoration( - color: () { - final showFocus = librariesFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); - if (isCollapsed) return showFocus ? t.text.withValues(alpha: 0.08) : null; - if (showLibrariesSelectedBackground) return t.text.withValues(alpha: 0.1); - if (showFocus) return t.text.withValues(alpha: 0.08); - return null; - }(), - borderRadius: BorderRadius.circular(tokens(context).radiusMd), - ), - clipBehavior: Clip.hardEdge, - child: UnconstrainedBox( - alignment: .centerLeft, - constrainedAxis: Axis.vertical, - clipBehavior: Clip.hardEdge, - child: SizedBox( - width: expandedWidth - 24, - child: Padding( - padding: .symmetric(vertical: 12, horizontal: itemHorizontalPadding), - child: Row( - children: [ - AppIcon( - Symbols.video_library_rounded, - fill: 1, - size: 22, - color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, - ), - const SizedBox(width: 11), - Expanded( - child: AnimatedOpacity( - opacity: isCollapsed ? 0.0 : 1.0, - duration: tokens(context).fast, - child: Text( - Translations.of(context).navigation.libraries, - style: TextStyle( - fontSize: 14, - fontWeight: widget.selectedTab == NavigationTabId.libraries - ? FontWeight.w600 - : FontWeight.w400, - color: widget.selectedTab == NavigationTabId.libraries ? t.text : t.textMuted, - ), - ), - ), - ), - AnimatedOpacity( - opacity: isCollapsed ? 0.0 : 1.0, - duration: tokens(context).fast, - child: AppIcon( - _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, - fill: 1, - size: 20, - color: t.textMuted, - ), - ), - ], - ), - ), - ), - ), - ), - ), + NavigationRailItem( + icon: Symbols.video_library_rounded, + label: Text( + Translations.of(context).navigation.libraries, + style: TextStyle( + fontSize: 14, + fontWeight: isLibrariesTabSelected ? FontWeight.w600 : FontWeight.w400, + color: isLibrariesTabSelected ? t.text : t.textMuted, ), ), + trailing: AnimatedOpacity( + opacity: isCollapsed ? 0.0 : 1.0, + duration: tokens(context).fast, + child: AppIcon( + _librariesExpanded ? Symbols.expand_less_rounded : Symbols.expand_more_rounded, + fill: 1, + size: 20, + color: t.textMuted, + ), + ), + isSelected: isLibrariesTabSelected, + isCollapsed: isCollapsed, + onTap: () => setState(() => _librariesExpanded = !_librariesExpanded), + focusNode: _focusTracker.get(_kLibraries), + borderRadius: BorderRadius.circular(tokens(context).radiusMd), + horizontalPadding: itemHorizontalPadding, + // A selected library owns the highlight; the header only shows it + // for the bare Libraries tab. + suppressSelectedBackground: widget.isSidebarFocused || widget.selectedLibraryKey != null, + focusAlpha: 0.08, + selectedFocusAlpha: 0.1, + onNavigateRight: widget.onNavigateToContent, ), TweenAnimationBuilder( @@ -1222,18 +1176,8 @@ class SideNavigationRailState extends State with MountedSetS listenable: focusNode, builder: (context, _) => Focus( focusNode: focusNode, - onKeyEvent: (node, event) { - if (event is! KeyDownEvent) return KeyEventResult.ignored; - if (event.logicalKey.isSelectKey) { - onToggle(); - return KeyEventResult.handled; - } - if (event.logicalKey == LogicalKeyboardKey.arrowRight && widget.onNavigateToContent != null) { - widget.onNavigateToContent!(); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, + onKeyEvent: (node, event) => + _handleRailItemKey(event, onSelect: onToggle, onNavigateRight: widget.onNavigateToContent), child: Material( color: Colors.transparent, child: InkWell( diff --git a/lib/widgets/toolbar_scrim.dart b/lib/widgets/toolbar_scrim.dart new file mode 100644 index 00000000..50214105 --- /dev/null +++ b/lib/widgets/toolbar_scrim.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; + +import 'rasterized_gradient.dart'; + +/// Top-edge fade behind a toolbar that floats over content, keeping its +/// glyphs legible against artwork without a solid chrome bar. +/// +/// The fade is pure black on dark schemes — a tinted surface reads as haze +/// over backdrop artwork — and the scheme surface otherwise. [child] is laid +/// out below the status bar with the standard chrome insets. +class ToolbarScrim extends StatelessWidget { + const ToolbarScrim({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final statusBarHeight = MediaQuery.paddingOf(context).top; + final colorScheme = Theme.of(context).colorScheme; + final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; + return RasterizedGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + overlayColor.withValues(alpha: 0.7), + overlayColor.withValues(alpha: 0.5), + overlayColor.withValues(alpha: 0.3), + Colors.transparent, + ], + stops: const [0.0, 0.3, 0.6, 1.0], + ), + child: Padding( + padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16), + child: child, + ), + ); + } +} diff --git a/lib/widgets/tv_color_picker.dart b/lib/widgets/tv_color_picker.dart index 84846f1c..29cfd1f0 100644 --- a/lib/widgets/tv_color_picker.dart +++ b/lib/widgets/tv_color_picker.dart @@ -3,14 +3,9 @@ import 'package:flutter/services.dart'; import '../i18n/strings.g.dart'; import '../focus/dpad_navigator.dart'; -import '../focus/focus_theme.dart'; import '../focus/focusable_text_field.dart'; -import '../focus/input_mode_tracker.dart'; -import '../focus/key_repeat_helper.dart'; import '../mixins/controller_disposer_mixin.dart'; -import '../theme/mono_tokens.dart'; -import 'package:material_symbols_icons/symbols.dart'; -import 'app_icon.dart'; +import 'tv_number_spinner.dart'; /// A TV-friendly color picker using HSV sliders for D-pad navigation. /// @@ -106,6 +101,31 @@ class _TvColorPickerState extends State with ControllerDisposerMi widget.onColorChanged(color); } + Widget _channelRow({ + required String label, + required String semanticLabel, + required int value, + required int max, + required String suffix, + required ValueChanged onChanged, + bool autofocus = false, + }) { + return TvNumberSpinner( + label: label, + semanticLabel: semanticLabel, + value: value, + min: 0, + max: max, + step: 5, + suffix: suffix, + autofocus: autofocus, + onConfirm: widget.onConfirm, + onChanged: onChanged, + verticalKeysAdjustValue: false, + density: TvNumberSpinnerDensity.compact, + ); + } + @override Widget build(BuildContext context) { final currentColor = _currentColor(); @@ -123,46 +143,37 @@ class _TvColorPickerState extends State with ControllerDisposerMi ), ), const SizedBox(height: 16), - _ColorChannelRow( + _channelRow( label: 'H', semanticLabel: Translations.of(context).accessibility.hue, value: _hue, - min: 0, max: 360, - step: 5, suffix: '°', autofocus: true, - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _hue = v); _onChannelChanged(); }, ), const SizedBox(height: 8), - _ColorChannelRow( + _channelRow( label: 'S', semanticLabel: Translations.of(context).accessibility.saturation, value: _saturation, - min: 0, max: 100, - step: 5, suffix: '%', - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _saturation = v); _onChannelChanged(); }, ), const SizedBox(height: 8), - _ColorChannelRow( + _channelRow( label: 'V', semanticLabel: Translations.of(context).accessibility.brightness, value: _value, - min: 0, max: 100, - step: 5, suffix: '%', - onConfirm: widget.onConfirm, onChanged: (v) { setState(() => _value = v); _onChannelChanged(); @@ -185,211 +196,3 @@ class _TvColorPickerState extends State with ControllerDisposerMi ); } } - -/// A horizontal channel row for a single HSV component. -/// -/// LEFT/RIGHT adjust the value (with repeat timer for held keys). -/// UP/DOWN are ignored so focus traverses normally between rows. -class _ColorChannelRow extends StatefulWidget { - final String label; - final String semanticLabel; - final int value; - final int min; - final int max; - final int step; - final String suffix; - final bool autofocus; - final ValueChanged onChanged; - - /// Called when the user presses SELECT to confirm. - final VoidCallback? onConfirm; - - const _ColorChannelRow({ - required this.label, - required this.semanticLabel, - required this.value, - required this.min, - required this.max, - required this.step, - required this.suffix, - required this.onChanged, - this.autofocus = false, - this.onConfirm, - }); - - @override - State<_ColorChannelRow> createState() => _ColorChannelRowState(); -} - -class _ColorChannelRowState extends State<_ColorChannelRow> with KeyRepeatHelper<_ColorChannelRow> { - late FocusNode _focusNode; - bool _isFocused = false; - - @override - void initState() { - super.initState(); - _focusNode = FocusNode(debugLabel: 'ColorChannel_${widget.label}'); - } - - @override - void dispose() { - stopRepeat(); - _focusNode.dispose(); - super.dispose(); - } - - void _increment() { - final newValue = widget.value + widget.step; - if (newValue <= widget.max) { - widget.onChanged(newValue); - } - } - - void _decrement() { - final newValue = widget.value - widget.step; - if (newValue >= widget.min) { - widget.onChanged(newValue); - } - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - // Let UP/DOWN pass through for focus traversal between rows - if (key.isUpKey || key.isDownKey) { - return KeyEventResult.ignored; - } - - if (event is KeyDownEvent) { - if (key.isSelectKey && widget.onConfirm != null) { - widget.onConfirm!(); - return KeyEventResult.handled; - } - if (key.isRightKey) { - startRepeat(_increment); - return KeyEventResult.handled; - } else if (key.isLeftKey) { - startRepeat(_decrement); - return KeyEventResult.handled; - } - } else if (event is KeyRepeatEvent) { - // Consume repeat events for LEFT/RIGHT so they don't escape - // to the focus system as traversal actions. The repeat timer - // from KeyDown already handles value repetition. - if (key.isRightKey || key.isLeftKey) { - return KeyEventResult.handled; - } - } else if (event is KeyUpEvent) { - if (key.isRightKey || key.isLeftKey) { - stopRepeat(); - return KeyEventResult.handled; - } - } - - return KeyEventResult.ignored; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final tokens = theme.extension(); - final canDecrement = widget.value > widget.min; - final canIncrement = widget.value < widget.max; - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return Focus( - focusNode: _focusNode, - autofocus: widget.autofocus, - descendantsAreFocusable: false, - onFocusChange: (hasFocus) { - setState(() => _isFocused = hasFocus); - if (!hasFocus) stopRepeat(); - }, - onKeyEvent: _handleKeyEvent, - child: AnimatedContainer( - duration: tokens?.fast ?? const Duration(milliseconds: 150), - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(FocusTheme.defaultBorderRadius)), - border: Border.fromBorderSide( - BorderSide( - color: _isFocused && isKeyboardMode ? FocusTheme.getFocusBorderColor(context) : Colors.transparent, - width: FocusTheme.focusBorderWidth, - ), - ), - ), - child: Row( - children: [ - SizedBox( - width: 24, - child: Text(widget.label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)), - ), - const SizedBox(width: 8), - _ChannelButton( - icon: Symbols.remove_rounded, - onPressed: canDecrement ? _decrement : null, - semanticLabel: Translations.of(context).accessibility.decreaseValue(label: widget.semanticLabel), - ), - const SizedBox(width: 8), - Container( - constraints: const BoxConstraints(minWidth: 56), - alignment: .center, - child: Text('${widget.value}${widget.suffix}', style: theme.textTheme.titleMedium), - ), - const SizedBox(width: 8), - _ChannelButton( - icon: Symbols.add_rounded, - onPressed: canIncrement ? _increment : null, - semanticLabel: Translations.of(context).accessibility.increaseValue(label: widget.semanticLabel), - ), - ], - ), - ), - ); - } -} - -class _ChannelButton extends StatelessWidget { - final IconData icon; - final VoidCallback? onPressed; - final String semanticLabel; - - const _ChannelButton({required this.icon, required this.onPressed, required this.semanticLabel}); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final isEnabled = onPressed != null; - - return Semantics( - label: semanticLabel, - button: true, - enabled: isEnabled, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: const BorderRadius.all(Radius.circular(20)), - child: Container( - width: 36, - height: 36, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, - ), - child: Center( - child: AppIcon( - icon, - size: 18, - fill: 1, - color: isEnabled - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), - ), - ), - ), - ); - } -} diff --git a/lib/widgets/tv_number_spinner.dart b/lib/widgets/tv_number_spinner.dart index 3b6b0b9b..234802f6 100644 --- a/lib/widgets/tv_number_spinner.dart +++ b/lib/widgets/tv_number_spinner.dart @@ -11,10 +11,20 @@ import 'app_icon.dart'; import '../theme/mono_tokens.dart'; import 'package:material_symbols_icons/symbols.dart'; +/// Size variant for [TvNumberSpinner]. +enum TvNumberSpinnerDensity { + /// Large buttons with long-press repeat, for a spinner that owns the dialog. + standard, + + /// Smaller buttons sized to sit in a stack of labelled rows. + compact, +} + /// A TV-friendly number spinner with +/- buttons for D-pad navigation. /// -/// Displays a value with decrement/increment buttons on either side. -/// Supports keyboard repeat for faster value changes when holding arrows. +/// Displays a value with decrement/increment buttons on either side, optionally +/// behind a leading [label]. Supports keyboard repeat for faster value changes +/// when holding arrows. class TvNumberSpinner extends StatefulWidget { final int value; @@ -27,6 +37,13 @@ class TvNumberSpinner extends StatefulWidget { /// Optional suffix text (e.g., "s" for seconds). final String? suffix; + /// Optional leading label shown before the buttons (e.g., "H" for hue). + final String? label; + + /// When set, the +/- buttons announce themselves as adjusting this value + /// instead of using the generic increase/decrease labels. + final String? semanticLabel; + final ValueChanged onChanged; /// Called when the user presses SELECT to confirm. @@ -39,6 +56,13 @@ class TvNumberSpinner extends StatefulWidget { final bool autofocus; + /// When false, UP/DOWN are left alone so focus traverses between rows, and + /// held LEFT/RIGHT repeat events are consumed so they don't escape to the + /// focus system as traversal actions. + final bool verticalKeysAdjustValue; + + final TvNumberSpinnerDensity density; + const TvNumberSpinner({ super.key, required this.value, @@ -47,9 +71,13 @@ class TvNumberSpinner extends StatefulWidget { required this.onChanged, this.step = 1, this.suffix, + this.label, + this.semanticLabel, this.autofocus = false, this.onConfirm, this.onCancel, + this.verticalKeysAdjustValue = true, + this.density = TvNumberSpinnerDensity.standard, }); @override @@ -63,7 +91,8 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< @override void initState() { super.initState(); - _focusNode = FocusNode(debugLabel: 'TvNumberSpinner'); + final label = widget.label; + _focusNode = FocusNode(debugLabel: label == null ? 'TvNumberSpinner' : 'TvNumberSpinner_$label'); } @override @@ -89,6 +118,7 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; + final vertical = widget.verticalKeysAdjustValue; if (widget.onCancel != null) { final backResult = handleBackKeyAction(event, widget.onCancel!); @@ -97,20 +127,32 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< } } + // Let UP/DOWN pass through for focus traversal between rows. + if (!vertical && (key.isUpKey || key.isDownKey)) { + return KeyEventResult.ignored; + } + if (event is KeyDownEvent) { if (key.isSelectKey && widget.onConfirm != null) { widget.onConfirm!(); return KeyEventResult.handled; } - if (key.isUpKey || key.isRightKey) { + if ((vertical && key.isUpKey) || key.isRightKey) { startRepeat(_increment); return KeyEventResult.handled; - } else if (key.isDownKey || key.isLeftKey) { + } else if ((vertical && key.isDownKey) || key.isLeftKey) { startRepeat(_decrement); return KeyEventResult.handled; } + } else if (event is KeyRepeatEvent) { + // The repeat timer from KeyDown already handles value repetition, so + // swallow the OS repeats that would otherwise traverse focus. Only + // needed when UP/DOWN traverse — otherwise no direction escapes. + if (!vertical && (key.isRightKey || key.isLeftKey)) { + return KeyEventResult.handled; + } } else if (event is KeyUpEvent) { - if (key.isUpKey || key.isRightKey || key.isDownKey || key.isLeftKey) { + if ((vertical && (key.isUpKey || key.isDownKey)) || key.isRightKey || key.isLeftKey) { stopRepeat(); return KeyEventResult.handled; } @@ -126,6 +168,11 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< final canDecrement = widget.value > widget.min; final canIncrement = widget.value < widget.max; final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final isCompact = widget.density == TvNumberSpinnerDensity.compact; + final gap = isCompact ? const SizedBox(width: 8) : const SizedBox(width: 16); + final label = widget.label; + final semanticLabel = widget.semanticLabel; + final a11y = Translations.of(context).accessibility; return Focus( focusNode: _focusNode, @@ -149,32 +196,43 @@ class _TvNumberSpinnerState extends State with KeyRepeatHelper< ), ), child: Row( - mainAxisSize: .min, - mainAxisAlignment: .center, + mainAxisSize: isCompact ? .max : .min, + mainAxisAlignment: isCompact ? .start : .center, children: [ + if (label != null) ...[ + SizedBox( + width: 24, + child: Text(label, style: theme.textTheme.titleMedium?.copyWith(fontWeight: .bold)), + ), + gap, + ], _SpinnerButton( icon: Symbols.remove_rounded, onPressed: canDecrement ? _decrement : null, - onLongPressStart: canDecrement ? () => startRepeat(_decrement) : null, - onLongPressEnd: stopRepeat, - semanticLabel: Translations.of(context).accessibility.decrease, + onLongPressStart: !isCompact && canDecrement ? () => startRepeat(_decrement) : null, + onLongPressEnd: isCompact ? null : stopRepeat, + semanticLabel: semanticLabel != null ? a11y.decreaseValue(label: semanticLabel) : a11y.decrease, + compact: isCompact, ), - const SizedBox(width: 16), + gap, Container( - constraints: const BoxConstraints(minWidth: 60), + constraints: BoxConstraints(minWidth: isCompact ? 56 : 60), alignment: .center, child: Text( - widget.suffix != null ? '${widget.value}${widget.suffix}' : '${widget.value}', - style: theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold), + '${widget.value}${widget.suffix ?? ''}', + style: isCompact + ? theme.textTheme.titleMedium + : theme.textTheme.headlineMedium?.copyWith(fontWeight: .bold), ), ), - const SizedBox(width: 16), + gap, _SpinnerButton( icon: Symbols.add_rounded, onPressed: canIncrement ? _increment : null, - onLongPressStart: canIncrement ? () => startRepeat(_increment) : null, - onLongPressEnd: stopRepeat, - semanticLabel: Translations.of(context).accessibility.increase, + onLongPressStart: !isCompact && canIncrement ? () => startRepeat(_increment) : null, + onLongPressEnd: isCompact ? null : stopRepeat, + semanticLabel: semanticLabel != null ? a11y.increaseValue(label: semanticLabel) : a11y.increase, + compact: isCompact, ), ], ), @@ -190,6 +248,7 @@ class _SpinnerButton extends StatelessWidget { final VoidCallback? onLongPressStart; final VoidCallback? onLongPressEnd; final String semanticLabel; + final bool compact; const _SpinnerButton({ required this.icon, @@ -197,45 +256,49 @@ class _SpinnerButton extends StatelessWidget { this.onLongPressStart, this.onLongPressEnd, required this.semanticLabel, + this.compact = false, }); @override Widget build(BuildContext context) { final theme = Theme.of(context); final isEnabled = onPressed != null; + final size = compact ? 36.0 : 48.0; - return Semantics( - label: semanticLabel, - button: true, - enabled: isEnabled, - child: GestureDetector( - onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null, - onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onPressed, - borderRadius: const BorderRadius.all(Radius.circular(24)), - child: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, - ), - child: Center( - child: AppIcon( - icon, - fill: 1, - color: isEnabled - ? theme.colorScheme.onPrimaryContainer - : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), - ), - ), + Widget button = Material( + color: Colors.transparent, + child: InkWell( + onTap: onPressed, + borderRadius: BorderRadius.all(Radius.circular(compact ? 20 : 24)), + child: Container( + width: size, + height: size, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: isEnabled ? theme.colorScheme.primaryContainer : theme.colorScheme.surfaceContainerHighest, + ), + child: Center( + child: AppIcon( + icon, + size: compact ? 18 : null, + fill: 1, + color: isEnabled + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.onSurfaceVariant.withValues(alpha: 0.5), ), ), ), ), ); + + if (onLongPressStart != null || onLongPressEnd != null) { + button = GestureDetector( + onLongPressStart: onLongPressStart != null ? (_) => onLongPressStart!() : null, + onLongPressEnd: onLongPressEnd != null ? (_) => onLongPressEnd!() : null, + child: button, + ); + } + + return Semantics(label: semanticLabel, button: true, enabled: isEnabled, child: button); } } diff --git a/lib/widgets/tv_spotlight_scaffold.dart b/lib/widgets/tv_spotlight_scaffold.dart index 80b775ad..b6da0798 100644 --- a/lib/widgets/tv_spotlight_scaffold.dart +++ b/lib/widgets/tv_spotlight_scaffold.dart @@ -142,3 +142,27 @@ class TvSpotlightScaffold extends StatelessWidget { ); } } + +/// Pins a toolbar to the top of the viewport across the full bleed width, +/// sliding with the sidebar so it stays put while the content box translates. +/// +/// Excluded from default focus traversal so that initial/tab-switch focus +/// lands on content (hero/rails) rather than the toolbar; its buttons stay +/// reachable via explicit UP from the content. Reads the offset aspect from +/// its own element, so a sidebar flip rebuilds only this overlay. +class TvToolbarOverlay extends StatelessWidget { + const TvToolbarOverlay({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context); + return SideNavigationBleedBuilder( + targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context), + child: ExcludeFocusTraversal(child: child), + builder: (context, animatedBleed, child) => + Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), + ); + } +} diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 0563db84..163ed362 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -23,6 +23,7 @@ import '../../models/livetv_capture_buffer.dart'; import 'models/track_controls_state.dart'; import 'player_chrome_controller.dart'; import 'widgets/content_strip.dart'; +import 'widgets/content_strip_panel.dart'; import 'widgets/live_timeline_bar.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; @@ -614,51 +615,28 @@ class DesktopVideoControlsState extends State { _buildBottomControlsContent(context, hasFrame: true), // Down arrow hint when strip content is available if (widget.useDpadNavigation && _hasStripContent) - const Positioned( - left: 0, - right: 0, - bottom: 12, - child: AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white24, size: 24), - ), + const ContentStripHint(Symbols.keyboard_arrow_down_rounded), ], ), // Content strip (TV/dpad only) — replaces normal controls if (_contentStripVisible && widget.useDpadNavigation) - Container( + ContentStripPanel( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8, top: 32), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.65), - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.42, 1.0], - ), - ), - child: Column( - mainAxisSize: .min, - children: [ - const AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20), - const SizedBox(height: 4), - ContentStrip( - key: _contentStripKey, - player: widget.player, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - serverId: widget.serverId, - canControl: _canControl, - showQueueTab: widget.showQueueTab, - onQueueItemSelected: widget.onQueueItemSelected, - onSeekRequested: widget.onSeekRequested, - onSeekCompleted: widget.onSeekCompleted, - useFocusNavigation: true, - onNavigateUp: _onContentStripNavigateUp, - onFocusActivity: widget.onFocusActivity, - ), - ], + chevron: Symbols.keyboard_arrow_up_rounded, + child: ContentStrip( + key: _contentStripKey, + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + serverId: widget.serverId, + canControl: _canControl, + showQueueTab: widget.showQueueTab, + onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, + onSeekCompleted: widget.onSeekCompleted, + useFocusNavigation: true, + onNavigateUp: _onContentStripNavigateUp, + onFocusActivity: widget.onFocusActivity, ), ), ], diff --git a/lib/widgets/video_controls/helpers/track_selection_helper.dart b/lib/widgets/video_controls/helpers/track_selection_helper.dart index e541cedf..67ad2312 100644 --- a/lib/widgets/video_controls/helpers/track_selection_helper.dart +++ b/lib/widgets/video_controls/helpers/track_selection_helper.dart @@ -2,41 +2,12 @@ import 'package:flutter/material.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../i18n/strings.g.dart'; -import '../../../mpv/mpv.dart'; import '../../../theme/mono_tokens.dart'; import '../../../utils/track_label_builder.dart'; import '../../../widgets/focusable_list_tile.dart'; class TrackSelectionHelper { - /// Get the appropriate empty message based on track type - static String getEmptyMessage() { - if (T == SubtitleTrack) { - return t.videoControls.noSubtitlesAvailable; - } else if (T == AudioTrack) { - return t.videoControls.noAudioTracksAvailable; - } - return t.videoControls.noTracksAvailable; - } - - static Widget buildEmptyState() { - return Center(child: Text(getEmptyMessage())); - } - - /// Check if "Off" is selected for a track - static bool isOffSelected(T? selectedTrack, bool Function(T track)? isOffTrack) { - return selectedTrack == null || (isOffTrack?.call(selectedTrack) ?? false); - } - - static String getTrackId(T track) { - if (track is AudioTrack) { - return track.id; - } else if (track is SubtitleTrack) { - return track.id; - } - return ''; - } - - static Widget buildOffTile({ + static Widget buildOffTile({ required BuildContext context, required bool isSelected, required VoidCallback onTap, @@ -59,7 +30,7 @@ class TrackSelectionHelper { ); } - static Widget buildTrackTile({ + static Widget buildTrackTile({ required BuildContext context, required TrackLabel label, required bool isSelected, diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 99ea3da5..32df0e97 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../widgets/app_icon.dart'; import '../../media/media_item.dart'; import '../../mpv/mpv.dart'; import '../../models/livetv_capture_buffer.dart'; @@ -12,6 +11,7 @@ import '../../i18n/strings.g.dart'; import 'player_chrome_controller.dart'; import 'widgets/circular_control_button.dart'; import 'widgets/content_strip.dart'; +import 'widgets/content_strip_panel.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; import 'widgets/live_timeline_bar.dart'; @@ -270,12 +270,7 @@ class _MobileVideoControlsState extends State with SingleTi _buildBottomBar(context), ], ), - const Positioned( - left: 0, - right: 0, - bottom: 12, - child: AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white24, size: 24), - ), + const ContentStripHint(Symbols.keyboard_arrow_up_rounded), ], ), ), @@ -290,37 +285,19 @@ class _MobileVideoControlsState extends State with SingleTi ignoring: t < 0.5, child: Opacity( opacity: (t * 2).clamp(0.0, 1.0), - child: Container( + child: ContentStripPanel( padding: const EdgeInsets.only(top: 32), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.65), - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.42, 1.0], - ), - ), - child: Column( - mainAxisSize: .min, - children: [ - const AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20), - const SizedBox(height: 4), - ContentStrip( - player: widget.player, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - canControl: widget.canControl, - serverId: widget.serverId, - showQueueTab: widget.showQueueTab, - onQueueItemSelected: widget.onQueueItemSelected, - onSeekRequested: widget.onSeekRequested, - onSeekCompleted: widget.onSeekCompleted, - ), - ], + chevron: Symbols.keyboard_arrow_down_rounded, + child: ContentStrip( + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + canControl: widget.canControl, + serverId: widget.serverId, + showQueueTab: widget.showQueueTab, + onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, + onSeekCompleted: widget.onSeekCompleted, ), ), ), diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 421384cd..2ab452cc 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -35,7 +35,6 @@ class TrackControlsState { final int audioSyncOffset; final int subtitleSyncOffset; final bool isRotationLocked; - final bool isScreenLocked; final bool isFullscreen; final bool isAlwaysOnTop; final VoidCallback? onTogglePIPMode; @@ -97,7 +96,6 @@ class TrackControlsState { this.audioSyncOffset = 0, this.subtitleSyncOffset = 0, this.isRotationLocked = false, - this.isScreenLocked = false, this.isFullscreen = false, this.isAlwaysOnTop = false, this.onTogglePIPMode, diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 0c2bb81b..9b3851b3 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -100,6 +100,35 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return KeyEventResult.ignored; } + KeyEventResult _dispatchShortcut(KeyEvent event, {VoidCallback? onSkipMarker}) { + return _keyboardService!.handleVideoPlayerKeyEvent( + event, + widget.player, + _toggleFullscreen, + _toggleSubtitles, + _nextAudioTrack, + _nextSubtitleTrack, + _nextChapter, + _previousChapter, + canControlPlayback: widget.canControl, + canNavigateMediaItems: widget.canNavigateMediaItems, + onPlayPause: () => unawaited(_playOrPause()), + onToggleShader: _toggleShader, + onSkipMarker: onSkipMarker, + onNextEpisode: widget.onNext, + onPreviousEpisode: widget.onPrevious, + onScreenshot: _showScreenshotToast, + onZoomIn: widget.onZoomIn, + onZoomOut: widget.onZoomOut, + onZoomReset: widget.onResetVideoZoom, + onVolumeUp: () => widget.volumeController.adjust(10), + onVolumeDown: () => widget.volumeController.adjust(-10), + onToggleMute: widget.volumeController.toggleMute, + onLiveSeekBy: widget.onLiveSeekBy, + onSeekRequested: widget.onSeekRequested, + ); + } + /// Global key event handler for focus-independent shortcuts (desktop only) bool _handleGlobalKeyEvent(KeyEvent event) { if (!mounted) return false; @@ -140,33 +169,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { // (e.g. after controls auto-hide). The !hasFocus guard prevents // double-handling when the Focus onKeyEvent already processes the event. if (!_focusNode.hasFocus && _keyboardService != null) { - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - canControlPlayback: widget.canControl, - canNavigateMediaItems: widget.canNavigateMediaItems, - onPlayPause: () => unawaited(_playOrPause()), - onToggleShader: _toggleShader, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - onScreenshot: _showScreenshotToast, - onZoomIn: widget.onZoomIn, - onZoomOut: widget.onZoomOut, - onZoomReset: widget.onResetVideoZoom, - onVolumeUp: () => widget.volumeController.adjust(10), - onVolumeDown: () => widget.volumeController.adjust(-10), - onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - onLiveSeekBy: widget.onLiveSeekBy, - onSeekRequested: widget.onSeekRequested, - ); + final result = _dispatchShortcut(event); if (result == KeyEventResult.handled) { _focusNode.requestFocus(); // self-heal focus return true; @@ -270,34 +273,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored; } - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - canControlPlayback: widget.canControl, - canNavigateMediaItems: widget.canNavigateMediaItems, - onPlayPause: () => unawaited(_playOrPause()), - onToggleShader: _toggleShader, - onSkipMarker: _performAutoSkip, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - onScreenshot: _showScreenshotToast, - onZoomIn: widget.onZoomIn, - onZoomOut: widget.onZoomOut, - onZoomReset: widget.onResetVideoZoom, - onVolumeUp: () => widget.volumeController.adjust(10), - onVolumeDown: () => widget.volumeController.adjust(-10), - onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - onLiveSeekBy: widget.onLiveSeekBy, - onSeekRequested: widget.onSeekRequested, - ); + final result = _dispatchShortcut(event, onSkipMarker: _performAutoSkip); if (!event.logicalKey.isNavigationKey) return result; // Never return .ignored for navigation keys — prevent leaking to previous routes. return result == KeyEventResult.ignored ? KeyEventResult.handled : result; diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index cb81cbf9..43d4a919 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -144,7 +144,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { audioSyncOffset: _audioSyncOffset, subtitleSyncOffset: _subtitleSyncOffset, isRotationLocked: _isRotationLocked, - isScreenLocked: _isScreenLocked, isFullscreen: _isFullscreen, isAlwaysOnTop: _isAlwaysOnTop, onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null, diff --git a/lib/widgets/video_controls/player_chrome_controller.dart b/lib/widgets/video_controls/player_chrome_controller.dart index 6e400647..1e6529e2 100644 --- a/lib/widgets/video_controls/player_chrome_controller.dart +++ b/lib/widgets/video_controls/player_chrome_controller.dart @@ -34,7 +34,6 @@ class PlayerChromeController extends ChangeNotifier implements ValueListenable _controlsPresented; bool get contentStripVisible => _contentStripVisible; - bool get hasVisibleHold => _holds.isNotEmpty; bool isHeld(PlayerChromeHold hold) => _holds.contains(hold); PlayerChromeFocusTarget? get pendingFocusTarget => _pendingFocusTarget; diff --git a/lib/widgets/video_controls/sheets/sheet_selection_column.dart b/lib/widgets/video_controls/sheets/sheet_selection_column.dart new file mode 100644 index 00000000..64edc8cf --- /dev/null +++ b/lib/widgets/video_controls/sheets/sheet_selection_column.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../utils/scroll_utils.dart'; +import '../../../widgets/overlay_sheet.dart'; +import 'sheet_column_header.dart'; + +/// Per-row handle handed to [SheetSelectionColumn.itemBuilder]. +abstract class SheetSelectionColumnScope { + /// Key for the row at [index]. Only the first row is keyed, so the one-time + /// initial scroll can measure a real item height. + Key? keyFor(int index); + + /// Runs an async selection: re-entrant taps are ignored while one is in + /// flight, a progress bar is shown meanwhile, and the sheet is closed once + /// [action] completes. + void runExclusive(Future Function() action); +} + +/// Shared scaffold for the selectable columns inside the video control sheets: +/// an optional header, a one-shot scroll to the selected row, the async +/// selection guard, the scrolling list, and an optional footer. +class SheetSelectionColumn extends StatefulWidget { + /// Header text, or null to omit the header entirely. + final String? headerLabel; + final int itemCount; + + /// Row to scroll into view on first build; ignored when null or <= 0. + final int? initialIndex; + final Widget Function(BuildContext context, int index, SheetSelectionColumnScope scope) itemBuilder; + final List footer; + + const SheetSelectionColumn({ + super.key, + this.headerLabel, + required this.itemCount, + required this.initialIndex, + required this.itemBuilder, + this.footer = const [], + }); + + @override + State createState() => _SheetSelectionColumnState(); +} + +class _SheetSelectionColumnState extends State implements SheetSelectionColumnScope { + final _initialScroll = InitialItemScrollController(); + bool _selectionPending = false; + + @override + void dispose() { + _initialScroll.dispose(); + super.dispose(); + } + + @override + Key? keyFor(int index) => index == 0 ? _initialScroll.firstItemKey : null; + + @override + void runExclusive(Future Function() action) => unawaited(_select(action)); + + Future _select(Future Function() action) async { + if (_selectionPending) return; + setState(() => _selectionPending = true); + try { + await action(); + if (mounted) OverlaySheetController.of(context).close(); + } finally { + if (mounted) setState(() => _selectionPending = false); + } + } + + @override + Widget build(BuildContext context) { + _initialScroll.maybeScrollTo(widget.initialIndex); + + return Column( + children: [ + if (widget.headerLabel != null) SheetColumnHeader(label: widget.headerLabel!), + if (_selectionPending) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: ListView.builder( + controller: _initialScroll.controller, + itemCount: widget.itemCount, + itemBuilder: (context, index) => widget.itemBuilder(context, index, this), + ), + ), + ...widget.footer, + ], + ); + } +} diff --git a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart index 18479e41..d193b145 100644 --- a/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart +++ b/lib/widgets/video_controls/sheets/subtitle_search_sheet.dart @@ -10,7 +10,6 @@ import '../../../focus/input_mode_tracker.dart'; import '../../../i18n/strings.g.dart'; import '../../../mixins/controller_disposer_mixin.dart'; import '../../../models/plex/plex_subtitle_search_result.dart'; -import '../../../services/plex_client.dart'; import '../../../services/settings_service.dart'; import '../../../utils/language_codes.dart'; import '../../../utils/provider_extensions.dart'; @@ -103,8 +102,7 @@ class _SubtitleSearchSheetState extends State with Controll }); try { - final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId)); - final client = neutral is PlexClient ? neutral : null; + final client = context.tryGetPlexClientForServer(ServerId(widget.serverId)); if (client == null) { if (!mounted || generation != _searchGeneration) return; setState(() => _isSearching = false); @@ -185,10 +183,7 @@ class _SubtitleSearchSheetState extends State with Controll setState(() => _downloadingKey = result.key); try { - // Same Plex-only guard as in [_search]. Don't throw if a Jellyfin - // server somehow reaches the download path. - final neutral = context.tryGetMediaClientForServer(ServerId(widget.serverId)); - final client = neutral is PlexClient ? neutral : null; + final client = context.tryGetPlexClientForServer(ServerId(widget.serverId)); if (client == null) { if (!mounted) return; setState(() => _downloadingKey = null); diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index c2342b59..b8ddea20 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -7,13 +5,12 @@ import '../../../media/media_source_info.dart'; import '../../../mpv/mpv.dart'; import '../../../services/playback_subtitle_resolver.dart'; import '../../../i18n/strings.g.dart'; -import '../../../utils/scroll_utils.dart'; import '../../../utils/track_label_builder.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import 'sheet_column_header.dart'; +import 'sheet_selection_column.dart'; import 'subtitle_search_sheet.dart'; import '../models/track_controls_state.dart'; import '../helpers/track_filter_helper.dart'; @@ -134,7 +131,7 @@ class TrackSheet extends StatelessWidget { } } -class _SourceAudioColumn extends StatefulWidget { +class _SourceAudioColumn extends StatelessWidget { final List tracks; final int? selectedStreamId; final Future Function(int) onSelected; @@ -147,157 +144,94 @@ class _SourceAudioColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_SourceAudioColumn> createState() => _SourceAudioColumnState(); -} - -class _SourceAudioColumnState extends State<_SourceAudioColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _select(int streamId) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.onSelected(streamId); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { final selectedId = _effectiveSelectedStreamId(); - final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId); - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = selectedId == null ? null : tracks.indexWhere((t) => t.id == selectedId); - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length, - itemBuilder: (context, index) { - final track = widget.tracks[index]; - final isSelected = track.id == selectedId; - return TrackSelectionHelper.buildTrackTile( - context: context, - key: index == 0 ? _initialScroll.firstItemKey : null, - label: track.label, - isSelected: isSelected, - onTap: () => unawaited(_select(track.id)), - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.audioLabel : null, + itemCount: tracks.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final track = tracks[index]; + return TrackSelectionHelper.buildTrackTile( + context: context, + key: scope.keyFor(index), + label: track.label, + isSelected: track.id == selectedId, + onTap: () => scope.runExclusive(() => onSelected(track.id)), + ); + }, ); } int? _effectiveSelectedStreamId() { - final explicit = widget.selectedStreamId; - if (explicit != null && widget.tracks.any((track) => track.id == explicit)) return explicit; - for (final track in widget.tracks) { + final explicit = selectedStreamId; + if (explicit != null && tracks.any((track) => track.id == explicit)) return explicit; + for (final track in tracks) { if (track.selected) return track.id; } return null; } } -class _SourceSubtitleColumn extends StatefulWidget { +class _SourceSubtitleColumn extends StatelessWidget { final List tracks; final TrackControlsState trackControlsState; final bool showHeader; const _SourceSubtitleColumn({required this.tracks, required this.trackControlsState, required this.showHeader}); - @override - State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState(); -} - -class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _select(PlaybackSourceSubtitleChoice choice) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.trackControlsState.onSwitchSubtitle!(choice); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { final selectedChoice = _effectiveSelectedChoice(); final selectedId = selectedChoice.sourceStreamId; - final selectedIndex = selectedChoice.isOff ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1; - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = selectedChoice.isOff ? 0 : tracks.indexWhere((t) => t.id == selectedId) + 1; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return TrackSelectionHelper.buildOffTile( - context: context, - key: _initialScroll.firstItemKey, - isSelected: selectedChoice.isOff, - onTap: () => unawaited(_select(const PlaybackSourceSubtitleChoice.off())), - ); - } + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.subtitlesLabel : null, + itemCount: tracks.length + 1, + initialIndex: selectedIndex, + footer: _buildSubtitleSearchFooter(context, trackControlsState), + itemBuilder: (context, index, scope) { + if (index == 0) { + return TrackSelectionHelper.buildOffTile( + context: context, + key: scope.keyFor(index), + isSelected: selectedChoice.isOff, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(const PlaybackSourceSubtitleChoice.off()), + ), + ); + } - final track = widget.tracks[index - 1]; - return TrackSelectionHelper.buildTrackTile( - context: context, - label: track.labelForIndex(index - 1), - isSelected: track.id == selectedId, - onTap: () => unawaited(_select(PlaybackSourceSubtitleChoice.source(track.id))), - ); - }, + final track = tracks[index - 1]; + return TrackSelectionHelper.buildTrackTile( + context: context, + label: track.labelForIndex(index - 1), + isSelected: track.id == selectedId, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(track.id)), ), - ), - ..._buildSubtitleSearchFooter(context, widget.trackControlsState), - ], + ); + }, ); } PlaybackSourceSubtitleChoice _effectiveSelectedChoice() { - final explicit = widget.trackControlsState.selectedSubtitleChoice; - if (explicit != null && (explicit.isOff || widget.tracks.any((track) => track.id == explicit.sourceStreamId))) { + final explicit = trackControlsState.selectedSubtitleChoice; + if (explicit != null && (explicit.isOff || tracks.any((track) => track.id == explicit.sourceStreamId))) { return explicit; } - for (final track in widget.tracks) { + for (final track in tracks) { if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id); } return const PlaybackSourceSubtitleChoice.off(); } } -class _AudioColumn extends StatefulWidget { +class _AudioColumn extends StatelessWidget { final List tracks; final TrackSelection selection; final Player player; @@ -312,61 +246,41 @@ class _AudioColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_AudioColumn> createState() => _AudioColumnState(); -} - -class _AudioColumnState extends State<_AudioColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final selectedId = widget.selection.audio?.id ?? ''; - final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId); - _initialScroll.maybeScrollTo(selectedIndex); + final selectedId = selection.audio?.id ?? ''; + final selectedIndex = tracks.indexWhere((t) => t.id == selectedId); - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length, - itemBuilder: (context, index) { - final track = widget.tracks[index]; - final label = TrackLabelBuilder.audioLabel( - title: track.title, - language: track.language, - codec: track.codec, - channels: track.channelsCount, - index: index, - ); - return TrackSelectionHelper.buildTrackTile( - context: context, - key: index == 0 ? _initialScroll.firstItemKey : null, - label: label, - isSelected: track.id == selectedId, - onTap: () { - widget.player.selectAudioTrack(track); - widget.onTrackChanged?.call(track); - OverlaySheetController.of(context).close(); - }, - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.audioLabel : null, + itemCount: tracks.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final track = tracks[index]; + final label = TrackLabelBuilder.audioLabel( + title: track.title, + language: track.language, + codec: track.codec, + channels: track.channelsCount, + index: index, + ); + return TrackSelectionHelper.buildTrackTile( + context: context, + key: scope.keyFor(index), + label: label, + isSelected: track.id == selectedId, + onTap: () { + player.selectAudioTrack(track); + onTrackChanged?.call(track); + OverlaySheetController.of(context).close(); + }, + ); + }, ); } } -class _SubtitleColumn extends StatefulWidget { +class _SubtitleColumn extends StatelessWidget { final List tracks; final TrackSelection selection; final Player player; @@ -385,49 +299,24 @@ class _SubtitleColumn extends StatefulWidget { this.sourceSidecars = const [], }); - @override - State<_SubtitleColumn> createState() => _SubtitleColumnState(); -} - -class _SubtitleColumnState extends State<_SubtitleColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _selectSourceSidecar(int streamId) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(streamId)); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { - final selectedSub = widget.selection.subtitle; - final secondarySub = widget.selection.secondarySubtitle; + final selectedSub = selection.subtitle; + final secondarySub = selection.secondarySubtitle; final isOffSelected = selectedSub == null || selectedSub.id == 'no'; - final hasSecondary = widget.supportsSecondary && secondarySub != null; - final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId; - final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId; + final hasSecondary = supportsSecondary && secondarySub != null; + final selectedSourceId = trackControlsState.selectedSubtitleChoice?.sourceStreamId; + final selectedSecondarySourceId = trackControlsState.selectedSecondarySubtitleStreamId; final attachedSourceSidecarIds = {}; - for (final sidecar in widget.trackControlsState.sourceSubtitleSidecars) { + for (final sidecar in trackControlsState.sourceSubtitleSidecars) { final sourceStreamId = sidecar.sourceStreamId; final uri = sidecar.track.uri; if (sourceStreamId == null || uri == null) continue; - if (widget.tracks.any((track) => track.isExternal && track.uri == uri)) { + if (tracks.any((track) => track.isExternal && track.uri == uri)) { attachedSourceSidecarIds.add(sourceStreamId); } } - final unloadedSourceSidecars = widget.sourceSidecars + final unloadedSourceSidecars = sourceSidecars .where( (track) => !attachedSourceSidecarIds.contains(track.id) && @@ -438,127 +327,121 @@ class _SubtitleColumnState extends State<_SubtitleColumn> { // +1 for "Off". Source sidecars represented by native external tracks, // plus active source IDs awaiting native discovery, are not appended. - final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1; + final itemCount = tracks.length + unloadedSourceSidecars.length + 1; - final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1; - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = isOffSelected ? null : tracks.indexWhere((t) => t.id == selectedSub.id) + 1; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: itemCount, - itemBuilder: (context, index) { - if (index == 0) { - return TrackSelectionHelper.buildOffTile( - context: context, - key: _initialScroll.firstItemKey, - isSelected: isOffSelected, - onTap: () { - // Turning off primary also clears secondary - if (hasSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - widget.player.selectSubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off); - OverlaySheetController.of(context).close(); - }, - onLongPress: widget.supportsSecondary && hasSecondary - ? () { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - : null, - onSecondaryTap: widget.supportsSecondary && hasSecondary - ? () { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - : null, - ); + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.subtitlesLabel : null, + itemCount: itemCount, + initialIndex: selectedIndex, + footer: _buildSubtitleSearchFooter(context, trackControlsState), + itemBuilder: (context, index, scope) { + if (index == 0) { + return TrackSelectionHelper.buildOffTile( + context: context, + key: scope.keyFor(index), + isSelected: isOffSelected, + onTap: () { + // Turning off primary also clears secondary + if (hasSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } - - final trackIndex = index - 1; - if (trackIndex >= widget.tracks.length) { - final sourceIndex = trackIndex - widget.tracks.length; - final sourceTrack = unloadedSourceSidecars[sourceIndex]; - return TrackSelectionHelper.buildTrackTile( - context: context, - label: sourceTrack.labelForIndex(trackIndex), - isSelected: false, - onTap: () => unawaited(_selectSourceSidecar(sourceTrack.id)), - ); - } - - final track = widget.tracks[trackIndex]; - final isPrimary = !isOffSelected && track.id == selectedSub.id; - final isSecondary = hasSecondary && track.id == secondarySub.id; - final label = TrackLabelBuilder.subtitleLabel( - title: track.title, - language: track.language, - codec: track.codec, - forced: track.isForced, - index: trackIndex, - ); - - Widget? badge; - if (widget.supportsSecondary && hasSecondary) { - if (isPrimary) { - badge = TrackSelectionHelper.buildTrackBadge(context, 1); - } else if (isSecondary) { - badge = TrackSelectionHelper.buildTrackBadge(context, 2); - } - } - - return TrackSelectionHelper.buildTrackTile( - context: context, - label: label, - isSelected: isPrimary, - badge: badge, - onTap: () { - // If tapping a track that is currently the secondary, clear secondary first - if (isSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - widget.player.selectSubtitleTrack(track); - widget.trackControlsState.onSubtitleTrackChanged?.call(track); - OverlaySheetController.of(context).close(); - }, - onLongPress: widget.supportsSecondary - ? () { - if (isSecondary) { - // Already secondary — clear it - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } else if (!isPrimary) { - // Set as secondary (don't close sheet so user sees badge update) - widget.player.selectSecondarySubtitleTrack(track); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); - } - } - : null, - onSecondaryTap: widget.supportsSecondary - ? () { - if (isSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } else if (!isPrimary) { - widget.player.selectSecondarySubtitleTrack(track); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); - } - } - : null, - ); + player.selectSubtitleTrack(SubtitleTrack.off); + trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off); + OverlaySheetController.of(context).close(); }, - ), - ), - ..._buildSubtitleSearchFooter(context, widget.trackControlsState), - ], + onLongPress: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + : null, + onSecondaryTap: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + : null, + ); + } + + final trackIndex = index - 1; + if (trackIndex >= tracks.length) { + final sourceIndex = trackIndex - tracks.length; + final sourceTrack = unloadedSourceSidecars[sourceIndex]; + return TrackSelectionHelper.buildTrackTile( + context: context, + label: sourceTrack.labelForIndex(trackIndex), + isSelected: false, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(sourceTrack.id)), + ), + ); + } + + final track = tracks[trackIndex]; + final isPrimary = !isOffSelected && track.id == selectedSub.id; + final isSecondary = hasSecondary && track.id == secondarySub.id; + final label = TrackLabelBuilder.subtitleLabel( + title: track.title, + language: track.language, + codec: track.codec, + forced: track.isForced, + index: trackIndex, + ); + + Widget? badge; + if (supportsSecondary && hasSecondary) { + if (isPrimary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 1); + } else if (isSecondary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 2); + } + } + + return TrackSelectionHelper.buildTrackTile( + context: context, + label: label, + isSelected: isPrimary, + badge: badge, + onTap: () { + // If tapping a track that is currently the secondary, clear secondary first + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + player.selectSubtitleTrack(track); + trackControlsState.onSubtitleTrackChanged?.call(track); + OverlaySheetController.of(context).close(); + }, + onLongPress: supportsSecondary + ? () { + if (isSecondary) { + // Already secondary — clear it + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + // Set as secondary (don't close sheet so user sees badge update) + player.selectSecondarySubtitleTrack(track); + trackControlsState.onSecondarySubtitleTrackChanged?.call(track); + } + } + : null, + onSecondaryTap: supportsSecondary + ? () { + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + player.selectSecondarySubtitleTrack(track); + trackControlsState.onSecondarySubtitleTrackChanged?.call(track); + } + } + : null, + ); + }, ); } } diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart index e3b4ad1e..ef978e3d 100644 --- a/lib/widgets/video_controls/sheets/version_quality_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -6,10 +6,9 @@ import '../../../i18n/strings.g.dart'; import '../../../media/media_version.dart'; import '../../../models/transcode_quality_preset.dart'; import '../../../utils/quality_preset_labels.dart'; -import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; -import 'sheet_column_header.dart'; +import 'sheet_selection_column.dart'; String versionQualityPickerTitle({required bool showVersions, required bool showQuality}) { return showQuality @@ -114,7 +113,7 @@ class VersionQualityPicker extends StatelessWidget { } } -class _VersionColumn extends StatefulWidget { +class _VersionColumn extends StatelessWidget { final List versions; final int selectedIndex; final ValueChanged onSelected; @@ -127,48 +126,27 @@ class _VersionColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_VersionColumn> createState() => _VersionColumnState(); -} - -class _VersionColumnState extends State<_VersionColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - _initialScroll.maybeScrollTo(widget.selectedIndex); - - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.versionColumnHeader), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.versions.length, - itemBuilder: (context, index) { - final version = widget.versions[index]; - final isSelected = index == widget.selectedIndex; - return _SelectionTile( - key: index == 0 ? _initialScroll.firstItemKey : null, - label: version.displayLabel, - isSelected: isSelected, - onTap: () => widget.onSelected(index), - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.versionColumnHeader : null, + itemCount: versions.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final version = versions[index]; + final isSelected = index == selectedIndex; + return _SelectionTile( + key: scope.keyFor(index), + label: version.displayLabel, + isSelected: isSelected, + onTap: () => onSelected(index), + ); + }, ); } } -class _QualityColumn extends StatefulWidget { +class _QualityColumn extends StatelessWidget { final TranscodeQualityPreset selected; final bool enabledForTranscoding; final int? sourceBitrateKbps; @@ -187,58 +165,36 @@ class _QualityColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_QualityColumn> createState() => _QualityColumnState(); -} - -class _QualityColumnState extends State<_QualityColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final presets = TranscodeQualityPreset.displayOrder; - final selectedIndex = presets.indexOf(widget.selected); - _initialScroll.maybeScrollTo(selectedIndex); + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.qualityColumnHeader : null, + itemCount: presets.length, + initialIndex: presets.indexOf(selected), + itemBuilder: (context, index, scope) { + final preset = presets[index]; + final isSelected = preset == selected; + final isOriginal = preset.isOriginal; + final enabled = isOriginal || enabledForTranscoding; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.qualityColumnHeader), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: presets.length, - itemBuilder: (context, index) { - final preset = presets[index]; - final isSelected = preset == widget.selected; - final isOriginal = preset.isOriginal; - final enabled = isOriginal || widget.enabledForTranscoding; + final trailing = qualityPresetSizeEstimate( + preset: preset, + sourceBitrateKbps: sourceBitrateKbps, + sourceDurationMs: sourceDurationMs, + sourceSizeBytes: sourceSizeBytes, + ); - final trailing = qualityPresetSizeEstimate( - preset: preset, - sourceBitrateKbps: widget.sourceBitrateKbps, - sourceDurationMs: widget.sourceDurationMs, - sourceSizeBytes: widget.sourceSizeBytes, - ); - - return _SelectionTile( - key: index == 0 ? _initialScroll.firstItemKey : null, - label: qualityPresetLabel(preset), - trailingText: trailing, - isSelected: isSelected, - enabled: enabled, - onTap: enabled ? () => widget.onSelected(preset) : null, - ); - }, - ), - ), - ], + return _SelectionTile( + key: scope.keyFor(index), + label: qualityPresetLabel(preset), + trailingText: trailing, + isSelected: isSelected, + enabled: enabled, + onTap: enabled ? () => onSelected(preset) : null, + ); + }, ); } } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 2ca95c50..06f78ade 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -12,14 +12,11 @@ import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import '../../../models/shader_preset.dart'; -import '../../../models/transcode_quality_preset.dart'; import '../../../media/playback_rate.dart'; -import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../providers/shader_provider.dart'; import '../../../services/file_picker_service.dart'; import '../../../services/settings_service.dart'; -import '../../../services/shader_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../services/video_filter_manager.dart'; import '../../../focus/focusable_wrapper.dart'; @@ -33,6 +30,7 @@ import '../../../utils/snackbar_helper.dart'; import '../../../theme/mono_tokens.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../models/track_controls_state.dart'; import '../widgets/sync_offset_control.dart'; import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; @@ -191,73 +189,17 @@ class VideoSettingsSheet extends StatefulWidget { /// Defaults to the native platform capability, but can be supplied by /// embedders whose capability is known independently of the host platform. final bool? supportsHdrControl; - final int audioSyncOffset; - final int subtitleSyncOffset; - final double videoZoomScale; - final ValueChanged? onVideoZoomChanged; - final VoidCallback? onResetVideoZoom; - /// Whether the user can control playback (false hides speed option in host-only mode). - final bool canControl; - - /// Whether this is a live TV stream (hides speed settings). - final bool isLive; - - /// Available media versions and quality controls shown inside playback settings. - final List availableVersions; - final int selectedMediaIndex; - final TranscodeQualityPreset selectedQualityPreset; - final bool serverSupportsTranscoding; - final int? sourceDurationMs; - final ValueChanged? onVersionSelected; - final ValueChanged? onQualitySelected; - - /// Optional shader service for MPV shader control - final ShaderService? shaderService; - - /// Called when shader preset changes - final VoidCallback? onShaderChanged; - - /// Whether ambient lighting is currently enabled - final bool isAmbientLightingEnabled; - - /// Called to toggle ambient lighting on/off (null if unsupported) - final VoidCallback? onToggleAmbientLighting; - - /// Called to cancel the video controls auto-hide timer. - final VoidCallback? onCancelAutoHide; - - /// Called to restart the video controls auto-hide timer. - final VoidCallback? onStartAutoHide; - - /// Called when a sync offset changes (so the parent can update its state). - final void Function(String propertyName, int offset)? onSyncOffsetChanged; + /// Shared player-control state. Every playback value and callback this sheet + /// shows (sync offsets, zoom, versions/quality, shaders, ambient lighting, + /// auto-hide) is read straight off it. + final TrackControlsState trackControlsState; const VideoSettingsSheet({ super.key, required this.player, this.supportsHdrControl, - required this.audioSyncOffset, - required this.subtitleSyncOffset, - this.videoZoomScale = 1.0, - this.onVideoZoomChanged, - this.onResetVideoZoom, - this.canControl = true, - this.isLive = false, - this.availableVersions = const [], - this.selectedMediaIndex = 0, - this.selectedQualityPreset = TranscodeQualityPreset.original, - this.serverSupportsTranscoding = false, - this.sourceDurationMs, - this.onVersionSelected, - this.onQualitySelected, - this.shaderService, - this.onShaderChanged, - this.isAmbientLightingEnabled = false, - this.onToggleAmbientLighting, - this.onCancelAutoHide, - this.onStartAutoHide, - this.onSyncOffsetChanged, + required this.trackControlsState, }); @override @@ -272,6 +214,8 @@ class _VideoSettingsSheetState extends State { String _dvConversionMode = 'auto'; int _dvConversionWriteGeneration = 0; + TrackControlsState get _state => widget.trackControlsState; + bool get _supportsHdrControl => widget.supportsHdrControl ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows); @@ -284,16 +228,16 @@ class _VideoSettingsSheetState extends State { @override void initState() { super.initState(); - _audioSyncOffset = widget.audioSyncOffset; - _subtitleSyncOffset = widget.subtitleSyncOffset; - _zoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale); + _audioSyncOffset = _state.audioSyncOffset; + _subtitleSyncOffset = _state.subtitleSyncOffset; + _zoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale); _loadDebugDvConversionMode(); } @override void didUpdateWidget(covariant VideoSettingsSheet oldWidget) { super.didUpdateWidget(oldWidget); - final nextZoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale); + final nextZoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale); if (_zoomScale != nextZoomScale) { _zoomScale = nextZoomScale; } @@ -373,19 +317,19 @@ class _VideoSettingsSheetState extends State { } else { await settings.write(SettingsService.audioSyncOffset, offset); } - widget.onSyncOffsetChanged?.call(propertyName, offset); + _state.onSyncOffsetChanged?.call(propertyName, offset); }, ), ) .whenComplete(() { sliderFocusNode.dispose(); - widget.onStartAutoHide?.call(); + _state.onStartAutoHide?.call(); }); // Cancel auto-hide after show() — the previous sheet's whenComplete // fires as a microtask and restarts the timer, so schedule our cancel // to run after that microtask. - Future.microtask(() => widget.onCancelAutoHide?.call()); + Future.microtask(() => _state.onCancelAutoHide?.call()); } void _navigateBack() { @@ -477,44 +421,44 @@ class _VideoSettingsSheetState extends State { setState(() { _zoomScale = next; }); - widget.onVideoZoomChanged?.call(next); + _state.onVideoZoomChanged?.call(next); } void _resetZoomScale() { setState(() { _zoomScale = 1.0; }); - final reset = widget.onResetVideoZoom; + final reset = _state.onResetVideoZoom; if (reset != null) { reset(); } else { - widget.onVideoZoomChanged?.call(1.0); + _state.onVideoZoomChanged?.call(1.0); } } bool get _hasVersionQuality { - return (widget.availableVersions.length > 1 || widget.serverSupportsTranscoding) && - (widget.onVersionSelected != null || widget.onQualitySelected != null); + return (_state.availableVersions.length > 1 || _state.serverSupportsTranscoding) && + (_state.onSwitchVersion != null || _state.onSwitchQualityPreset != null); } String _versionQualityTitle() { return versionQualityPickerTitle( - showVersions: widget.availableVersions.length > 1, - showQuality: widget.serverSupportsTranscoding, + showVersions: _state.availableVersions.length > 1, + showQuality: _state.serverSupportsTranscoding, ); } String _versionQualityValueText() { final values = []; - if (widget.availableVersions.length > 1) values.add(_selectedVersionLabel()); - if (widget.serverSupportsTranscoding) values.add(qualityPresetLabel(widget.selectedQualityPreset)); + if (_state.availableVersions.length > 1) values.add(_selectedVersionLabel()); + if (_state.serverSupportsTranscoding) values.add(qualityPresetLabel(_state.selectedQualityPreset)); return values.join(' / '); } String _selectedVersionLabel() { - final index = widget.selectedMediaIndex; - if (index >= 0 && index < widget.availableVersions.length) { - return widget.availableVersions[index].displayLabel; + final index = _state.selectedMediaIndex; + if (index >= 0 && index < _state.availableVersions.length) { + return _state.availableVersions[index].displayLabel; } return t.videoControls.versionColumnHeader; } @@ -526,7 +470,7 @@ class _VideoSettingsSheetState extends State { return ListView( children: [ // Playback Speed - hidden for live TV and when user cannot control playback - if (widget.canControl && !widget.isLive) + if (_state.canControl && !_state.isLive) StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, @@ -541,7 +485,7 @@ class _VideoSettingsSheetState extends State { }, ), - if (widget.onVideoZoomChanged != null || widget.onResetVideoZoom != null) + if (_state.onVideoZoomChanged != null || _state.onResetVideoZoom != null) _SettingsMenuItem( icon: Symbols.zoom_in_rounded, title: t.videoSettings.zoom, @@ -657,36 +601,36 @@ class _VideoSettingsSheetState extends State { ), // Shader Preset (MPV only) - if (widget.shaderService != null && widget.shaderService!.isSupported) + if (_state.shaderService != null && _state.shaderService!.isSupported) _SettingsMenuItem( icon: Symbols.auto_fix_high_rounded, title: t.shaders.title, - valueText: widget.shaderService!.currentPreset.id == ShaderPreset.none.id + valueText: _state.shaderService!.currentPreset.id == ShaderPreset.none.id ? t.common.off - : widget.shaderService!.currentPreset.name, - isHighlighted: widget.shaderService!.currentPreset.isEnabled, + : _state.shaderService!.currentPreset.name, + isHighlighted: _state.shaderService!.currentPreset.isEnabled, onTap: () => _navigateTo(_SettingsView.shader), ), // Ambient Lighting (MPV only) - if (widget.onToggleAmbientLighting != null) + if (_state.onToggleAmbientLighting != null) FocusableListTile( leading: AppIcon( Symbols.blur_on_rounded, fill: 1, - color: widget.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, + color: _state.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, ), title: Text(t.videoControls.ambientLighting), trailing: Switch( - value: widget.isAmbientLightingEnabled, + value: _state.isAmbientLightingEnabled, onChanged: (_) { - widget.onToggleAmbientLighting?.call(); + _state.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, activeThumbColor: Colors.amber, ), onTap: () { - widget.onToggleAmbientLighting?.call(); + _state.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, ), @@ -851,13 +795,13 @@ class _VideoSettingsSheetState extends State { Widget _buildVersionQualityView() { return VersionQualityPicker( - availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - selectedQualityPreset: widget.selectedQualityPreset, - serverSupportsTranscoding: widget.serverSupportsTranscoding, - sourceDurationMs: widget.sourceDurationMs, - onVersionSelected: (index) => widget.onVersionSelected?.call(index), - onQualitySelected: (preset) => widget.onQualitySelected?.call(preset), + availableVersions: _state.availableVersions, + selectedMediaIndex: _state.selectedMediaIndex, + selectedQualityPreset: _state.selectedQualityPreset, + serverSupportsTranscoding: _state.serverSupportsTranscoding, + sourceDurationMs: _state.sourceDurationMs, + onVersionSelected: (index) => _state.onSwitchVersion?.call(index), + onQualitySelected: (preset) => _state.onSwitchQualityPreset?.call(preset), ); } @@ -962,11 +906,11 @@ class _VideoSettingsSheetState extends State { } Widget _buildShaderView() { - if (widget.shaderService == null) return const SizedBox.shrink(); + if (_state.shaderService == null) return const SizedBox.shrink(); return Consumer( builder: (context, shaderProvider, _) { - final currentPreset = widget.shaderService!.currentPreset; + final currentPreset = _state.shaderService!.currentPreset; final presets = shaderProvider.allPresets; // +1 for the import button at the end @@ -1006,13 +950,13 @@ class _VideoSettingsSheetState extends State { ), onTap: () async { // Disable ambient lighting when selecting a shader - if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { - widget.onToggleAmbientLighting?.call(); + if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) { + _state.onToggleAmbientLighting?.call(); } - await widget.shaderService!.applyPreset(preset); + await _state.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!context.mounted) return; - widget.onShaderChanged?.call(); + _state.onShaderChanged?.call(); OverlaySheetController.of(context).close(); }, ); @@ -1034,14 +978,14 @@ class _VideoSettingsSheetState extends State { final displayName = path.basenameWithoutExtension(filePath); final preset = await shaderProvider.importCustomShader(filePath, displayName); - if (widget.shaderService != null && mounted) { - if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { - widget.onToggleAmbientLighting?.call(); + if (_state.shaderService != null && mounted) { + if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) { + _state.onToggleAmbientLighting?.call(); } - await widget.shaderService!.applyPreset(preset); + await _state.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!mounted) return; - widget.onShaderChanged?.call(); + _state.onShaderChanged?.call(); } if (mounted) showSuccessSnackBar(context, t.shaders.shaderImported); @@ -1059,9 +1003,9 @@ class _VideoSettingsSheetState extends State { if (!confirmed || !mounted) return; // If the deleted shader is active, clear it from the player first - if (widget.shaderService!.currentPreset.id == preset.id) { - await widget.shaderService!.applyPreset(ShaderPreset.none); - if (mounted) widget.onShaderChanged?.call(); + if (_state.shaderService!.currentPreset.id == preset.id) { + await _state.shaderService!.applyPreset(ShaderPreset.none); + if (mounted) _state.onShaderChanged?.call(); } await shaderProvider.deleteCustomShader(preset); @@ -1100,7 +1044,7 @@ class _VideoSettingsSheetState extends State { @override Widget build(BuildContext context) { final sleepTimer = SleepTimerService(); - final isShaderActive = widget.shaderService != null && widget.shaderService!.currentPreset.isEnabled; + final isShaderActive = _state.shaderService != null && _state.shaderService!.currentPreset.isEnabled; final isZoomActive = (_zoomScale - 1.0).abs() > 0.0001; final isIconActive = _currentView == _SettingsView.menu && diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index f8cb894f..b9ceee4b 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -76,9 +76,7 @@ class ContentStripState extends State { late _StripTab _activeTab; final ScrollController _chapterScrollController = ScrollController(); final ScrollController _queueScrollController = ScrollController(); - int? _lastAutoScrolledChapterIndex; - int? _lastAutoScrolledQueueItemID; - int? _lastAutoScrolledQueueIndex; + final Map<_StripTab, Object?> _lastAutoScrolled = {}; final Map _chapterItemKeys = {}; final Map _queueItemKeys = {}; late Stream _chapterIndexStream; @@ -110,11 +108,10 @@ class ContentStripState extends State { void _normalizeActiveTab() { if (_activeTab == _StripTab.chapters && !_hasChapters && _hasQueue) { _activeTab = _StripTab.queue; - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; + _lastAutoScrolled.remove(_StripTab.queue); } else if (_activeTab == _StripTab.queue && !_hasQueue && _hasChapters) { _activeTab = _StripTab.chapters; - _lastAutoScrolledChapterIndex = null; + _lastAutoScrolled.remove(_StripTab.chapters); } } @@ -204,12 +201,7 @@ class ContentStripState extends State { void _selectTab(_StripTab tab) { setState(() { _activeTab = tab; - if (tab == _StripTab.chapters) { - _lastAutoScrolledChapterIndex = null; - } else { - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; - } + _lastAutoScrolled.remove(tab); }); } @@ -218,21 +210,12 @@ class ContentStripState extends State { final key = event.logicalKey; - if (key == LogicalKeyboardKey.arrowLeft) { + if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; - if (index > 0) { - nodes[index - 1].requestFocus(); - _scrollToFocusedNode(nodes[index - 1]); - widget.onFocusActivity?.call(); - } - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowRight) { - final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; - if (index < totalItems - 1) { - nodes[index + 1].requestFocus(); - _scrollToFocusedNode(nodes[index + 1]); + final target = index + (key == LogicalKeyboardKey.arrowLeft ? -1 : 1); + if (target >= 0 && target < totalItems) { + nodes[target].requestFocus(); + _scrollToFocusedNode(nodes[target]); widget.onFocusActivity?.call(); } return KeyEventResult.handled; @@ -243,7 +226,7 @@ class ContentStripState extends State { // Switch to chapters page and focus current chapter setState(() { _activeTab = _StripTab.chapters; - _lastAutoScrolledChapterIndex = null; + _lastAutoScrolled.remove(_StripTab.chapters); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _chapterFocusNodes.isNotEmpty) { @@ -266,8 +249,7 @@ class ContentStripState extends State { // Switch to queue page and focus current queue item setState(() { _activeTab = _StripTab.queue; - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; + _lastAutoScrolled.remove(_StripTab.queue); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _queueFocusNodes.isNotEmpty) { @@ -407,41 +389,86 @@ class ContentStripState extends State { ); } - Widget _buildChapterStrip(bool isTablet) { - final thumbWidth = isTablet ? 200.0 : 120.0; - final thumbHeight = isTablet ? 112.0 : 68.0; + /// Horizontal list of strip items: auto-scrolls to [autoScrollIndex] whenever + /// [autoScrollToken] changes, and wraps items for focus navigation. + Widget _buildStrip({ + required _StripTab tab, + required ScrollController controller, + required Map keys, + required List nodes, + required String focusPrefix, + required int itemCount, + required bool isTablet, + required int? autoScrollIndex, + required Object? autoScrollToken, + required (Widget, VoidCallback?) Function(BuildContext context, int index, Key key) itemBuilder, + }) { + _trimItemKeys(keys, itemCount); + if (autoScrollIndex != null && _lastAutoScrolled[tab] != autoScrollToken) { + _lastAutoScrolled[tab] = autoScrollToken; + _autoScrollTo( + controller, + keys, + autoScrollIndex, + isTablet: isTablet, + isCurrent: () => _lastAutoScrolled[tab] == autoScrollToken, + ); + } + + if (widget.useFocusNavigation) { + _ensureFocusNodes(nodes, itemCount, focusPrefix); + } + + return ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + itemCount: itemCount, + padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), + itemBuilder: (context, index) { + final (item, onTap) = itemBuilder(context, index, _itemKeyFor(keys, index)); + + if (!widget.useFocusNavigation) return item; + + return Align( + alignment: .topCenter, + child: FocusableWrapper( + focusNode: nodes[index], + onSelect: onTap, + onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, itemCount, tab), + onFocusChange: (hasFocus) { + if (hasFocus) widget.onFocusActivity?.call(); + }, + borderRadius: 6, + autoScroll: false, + useBackgroundFocus: true, + child: item, + ), + ); + }, + ); + } + + Widget _buildChapterStrip(bool isTablet) { return StreamBuilder( stream: _chapterIndexStream, initialData: MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters), builder: (context, chapterSnapshot) { final currentChapterIndex = chapterSnapshot.data; - _trimItemKeys(_chapterItemKeys, widget.chapters.length); - if (currentChapterIndex != null && _lastAutoScrolledChapterIndex != currentChapterIndex) { - _lastAutoScrolledChapterIndex = currentChapterIndex; - _autoScrollTo( - _chapterScrollController, - _chapterItemKeys, - currentChapterIndex, - isTablet: isTablet, - isCurrent: () => _lastAutoScrolledChapterIndex == currentChapterIndex, - ); - } - - if (widget.useFocusNavigation) { - _ensureFocusNodes(_chapterFocusNodes, widget.chapters.length, 'ChapterFocus'); - } - - return ListView.builder( + return _buildStrip( + tab: _StripTab.chapters, controller: _chapterScrollController, - scrollDirection: Axis.horizontal, - clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + keys: _chapterItemKeys, + nodes: _chapterFocusNodes, + focusPrefix: 'ChapterFocus', itemCount: widget.chapters.length, - padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), - itemBuilder: (context, index) { + isTablet: isTablet, + autoScrollIndex: currentChapterIndex, + autoScrollToken: currentChapterIndex, + itemBuilder: (context, index, itemKey) { final chapter = widget.chapters[index]; - final isCurrent = currentChapterIndex == index; final localThumbPath = widget.serverId != null && chapter.thumb != null ? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!) @@ -451,48 +478,25 @@ class ContentStripState extends State { ? () => unawaited(_handleChapterTap(chapter.startTime)) : null; - final itemKey = _itemKeyFor(_chapterItemKeys, index); - final item = _buildStripItem( - key: itemKey, - isCurrent: isCurrent, - isTablet: isTablet, - thumbnail: chapter.thumb != null - ? OptimizedMediaImage.thumb( - client: _tryGetClient(context, serverIdOrNull(widget.serverId)), - imagePath: chapter.thumb, - localFilePath: localThumbPath, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - errorWidget: (_, _, _) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ) - : null, - title: chapter.label, - subtitle: formatDurationTimestamp(chapter.startTime), - onTap: onTap, + return ( + _buildStripItem( + key: itemKey, + isCurrent: currentChapterIndex == index, + isTablet: isTablet, + thumbnail: chapter.thumb != null + ? _buildStripThumbnail( + client: _tryGetClient(context, serverIdOrNull(widget.serverId)), + imagePath: chapter.thumb, + localFilePath: localThumbPath, + isTablet: isTablet, + ) + : null, + title: chapter.label, + subtitle: formatDurationTimestamp(chapter.startTime), + onTap: onTap, + ), + onTap, ); - - if (widget.useFocusNavigation) { - return Align( - alignment: .topCenter, - child: FocusableWrapper( - focusNode: _chapterFocusNodes[index], - onSelect: onTap, - onKeyEvent: (_, event) => - _handleFocusItemKeyEvent(event, index, widget.chapters.length, _StripTab.chapters), - onFocusChange: (hasFocus) { - if (hasFocus) widget.onFocusActivity?.call(); - }, - borderRadius: 6, - autoScroll: false, - useBackgroundFocus: true, - child: item, - ), - ); - } - - return item; }, ); }, @@ -500,9 +504,6 @@ class ContentStripState extends State { } Widget _buildQueueStrip(bool isTablet) { - final thumbWidth = isTablet ? 200.0 : 120.0; - final thumbHeight = isTablet ? 112.0 : 68.0; - return SettingValueBuilder( pref: SettingsService.hideSpoilers, builder: (context, hideSpoilers, _) => Consumer( @@ -513,35 +514,18 @@ class ContentStripState extends State { ? -1 : items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); - _trimItemKeys(_queueItemKeys, items.length); - - if (currentIndex >= 0 && - (_lastAutoScrolledQueueItemID != currentItemID || _lastAutoScrolledQueueIndex != currentIndex)) { - _lastAutoScrolledQueueItemID = currentItemID; - _lastAutoScrolledQueueIndex = currentIndex; - _autoScrollTo( - _queueScrollController, - _queueItemKeys, - currentIndex, - isTablet: isTablet, - isCurrent: () => - _lastAutoScrolledQueueItemID == currentItemID && _lastAutoScrolledQueueIndex == currentIndex, - ); - } - - if (widget.useFocusNavigation) { - _ensureFocusNodes(_queueFocusNodes, items.length, 'QueueFocus'); - } - - return ListView.builder( + return _buildStrip( + tab: _StripTab.queue, controller: _queueScrollController, - scrollDirection: Axis.horizontal, - clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + keys: _queueItemKeys, + nodes: _queueFocusNodes, + focusPrefix: 'QueueFocus', itemCount: items.length, - padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), - itemBuilder: (context, index) { + isTablet: isTablet, + autoScrollIndex: currentIndex >= 0 ? currentIndex : null, + autoScrollToken: (currentItemID, currentIndex), + itemBuilder: (context, index, itemKey) { final item = items[index]; - final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; final client = item.serverId != null ? context.tryGetMediaClientForServer(serverIdOrNull(item.serverId)) @@ -549,47 +533,21 @@ class ContentStripState extends State { void onTap() => widget.onQueueItemSelected?.call(item); - final itemKey = _itemKeyFor(_queueItemKeys, index); - final stripItem = _buildStripItem( - key: itemKey, - isCurrent: isCurrent, - isTablet: isTablet, - thumbnail: item.thumbPath != null - ? OptimizedMediaImage.thumb( - client: client, - imagePath: item.thumbPath, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - errorWidget: (_, _, _) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ) - : null, - blurThumbnail: hideSpoilers && item.shouldHideSpoiler, - title: item.title ?? '', - subtitle: formatQueueItemSubtitle(item), - onTap: onTap, + return ( + _buildStripItem( + key: itemKey, + isCurrent: playbackState.playQueueItemIdFor(item) == currentItemID, + isTablet: isTablet, + thumbnail: item.thumbPath != null + ? _buildStripThumbnail(client: client, imagePath: item.thumbPath, isTablet: isTablet) + : null, + blurThumbnail: hideSpoilers && item.shouldHideSpoiler, + title: item.title ?? '', + subtitle: formatQueueItemSubtitle(item), + onTap: onTap, + ), + onTap, ); - - if (widget.useFocusNavigation) { - return Align( - alignment: .topCenter, - child: FocusableWrapper( - focusNode: _queueFocusNodes[index], - onSelect: onTap, - onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, items.length, _StripTab.queue), - onFocusChange: (hasFocus) { - if (hasFocus) widget.onFocusActivity?.call(); - }, - borderRadius: 6, - autoScroll: false, - useBackgroundFocus: true, - child: stripItem, - ), - ); - } - - return stripItem; }, ); }, @@ -597,6 +555,23 @@ class ContentStripState extends State { ); } + Widget _buildStripThumbnail({ + required MediaServerClient? client, + required String? imagePath, + required bool isTablet, + String? localFilePath, + }) { + return OptimizedMediaImage.thumb( + client: client, + imagePath: imagePath, + localFilePath: localFilePath, + width: isTablet ? 200.0 : 120.0, + height: isTablet ? 112.0 : 68.0, + fit: BoxFit.cover, + errorWidget: (_, _, _) => const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), + ); + } + Widget _buildStripItem({ Key? key, required bool isCurrent, diff --git a/lib/widgets/video_controls/widgets/content_strip_panel.dart b/lib/widgets/video_controls/widgets/content_strip_panel.dart new file mode 100644 index 00000000..75ea0dae --- /dev/null +++ b/lib/widgets/video_controls/widgets/content_strip_panel.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../../app_icon.dart'; + +/// Gradient scrim that hosts the content strip once it is on screen. +/// +/// [chevron] points back at the controls the strip replaced — down for the +/// mobile swipe, up for D-pad focus. [padding] compensates for the strip's +/// own horizontal padding, which differs between touch and focus navigation. +class ContentStripPanel extends StatelessWidget { + final EdgeInsetsGeometry padding; + final IconData chevron; + final Widget child; + + const ContentStripPanel({super.key, required this.padding, required this.chevron, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.65), Colors.black.withValues(alpha: 0.7)], + stops: const [0.0, 0.42, 1.0], + ), + ), + child: Column( + mainAxisSize: .min, + children: [ + AppIcon(chevron, color: Colors.white38, size: 20), + const SizedBox(height: 4), + child, + ], + ), + ); + } +} + +/// Chevron pinned to the bottom of the controls hinting that the content +/// strip can be pulled into view. Must be placed directly in a [Stack]. +class ContentStripHint extends StatelessWidget { + final IconData chevron; + + const ContentStripHint(this.chevron, {super.key}); + + @override + Widget build(BuildContext context) { + return Positioned(left: 0, right: 0, bottom: 12, child: AppIcon(chevron, color: Colors.white24, size: 24)); + } +} diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 72efae64..99bbc9f0 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -3,8 +3,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../../../focus/dpad_navigator.dart'; -import '../../../media/media_item.dart'; -import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../media/media_source_info.dart'; import '../../../services/sleep_timer_service.dart'; @@ -13,12 +11,10 @@ import '../../../utils/quality_preset_labels.dart'; import '../../../i18n/strings.g.dart'; import '../../../widgets/overlay_sheet.dart'; import '../models/track_controls_state.dart'; -import '../../../models/transcode_quality_preset.dart'; import '../sheets/chapter_sheet.dart'; import '../sheets/queue_sheet.dart'; import '../sheets/track_sheet.dart'; import '../sheets/video_settings_sheet.dart'; -import '../../../services/shader_service.dart'; import '../../../utils/track_label_builder.dart'; import '../video_control_button.dart'; @@ -65,43 +61,6 @@ class TrackChapterControls extends StatelessWidget { this.hideChaptersAndQueue = false, }); - List get availableVersions => trackControlsState.availableVersions; - int get selectedMediaIndex => trackControlsState.selectedMediaIndex; - TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset; - bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding; - ValueChanged? get onSwitchQualityPreset => trackControlsState.onSwitchQualityPreset; - int get boxFitMode => trackControlsState.boxFitMode; - double get videoZoomScale => trackControlsState.videoZoomScale; - int get audioSyncOffset => trackControlsState.audioSyncOffset; - int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset; - bool get isRotationLocked => trackControlsState.isRotationLocked; - bool get isScreenLocked => trackControlsState.isScreenLocked; - bool get isFullscreen => trackControlsState.isFullscreen; - bool get isAlwaysOnTop => trackControlsState.isAlwaysOnTop; - VoidCallback? get onTogglePIPMode => trackControlsState.onTogglePIPMode; - VoidCallback? get onCycleBoxFitMode => trackControlsState.onCycleBoxFitMode; - ValueChanged? get onVideoZoomChanged => trackControlsState.onVideoZoomChanged; - VoidCallback? get onResetVideoZoom => trackControlsState.onResetVideoZoom; - VoidCallback? get onToggleRotationLock => trackControlsState.onToggleRotationLock; - VoidCallback? get onToggleScreenLock => trackControlsState.onToggleScreenLock; - VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen; - VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop; - Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion; - VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes; - VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide; - VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide; - void Function(String propertyName, int offset)? get onSyncOffsetChanged => trackControlsState.onSyncOffsetChanged; - String? get serverId => trackControlsState.serverId; - ShaderService? get shaderService => trackControlsState.shaderService; - VoidCallback? get onShaderChanged => trackControlsState.onShaderChanged; - bool get isAmbientLightingEnabled => trackControlsState.isAmbientLightingEnabled; - VoidCallback? get onToggleAmbientLighting => trackControlsState.onToggleAmbientLighting; - bool get canControl => trackControlsState.canControl; - bool get isLive => trackControlsState.isLive; - bool get subtitlesVisible => trackControlsState.subtitlesVisible; - bool get showQueueButton => trackControlsState.showQueueButton; - Function(MediaItem)? get onQueueItemSelected => trackControlsState.onQueueItemSelected; - /// Handle key event for button navigation KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) { if (!event.isActionable) { @@ -183,6 +142,7 @@ class TrackChapterControls extends StatelessWidget { initialData: player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; + final state = trackControlsState; final isMobile = PlatformDetector.isMobile(context); final isDesktop = PlatformDetector.isDesktopOS(); @@ -196,13 +156,14 @@ class TrackChapterControls extends StatelessWidget { listenable: SleepTimerService(), builder: (context, _) { final sleepTimer = SleepTimerService(); + final shaderService = state.shaderService; final isShaderActive = - shaderService != null && shaderService!.isSupported && shaderService!.currentPreset.isEnabled; - final isZoomActive = (videoZoomScale - 1.0).abs() > 0.0001; + shaderService != null && shaderService.isSupported && shaderService.currentPreset.isEnabled; + final isZoomActive = (state.videoZoomScale - 1.0).abs() > 0.0001; final isActive = sleepTimer.isActive || - audioSyncOffset != 0 || - subtitleSyncOffset != 0 || + state.audioSyncOffset != 0 || + state.subtitleSyncOffset != 0 || isShaderActive || isZoomActive; return _buildTrackButton( @@ -216,37 +177,14 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( - builder: (_) => VideoSettingsSheet( - player: player, - audioSyncOffset: audioSyncOffset, - subtitleSyncOffset: subtitleSyncOffset, - videoZoomScale: videoZoomScale, - onVideoZoomChanged: onVideoZoomChanged, - onResetVideoZoom: onResetVideoZoom, - canControl: canControl, - isLive: isLive, - availableVersions: availableVersions, - selectedMediaIndex: selectedMediaIndex, - selectedQualityPreset: selectedQualityPreset, - serverSupportsTranscoding: serverSupportsTranscoding, - sourceDurationMs: trackControlsState.sourceDurationMs, - onVersionSelected: onSwitchVersion == null ? null : (i) => onSwitchVersion!(i), - onQualitySelected: onSwitchQualityPreset, - shaderService: shaderService, - onShaderChanged: onShaderChanged, - isAmbientLightingEnabled: isAmbientLightingEnabled, - onToggleAmbientLighting: onToggleAmbientLighting, - onCancelAutoHide: onCancelAutoHide, - onStartAutoHide: onStartAutoHide, - onSyncOffsetChanged: onSyncOffsetChanged, - ), + builder: (_) => VideoSettingsSheet(player: player, trackControlsState: state), ) .whenComplete(() { - onStartAutoHide?.call(); - onLoadSeekTimes?.call(); + state.onStartAutoHide?.call(); + state.onLoadSeekTimes?.call(); }); }, ); @@ -264,10 +202,10 @@ class TrackChapterControls extends StatelessWidget { initialData: player.state.track, builder: (context, selectionSnapshot) { final selection = selectionSnapshot.data ?? player.state.track; - final hasSubtitleControls = trackControlsState.hasSubtitleControls(tracks); + final hasSubtitleControls = state.hasSubtitleControls(tracks); final selectedSub = selection.subtitle; final hasActiveSubtitle = selectedSub != null && selectedSub.id != SubtitleTrack.off.id; - final isHidden = hasSubtitleControls && hasActiveSubtitle && !subtitlesVisible; + final isHidden = hasSubtitleControls && hasActiveSubtitle && !state.subtitlesVisible; final icon = hasSubtitleControls ? (isHidden ? Symbols.subtitles_off_rounded : Symbols.subtitles_rounded) : Symbols.audiotrack_rounded; @@ -280,12 +218,12 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( - builder: (_) => TrackSheet(player: player, trackControlsState: trackControlsState), + builder: (_) => TrackSheet(player: player, trackControlsState: state), ) - .whenComplete(() => onStartAutoHide?.call()); + .whenComplete(() => state.onStartAutoHide?.call()); }, ); }, @@ -306,20 +244,20 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( builder: (_) => ChapterSheet( player: player, chapters: chapters, chaptersLoaded: chaptersLoaded, - canControl: canControl, - serverId: serverId, + canControl: state.canControl, + serverId: state.serverId, onSeekRequested: onSeekRequested, onSeekCompleted: onSeekCompleted, ), ) - .whenComplete(() => onStartAutoHide?.call()); + .whenComplete(() => state.onStartAutoHide?.call()); }, ), ); @@ -327,7 +265,7 @@ class TrackChapterControls extends StatelessWidget { } // Queue button (hidden on mobile when content strip is available) - if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) { + if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -338,10 +276,10 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) - .show(builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!)) - .whenComplete(() => onStartAutoHide?.call()); + .show(builder: (_) => QueueSheet(onItemSelected: state.onQueueItemSelected!)) + .whenComplete(() => state.onStartAutoHide?.call()); }, ), ); @@ -349,7 +287,7 @@ class TrackChapterControls extends StatelessWidget { } // Picture-in-Picture mode - if (onTogglePIPMode != null) { + if (state.onTogglePIPMode != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -359,25 +297,25 @@ class TrackChapterControls extends StatelessWidget { semanticLabel: t.videoControls.pipButton, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onTogglePIPMode, + onPressed: state.onTogglePIPMode, ), ); buttonIndex++; } // BoxFit mode button - if (onCycleBoxFitMode != null) { + if (state.onCycleBoxFitMode != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: _getBoxFitIcon(boxFitMode), - tooltip: _getBoxFitTooltip(boxFitMode), + icon: _getBoxFitIcon(state.boxFitMode), + tooltip: _getBoxFitTooltip(state.boxFitMode), semanticLabel: t.videoControls.aspectRatioButton, - semanticValue: _getBoxFitTooltip(boxFitMode), + semanticValue: _getBoxFitTooltip(state.boxFitMode), isMobile: isMobile, isDesktop: isDesktop, - onPressed: onCycleBoxFitMode, + onPressed: state.onCycleBoxFitMode, ), ); buttonIndex++; @@ -389,13 +327,13 @@ class TrackChapterControls extends StatelessWidget { buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded, - tooltip: isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation, + icon: state.isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded, + tooltip: state.isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation, semanticLabel: t.videoControls.rotationLockButton, - checked: isRotationLocked, + checked: state.isRotationLocked, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleRotationLock, + onPressed: state.onToggleRotationLock, ), ); buttonIndex++; @@ -412,14 +350,14 @@ class TrackChapterControls extends StatelessWidget { semanticLabel: t.videoControls.screenLockButton, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleScreenLock, + onPressed: state.onToggleScreenLock, ), ); buttonIndex++; } // Always on top button (desktop only, not TV) - if (isDesktop && onToggleAlwaysOnTop != null) { + if (isDesktop && state.onToggleAlwaysOnTop != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -427,11 +365,11 @@ class TrackChapterControls extends StatelessWidget { icon: Symbols.layers_rounded, tooltip: t.videoControls.alwaysOnTopButton, semanticLabel: t.videoControls.alwaysOnTopButton, - isActive: isAlwaysOnTop, - checked: isAlwaysOnTop, + isActive: state.isAlwaysOnTop, + checked: state.isAlwaysOnTop, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleAlwaysOnTop, + onPressed: state.onToggleAlwaysOnTop, ), ); buttonIndex++; @@ -443,13 +381,15 @@ class TrackChapterControls extends StatelessWidget { buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, - tooltip: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, - semanticLabel: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, - checked: isFullscreen, + icon: state.isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, + tooltip: state.isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, + semanticLabel: state.isFullscreen + ? t.videoControls.exitFullscreenButton + : t.videoControls.fullscreenButton, + checked: state.isFullscreen, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleFullscreen, + onPressed: state.onToggleFullscreen, ), ); } @@ -462,15 +402,16 @@ class TrackChapterControls extends StatelessWidget { } String? _versionQualitySemanticValue() { + final state = trackControlsState; final values = []; - if (availableVersions.length > 1) { - final index = selectedMediaIndex; - if (index >= 0 && index < availableVersions.length) { - values.add(availableVersions[index].displayLabel); + if (state.availableVersions.length > 1) { + final index = state.selectedMediaIndex; + if (index >= 0 && index < state.availableVersions.length) { + values.add(state.availableVersions[index].displayLabel); } } - if (serverSupportsTranscoding) { - values.add(qualityPresetLabel(selectedQualityPreset)); + if (state.serverSupportsTranscoding) { + values.add(qualityPresetLabel(state.selectedQualityPreset)); } return values.isEmpty ? null : values.join(' / '); } @@ -519,14 +460,15 @@ class TrackChapterControls extends StatelessWidget { /// Calculate total button count for navigation int _getButtonCount(bool isMobile, bool isDesktop) { + final state = trackControlsState; int count = 1; // Settings button always shown count++; // Audio & subtitles button always shown if (chapters.isNotEmpty && !hideChaptersAndQueue) count++; - if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++; - if (onTogglePIPMode != null) count++; - if (onCycleBoxFitMode != null) count++; + if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) count++; + if (state.onTogglePIPMode != null) count++; + if (state.onCycleBoxFitMode != null) count++; if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV) - if (isDesktop && onToggleAlwaysOnTop != null) count++; // Always on top + if (isDesktop && state.onToggleAlwaysOnTop != null) count++; // Always on top if (isDesktop) count++; // Fullscreen return count; } diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 1dea5b6d..f857e689 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -617,20 +617,7 @@ void MpvPlayer::CommandAsync(const std::vector& args, CommandCallba return; } - std::vector c_args; - c_args.reserve(args.size() + 1); - for (const auto& arg : args) { - c_args.push_back(arg.c_str()); - } - c_args.push_back(nullptr); - - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - int result = mpv_command_async(mpv_, request_id, c_args.data()); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback)); } void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { @@ -658,14 +645,7 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val if (completion) completion(error); }; } - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - char* property_value = const_cast(value.c_str()); - int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback)); } void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) { @@ -674,13 +654,7 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca return; } - uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback)); - - int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING); - if (result < 0) { - auto cb = pending_requests_.TakeProperty(request_id); - if (cb) cb(result, ""); - } + plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback)); } void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { @@ -920,33 +894,12 @@ void MpvPlayer::EnsureAudioRecoveryTimer() { } void MpvPlayer::HandleMpvEvent(mpv_event* event) { + if (plezy::mpv_common::DispatchReplyEvent( + pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) { + return; + } + switch (event->event_id) { - case MPV_EVENT_COMMAND_REPLY: - case MPV_EVENT_SET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - StatusCallback callback = pending_requests_.TakeStatus(request_id); - if (callback) { - callback(event->error); - } - break; - } - case MPV_EVENT_GET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - GetPropertyCallback callback = pending_requests_.TakeProperty(request_id); - if (callback) { - int error = event->error; - std::string value; - if (error >= 0) { - auto* prop = static_cast(event->data); - if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { - auto c_value = *static_cast(prop->data); - if (c_value) value = SanitizeUtf8(c_value); - } - } - callback(error, value); - } - break; - } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); if (!msg) break; @@ -963,54 +916,12 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { case MPV_EVENT_PROPERTY_CHANGE: { auto* prop = static_cast(event->data); if (!prop || !prop->name) break; - mpv_node node; - node.format = prop->format; + mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop); - switch (prop->format) { - case MPV_FORMAT_STRING: - node.u.string = prop->data ? *static_cast(prop->data) : nullptr; - break; - case MPV_FORMAT_FLAG: - node.u.flag = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_INT64: - node.u.int64 = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_DOUBLE: - node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; - break; - case MPV_FORMAT_NODE: - if (prop->data) { - node = *static_cast(prop->data); - } else { - node.format = MPV_FORMAT_NONE; - } - break; - default: - node.format = MPV_FORMAT_NONE; - break; - } - - if (strcmp(prop->name, "current-ao") == 0) { - const char* current_ao = nullptr; - if (prop->format == MPV_FORMAT_STRING && prop->data) { - current_ao = *static_cast(prop->data); - } - const bool is_null = current_ao && strcmp(current_ao, "null") == 0; - const auto transition = - audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now()); - if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) { - LogRecovery("current-ao fell back to null; starting recovery"); - EnsureAudioRecoveryTimer(); - } else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) { - LogRecovery("audio recovered (current-ao no longer null)"); - } - } - if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && - audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) { - LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload"); - EnsureAudioRecoveryTimer(); - } + const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop); + if (notice.message) LogRecovery(notice.message); + // Recovery runs off a GLib timer here, so newly queued work has to arm it. + if (notice.scheduled_work) EnsureAudioRecoveryTimer(); SendPropertyChange(prop->name, &node); break; @@ -1048,77 +959,41 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { break; } } -FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { - NodeConversionBudget budget{ - /*remaining_entries=*/16384, - /*remaining_bytes=*/16 * 1024 * 1024, - }; - return NodeToFlValue(node, 0, &budget); -} -bool MpvPlayer::ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result) { - if (!input || !budget || !result) return false; - const size_t length = strnlen(input, budget->remaining_bytes + 1); - if (length > budget->remaining_bytes) return false; - budget->remaining_bytes -= length; - *result = SanitizeUtf8(input, length); - return true; -} +namespace { -FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget) { - constexpr size_t kMaxNodeDepth = 32; - constexpr int kMaxNodeEntries = 16384; - if (!node || !budget || depth >= kMaxNodeDepth || budget->remaining_entries == 0) { - return fl_value_new_null(); +// Adapts the shared, bounded mpv_node walk onto GLib-owned FlValues. +struct FlValueNodeBuilder { + using Value = FlValue*; + using ListBuilder = FlValue*; + using MapBuilder = FlValue*; + + static Value Null() { return fl_value_new_null(); } + static Value Bool(bool value) { return fl_value_new_bool(value); } + static Value Int(int64_t value) { return fl_value_new_int(value); } + static Value Double(double value) { return fl_value_new_float(value); } + static Value String(const char* value, size_t length) { + return fl_value_new_string(SanitizeUtf8(value, length).c_str()); } - --budget->remaining_entries; - switch (node->format) { - case MPV_FORMAT_STRING: { - std::string value; - if (!ConvertNodeString(node->u.string, budget, &value)) return fl_value_new_null(); - return fl_value_new_string(value.c_str()); - } - case MPV_FORMAT_FLAG: - return fl_value_new_bool(node->u.flag != 0); - case MPV_FORMAT_INT64: - return fl_value_new_int(node->u.int64); - case MPV_FORMAT_DOUBLE: - return fl_value_new_float(node->u.double_); - case MPV_FORMAT_NODE_ARRAY: { - const mpv_node_list* list = node->u.list; - if (!list || list->num < 0 || list->num > kMaxNodeEntries || (list->num > 0 && !list->values)) { - return fl_value_new_null(); - } - FlValue* result = fl_value_new_list(); - for (int i = 0; i < list->num; i++) { - fl_value_append_take(result, NodeToFlValue(&list->values[i], depth + 1, budget)); - } - return result; - } - case MPV_FORMAT_NODE_MAP: { - const mpv_node_list* map = node->u.list; - if (!map || map->num < 0 || map->num > kMaxNodeEntries || (map->num > 0 && (!map->keys || !map->values))) { - return fl_value_new_null(); - } - FlValue* result = fl_value_new_map(); - for (int i = 0; i < map->num; i++) { - if (!map->keys[i]) { - fl_value_unref(result); - return fl_value_new_null(); - } - std::string key; - if (!ConvertNodeString(map->keys[i], budget, &key)) { - fl_value_unref(result); - return fl_value_new_null(); - } - fl_value_set_string_take(result, key.c_str(), NodeToFlValue(&map->values[i], depth + 1, budget)); - } - return result; - } - default: - return fl_value_new_null(); + static ListBuilder NewList() { return fl_value_new_list(); } + static void Append(ListBuilder& list, Value value) { fl_value_append_take(list, value); } + static Value FinishList(ListBuilder list) { return list; } + + static MapBuilder NewMap() { return fl_value_new_map(); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + fl_value_set_string_take(map, SanitizeUtf8(key, key_length).c_str(), value); } + static Value FinishMap(MapBuilder map) { return map; } + static void AbandonMap(MapBuilder& map) { fl_value_unref(map); } +}; + +} // namespace + +FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { return plezy::mpv_common::ConvertNode(node); } + +FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget) { + return plezy::mpv_common::ConvertNode(node, 0, budget); } void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index 61b76fec..2654be8c 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -242,15 +242,9 @@ class MpvPlayer { void LogRecovery(const std::string& text); void SetHDREnabled(bool enabled, StatusCallback callback = nullptr); - struct NodeConversionBudget { - size_t remaining_entries; - size_t remaining_bytes; - }; - - /// Helper to convert mpv_node to FlValue. + /// Helper to convert mpv_node to FlValue, bounded by the shared node budget. ::_FlValue* NodeToFlValue(mpv_node* node); - ::_FlValue* NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget); - bool ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result); + ::_FlValue* NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget); const bool audio_only_; mpv_handle* mpv_ = nullptr; diff --git a/linux/runner/mpv/mpv_player_lifecycle_test.cc b/linux/runner/mpv/mpv_player_lifecycle_test.cc index fd67baeb..5f263d31 100644 --- a/linux/runner/mpv/mpv_player_lifecycle_test.cc +++ b/linux/runner/mpv/mpv_player_lifecycle_test.cc @@ -102,8 +102,8 @@ class MpvPlayerLifecycleTestPeer { static FlValue* ConvertNode(MpvPlayer& player, mpv_node* node) { return player.NodeToFlValue(node); } static FlValue* ConvertNodeWithBudget( MpvPlayer& player, mpv_node* node, size_t remaining_entries, size_t remaining_bytes) { - MpvPlayer::NodeConversionBudget budget{remaining_entries, remaining_bytes}; - return player.NodeToFlValue(node, 0, &budget); + plezy::mpv_common::NodeConversionBudget budget{remaining_entries, remaining_bytes}; + return player.NodeToFlValue(node, &budget); } static void RegisterObservedNode(MpvPlayer& player, const std::string& name, int id) { player.observed_properties_.Register(name, "node", id); diff --git a/scripts/check_build_workflow.py b/scripts/check_build_workflow.py index 555940bc..08edb39d 100644 --- a/scripts/check_build_workflow.py +++ b/scripts/check_build_workflow.py @@ -5,8 +5,15 @@ from pathlib import Path import re import sys +from workflow_yaml import iter_uses_references, job_block -DEFAULT_WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/build.yml" + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_WORKFLOW = ROOT / ".github/workflows/build.yml" +# The shared bootstrap both windows-arm jobs call, and the pins it must keep. +SETUP_FLUTTER_GIT = ROOT / ".github/actions/setup-flutter-git/action.yml" +FLUTTER_VERSION = "3.44.0" +FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" if len(sys.argv) > 2: raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]") WORKFLOW = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_WORKFLOW @@ -20,11 +27,9 @@ def require(condition: bool, message: str) -> None: def job(name: str) -> str: - match = re.search( - rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text - ) - require(match is not None, f"missing {name} job") - return match.group(0) if match else "" + block = job_block(text, name) + require(bool(block), f"missing {name} job") + return block def named_step(block: str, name: str) -> str: @@ -132,13 +137,7 @@ require( for expected in ( "if: matrix.flutter_setup == 'action'", "if: matrix.flutter_setup == 'git'", - '$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"', - "git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0", - "git -C $root checkout --detach refs/tags/3.44.0", - "$actualCommit = git -C $root rev-parse HEAD", - "$actualCommit -ne $expectedCommit", - r'$versionOutput = & "$root\bin\flutter.bat" --version --machine', - '$version.frameworkVersion -ne "3.44.0"', + "uses: ./.github/actions/setup-flutter-git", "flutter pub get --enforce-lockfile --no-example", "--dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }}", "--split-debug-info=debug-info/windows-${{ matrix.arch }}", @@ -160,6 +159,28 @@ require( ) require_explicit_shells("build-windows", windows, "pwsh") +setup_flutter_git = ( + SETUP_FLUTTER_GIT.read_text(encoding="utf-8") if SETUP_FLUTTER_GIT.is_file() else "" +) +require(bool(setup_flutter_git), "missing .github/actions/setup-flutter-git/action.yml") +for expected in ( + f'$version = "{FLUTTER_VERSION}"', + f'$expectedCommit = "{FLUTTER_COMMIT}"', + # Fetch the release tag rather than the bare commit: the commit is only + # reachable through the tag, and the tag is what makes the SDK report its + # own version. Both halves are then verified, so a moved tag fails the job. + 'git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"', + 'git -C $root checkout --detach "refs/tags/$version"', + "$actualCommit = git -C $root rev-parse HEAD", + "$actualCommit -ne $expectedCommit", + r'$versionOutput = & "$root\bin\flutter.bat" --version --machine', + "$reportedVersion -ne $version", +): + require( + expected in setup_flutter_git, + f"shared Flutter bootstrap must keep its verified pin: {expected}", + ) + linux = job("build-linux") require("runs-on: ${{ matrix.runner }}" in linux, "Linux must use its matrix runner") require("fail-fast: false" in linux, "Linux matrix must not cancel its other architecture") @@ -187,7 +208,7 @@ require( ) for expected in ( "channel: ${{ matrix.flutter_channel }}", - 'flutter-version: "3.44.0"', + "flutter-version: ${{ env.FLUTTER_VERSION }}", "flutter pub get --enforce-lockfile --no-example", "lib/${{ matrix.pkg_config_arch }}/pkgconfig", "--dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }}", @@ -292,6 +313,10 @@ for protected_job in ( f"{protected_job} must depend on trusted-ref validation", ) +require( + text.count(FLUTTER_VERSION) == 1 and f'FLUTTER_VERSION: "{FLUTTER_VERSION}"' in text, + "the Flutter SDK version must be written once, as the workflow FLUTTER_VERSION env", +) require( "TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1" in text, "build caches must use a dedicated trusted namespace", @@ -309,17 +334,18 @@ require( "every Flutter SDK cache must define its trusted cache key", ) -action_refs = re.findall(r"(?m)^\s*(?:-\s+)?uses:\s+([^\s@]+)@([^\s#]+)", text) -require(bool(action_refs), "build workflow must use pinned actions") -for action, ref in action_refs: - require( - re.fullmatch(r"[0-9a-f]{40}", ref) is not None, - f"action {action} must be pinned to a full commit SHA", - ) - -checkout_count = sum(action == "actions/checkout" for action, _ in action_refs) +# check_workflow_action_pins.py owns the SHA-pin rule for every workflow, this +# one included; build.yml only adds the credential invariant on top, because it +# is workflow_dispatch-only and so escapes the pull-request rule in +# check_workflow_security.py. +remote_actions = [ + reference.rpartition("@")[0] + for _, reference in iter_uses_references(text) + if not reference.startswith("./") +] +require(bool(remote_actions), "build workflow must use pinned actions") require( - text.count("persist-credentials: false") == checkout_count, + text.count("persist-credentials: false") == remote_actions.count("actions/checkout"), "every build checkout must discard GitHub credentials", ) diff --git a/scripts/check_update_packages_workflow.py b/scripts/check_update_packages_workflow.py index 75f6a501..65172130 100644 --- a/scripts/check_update_packages_workflow.py +++ b/scripts/check_update_packages_workflow.py @@ -5,6 +5,8 @@ from pathlib import Path import re import sys +from workflow_yaml import job_block + WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/update-packages.yml" text = WORKFLOW.read_text(encoding="utf-8") @@ -17,11 +19,9 @@ def require(condition: bool, message: str) -> None: def job(name: str) -> str: - match = re.search( - rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text - ) - require(match is not None, f"missing {name} job") - return match.group(0) if match else "" + block = job_block(text, name) + require(bool(block), f"missing {name} job") + return block require( diff --git a/scripts/check_workflow_action_pins.py b/scripts/check_workflow_action_pins.py index 2f30fc69..8cf66929 100755 --- a/scripts/check_workflow_action_pins.py +++ b/scripts/check_workflow_action_pins.py @@ -7,336 +7,21 @@ import re import sys from pathlib import Path +import workflow_yaml + ROOT = Path(__file__).resolve().parent.parent WORKFLOWS = ROOT / ".github" / "workflows" -MAPPING_RE = re.compile( - r"""^\s*(?:-\s*)?(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*:\s*(?P.*?)\s*$""" -) -EXPLICIT_KEY_RE = re.compile( - r"""^\s*(?:-\s*)?\?\s*(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*$""" -) -EXPLICIT_VALUE_RE = re.compile(r"^\s*:\s*(?P.*?)\s*$") +ACTIONS = ROOT / ".github" / "actions" REMOTE_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-fA-F]{40}$") -BLOCK_SCALAR_RE = re.compile(r":\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$") -BLOCK_SCALAR_VALUE_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$") -YAML_DOUBLE_ESCAPES = { - "0": "\0", - "a": "\a", - "b": "\b", - "t": "\t", - "\t": "\t", - "n": "\n", - "v": "\v", - "f": "\f", - "r": "\r", - "e": "\x1b", - " ": " ", - '"': '"', - "/": "/", - "\\": "\\", - "N": "\u0085", - "_": "\u00a0", - "L": "\u2028", - "P": "\u2029", -} -def iter_workflow_files(directory: Path = WORKFLOWS): - yield from sorted((*directory.glob("*.yml"), *directory.glob("*.yaml"))) - - -def _strip_yaml_comment(value: str) -> str: - quote = None - escaped = False - for index, char in enumerate(value): - if escaped: - escaped = False - continue - if char == "\\" and quote == '"': - escaped = True - continue - if char in ("'", '"'): - if quote is None: - quote = char - elif quote == char: - quote = None - continue - if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()): - return value[:index].rstrip() - return value.rstrip() - - -def _decode_quoted_yaml_string(value: str) -> str | None: - if len(value) < 2 or value[0] != value[-1] or value[0] not in ("'", '"'): - return None - if value[0] == "'": - return value[1:-1].replace("''", "'") - - decoded = [] - index = 1 - end = len(value) - 1 - while index < end: - char = value[index] - if char != "\\": - decoded.append(char) - index += 1 - continue - index += 1 - if index >= end: - return None - escape = value[index] - if escape in YAML_DOUBLE_ESCAPES: - decoded.append(YAML_DOUBLE_ESCAPES[escape]) - index += 1 - continue - width = {"x": 2, "u": 4, "U": 8}.get(escape) - if width is None or index + width >= end: - return None - digits = value[index + 1 : index + 1 + width] - if not re.fullmatch(rf"[0-9a-fA-F]{{{width}}}", digits): - return None - try: - decoded.append(chr(int(digits, 16))) - except ValueError: - return None - index += width + 1 - return "".join(decoded) - - -def _unquote(value: str) -> str: - decoded = _decode_quoted_yaml_string(value) - return value if decoded is None else decoded - - -def _flow_value(line: str, start: int, mapping_depth: int) -> str: - index = start - quote = None - escaped = False - depth = mapping_depth - while index < len(line): - char = line[index] - if escaped: - escaped = False - elif char == "\\" and quote == '"': - escaped = True - elif quote is not None: - if char == quote: - quote = None - elif char in ("'", '"'): - quote = char - elif char in ("{", "["): - depth += 1 - elif char in ("}", "]"): - if depth == mapping_depth: - break - depth -= 1 - elif char == "," and depth == mapping_depth: - break - index += 1 - return _unquote(line[start:index].strip()) - - -def _has_unsupported_block_mapping_key(line: str) -> bool: - candidate = line.lstrip() - if candidate.startswith("-") and not candidate.startswith("---"): - candidate = candidate[1:].lstrip() - if not candidate: - return False - if candidate[0] in "!&*": - return True - if candidate[0] not in ("'", '"'): - return False - - quote = candidate[0] - escaped = False - index = 1 - while index < len(candidate): - char = candidate[index] - if quote == "'" and char == "'" and index + 1 < len(candidate) and candidate[index + 1] == "'": - index += 2 - continue - if escaped: - escaped = False - elif quote == '"' and char == "\\": - escaped = True - elif char == quote: - return False - index += 1 - return True - - -def _flow_uses_references(line: str, initial_depth: int) -> tuple[list[str], int]: - references = [] - depth = initial_depth - index = 0 - while index < len(line): - char = line[index] - if char in ("'", '"'): - quote = char - escaped = False - end = index + 1 - while end < len(line): - quoted_char = line[end] - if escaped: - escaped = False - elif quoted_char == "\\" and quote == '"': - escaped = True - elif quoted_char == quote: - break - end += 1 - if end >= len(line): - if depth > 0: - references.append("") - return references, depth - key = _decode_quoted_yaml_string(line[index : end + 1]) - after_key = end + 1 - while after_key < len(line) and line[after_key].isspace(): - after_key += 1 - if depth > 0 and after_key < len(line) and line[after_key] == ":": - if key == "uses": - references.append(_flow_value(line, after_key + 1, depth)) - elif key is None: - references.append("") - index = end + 1 - continue - if line.startswith("${{", index): - expression_end = line.find("}}", index + 3) - if expression_end < 0: - references.append("") - return references, depth - index = expression_end + 2 - continue - if char in ("{", "["): - depth += 1 - index += 1 - continue - if char in ("}", "]"): - depth = max(0, depth - 1) - index += 1 - continue - if depth > 0 and char == "?": - references.append("") - index += 1 - continue - if depth > 0 and char in "!&*": - references.append("") - index += 1 - continue - if depth > 0 and (char.isalpha() or char == "_"): - end = index + 1 - while end < len(line) and (line[end].isalnum() or line[end] in "_-"): - end += 1 - after_key = end - while after_key < len(line) and line[after_key].isspace(): - after_key += 1 - if line[index:end] == "uses" and after_key < len(line) and line[after_key] == ":": - references.append(_flow_value(line, after_key + 1, depth)) - index = end - continue - index += 1 - return references, depth +def iter_action_files(directory: Path = ACTIONS): + """Local composite actions run in the same trust boundary as the workflows.""" + yield from sorted((*directory.glob("*/action.yml"), *directory.glob("*/action.yaml"))) def iter_uses_references(path: Path): - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content: list[str] = [] - explicit_uses_line = None - flow_start_line = None - flow_depth = 0 - for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): - stripped = raw_line.lstrip() - indent = len(raw_line) - len(stripped) - if block_parent_indent is not None: - if not stripped: - if block_uses_line is not None: - block_uses_content.append("") - continue - if indent <= block_parent_indent: - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content = [] - elif block_content_indent is None: - block_content_indent = indent - if block_uses_line is not None: - block_uses_content.append(raw_line[block_content_indent:]) - continue - elif indent >= block_content_indent: - if block_uses_line is not None: - block_uses_content.append(raw_line[block_content_indent:]) - continue - else: - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content = [] - if stripped.startswith("#") or not stripped: - continue - active_line = _strip_yaml_comment(raw_line) - if explicit_uses_line is not None: - explicit_value = EXPLICIT_VALUE_RE.match(active_line) - if explicit_value is None: - yield explicit_uses_line, "" - else: - value = explicit_value.group("value").strip() - if BLOCK_SCALAR_VALUE_RE.fullmatch(value): - block_parent_indent = indent - block_content_indent = None - block_uses_line = explicit_uses_line - block_uses_content = [] - else: - yield explicit_uses_line, _unquote(value) - explicit_uses_line = None - continue - explicit_uses_line = None - explicit_key = EXPLICIT_KEY_RE.match(active_line) - if explicit_key: - if _unquote(explicit_key.group("key")) == "uses": - explicit_uses_line = line_number - continue - if re.match(r"^\s*(?:-\s*)?\?", active_line): - yield line_number, "" - continue - if _has_unsupported_block_mapping_key(active_line): - yield line_number, "" - continue - match = MAPPING_RE.match(active_line) if flow_depth == 0 else None - if match: - key = _unquote(match.group("key")) - value = match.group("value").strip() - if BLOCK_SCALAR_VALUE_RE.fullmatch(value): - block_parent_indent = indent - block_content_indent = None - if key == "uses": - block_uses_line = line_number - block_uses_content = [] - continue - if key == "uses": - yield line_number, _unquote(value) - if BLOCK_SCALAR_RE.search(raw_line): - block_parent_indent = indent - block_content_indent = None - continue - previous_flow_depth = flow_depth - flow_references, flow_depth = _flow_uses_references(active_line, flow_depth) - for reference in flow_references: - yield line_number, reference - if previous_flow_depth == 0 and flow_depth > 0: - flow_start_line = line_number - elif flow_depth == 0: - flow_start_line = None - if explicit_uses_line is not None: - yield explicit_uses_line, "" - if flow_depth > 0: - yield flow_start_line or 1, "" - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() + return workflow_yaml.iter_uses_references(path.read_text(encoding="utf-8")) def validate_reference(reference: str) -> str | None: @@ -349,7 +34,11 @@ def validate_reference(reference: str) -> str | None: def main(argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) - paths = [Path(value) for value in args] if args else list(iter_workflow_files()) + paths = ( + [Path(value) for value in args] + if args + else [*workflow_yaml.iter_workflow_files(WORKFLOWS), *iter_action_files()] + ) violations = [] for path in paths: for line_number, reference in iter_uses_references(path): diff --git a/scripts/check_workflow_security.py b/scripts/check_workflow_security.py index 03811133..cf6f9703 100755 --- a/scripts/check_workflow_security.py +++ b/scripts/check_workflow_security.py @@ -5,12 +5,13 @@ from pathlib import Path import re import sys +from workflow_yaml import iter_uses_references, iter_workflow_files, scalar + ROOT = Path(__file__).resolve().parents[1] WORKFLOWS = ROOT / ".github" / "workflows" CI_WORKFLOW = Path(".github/workflows/ci.yml") FULL_SHA = re.compile(r"[0-9a-f]{40}") -USES_LINE = re.compile(r"^\s*(?:-\s+)?(?:uses|'uses'|\"uses\")\s*:\s*(.*?)\s*$") def _active_text(text: str) -> str: @@ -19,34 +20,6 @@ def _active_text(text: str) -> str: ) -def _scalar(value: str) -> str: - """Remove an inline YAML comment and matching scalar quotes.""" - quote: str | None = None - escaped = False - end = len(value) - for index, character in enumerate(value): - if escaped: - escaped = False - continue - if quote == '"' and character == "\\": - escaped = True - continue - if character in ("'", '"'): - if quote is None: - quote = character - elif quote == character: - quote = None - elif character == "#" and quote is None and ( - index == 0 or value[index - 1].isspace() - ): - end = index - break - result = value[:end].strip() - if len(result) >= 2 and result[0] == result[-1] and result[0] in ("'", '"'): - return result[1:-1] - return result - - def _has_trigger(text: str, event: str) -> bool: lines = text.splitlines() on_key = r"""(?:on|'on'|"on")""" @@ -61,7 +34,7 @@ def _has_trigger(text: str, event: str) -> bool: match = re.fullmatch(rf"{on_key}:\s*(.+?)\s*", line) if match is not None and re.search( rf"""(?:^|[\[{{,\s])['"]?{re.escape(event)}['"]?(?:$|[\]}},\s:])""", - _scalar(match.group(1)), + scalar(match.group(1)), ): return True return False @@ -104,7 +77,7 @@ def _check_fail_open(path: Path, text: str) -> list[str]: r"""^\s*(?:-\s+)?(?:continue-on-error|'continue-on-error'|"continue-on-error")\s*:\s*(.*?)\s*$""", line, ) - if match is not None and _scalar(match.group(1)).lower() != "false": + if match is not None and scalar(match.group(1)).lower() != "false": errors.append(f"{path}:{line_number}: continue-on-error must remain false") if re.search(r"\|\|\s*true(?:\s|$)", line): errors.append(f"{path}:{line_number}: command must not suppress failure with || true") @@ -121,26 +94,22 @@ def check_workflow(path: Path, text: str) -> list[str]: pull_request = _has_trigger(active, "pull_request") lines = active.splitlines() - for line_index, line in enumerate(lines): - match = USES_LINE.match(line) - if match is None: - continue - reference = _scalar(match.group(1)) + for line_number, reference in iter_uses_references(active): if reference.startswith("./"): continue action, separator, ref = reference.rpartition("@") if not separator or not action or FULL_SHA.fullmatch(ref) is None: errors.append( - f"{path}:{line_index + 1}: external action must use a full commit SHA: {reference}" + f"{path}:{line_number}: external action must use a full commit SHA: {reference}" ) if pull_request and action == "actions/checkout": - step = _step_block(lines, line_index) + step = _step_block(lines, line_number - 1) if re.search( r"""(?mi)^\s+(?:persist-credentials|'persist-credentials'|"persist-credentials")\s*:\s*['"]?false['"]?\s*(?:#.*)?$""", step, ) is None: errors.append( - f"{path}:{line_index + 1}: pull-request checkout must discard GitHub credentials" + f"{path}:{line_number}: pull-request checkout must discard GitHub credentials" ) if re.search( @@ -163,7 +132,7 @@ def check_workflow(path: Path, text: str) -> list[str]: def main() -> int: errors: list[str] = [] - for path in sorted((*WORKFLOWS.glob("*.yml"), *WORKFLOWS.glob("*.yaml"))): + for path in iter_workflow_files(WORKFLOWS): errors.extend(check_workflow(path.relative_to(ROOT), path.read_text(encoding="utf-8"))) if errors: diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index 47ac4ed2..a4bf1622 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -82,30 +82,7 @@ fi # 4. Workflow and script regression guards section "workflow and script guards" -if python3 scripts/check_build_workflow.py && - python3 scripts/test_check_build_workflow.py && - python3 scripts/check_apple_spm_locks.py && - python3 scripts/test_check_apple_spm_locks.py && - python3 scripts/check_workflow_security.py && - python3 scripts/test_check_workflow_security.py && - python3 scripts/check_workflow_action_pins.py && - python3 scripts/test_check_workflow_action_pins.py && - python3 scripts/check_container_image_pins.py && - python3 scripts/test_check_container_image_pins.py && - python3 scripts/verify_runtime_inputs.py && - python3 scripts/test_verify_runtime_inputs.py && - python3 scripts/test_fetch_tvos_engine.py && - python3 scripts/test_check_codegen.py && - python3 scripts/test_generate_relay_protocol.py && - python3 scripts/test_format_native.py && - python3 scripts/check_update_packages_workflow.py && - python3 scripts/test_pubspec_version.py && - python3 scripts/test_clean_translations.py && - python3 scripts/test_run_maestro.py && - python3 scripts/test_maestro_flow_contracts.py && - python3 scripts/test_maestro_jellyfin_proxy.py && - python3 scripts/test_maestro_real_jellyfin.py && - python3 scripts/test_check_icon_consistency.py; then +if bash scripts/ci_guard_checks.sh; then ok "workflow and script guards passed" else fail "workflow or script guard failed" diff --git a/scripts/ci_guard_checks.sh b/scripts/ci_guard_checks.sh new file mode 100644 index 00000000..be182dbb --- /dev/null +++ b/scripts/ci_guard_checks.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Workflow and script regression guards. +# +# Single source of truth for the guard roster, shared by the "Verify workflow +# and script guards" step in .github/workflows/ci.yml and section 4 of +# scripts/ci_checks.sh. The checkers are named explicitly because a few of them +# belong to other jobs (check_bun_audit.py needs Bun, check_codegen.py runs via +# codegen.sh), but their regression tests are discovered by glob so a newly +# added scripts/test_*.py is picked up automatically instead of having to be +# remembered in two places. +set -euo pipefail +shopt -s nullglob + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +for checker in \ + scripts/check_build_workflow.py \ + scripts/check_apple_spm_locks.py \ + scripts/verify_runtime_inputs.py \ + scripts/check_workflow_security.py \ + scripts/check_workflow_action_pins.py \ + scripts/check_container_image_pins.py \ + scripts/check_update_packages_workflow.py; do + python3 "$checker" +done + +for guard_test in scripts/test_*.py; do + python3 "$guard_test" +done diff --git a/scripts/workflow_yaml.py b/scripts/workflow_yaml.py new file mode 100644 index 00000000..91abc154 --- /dev/null +++ b/scripts/workflow_yaml.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Shared YAML scanning for the GitHub Actions guard scripts. + +The guards deliberately avoid a YAML dependency, so the scalar plumbing and the +`uses:` scanner live here once rather than being re-implemented, with differing +rigor, in every checker. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +MAPPING_RE = re.compile( + r"""^\s*(?:-\s*)?(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*:\s*(?P.*?)\s*$""" +) +EXPLICIT_KEY_RE = re.compile( + r"""^\s*(?:-\s*)?\?\s*(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*$""" +) +EXPLICIT_VALUE_RE = re.compile(r"^\s*:\s*(?P.*?)\s*$") +BLOCK_SCALAR_RE = re.compile(r":\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$") +BLOCK_SCALAR_VALUE_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$") +YAML_DOUBLE_ESCAPES = { + "0": "\0", + "a": "\a", + "b": "\b", + "t": "\t", + "\t": "\t", + "n": "\n", + "v": "\v", + "f": "\f", + "r": "\r", + "e": "\x1b", + " ": " ", + '"': '"', + "/": "/", + "\\": "\\", + "N": "\u0085", + "_": "\u00a0", + "L": "\u2028", + "P": "\u2029", +} + + +def iter_workflow_files(directory: Path): + yield from sorted((*directory.glob("*.yml"), *directory.glob("*.yaml"))) + + +def job_block(text: str, name: str) -> str: + """Return the YAML block of a top-level job, or "" when it is absent.""" + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text + ) + return match.group(0) if match else "" + + +def strip_comment(value: str) -> str: + quote = None + escaped = False + for index, char in enumerate(value): + if escaped: + escaped = False + continue + if char == "\\" and quote == '"': + escaped = True + continue + if char in ("'", '"'): + if quote is None: + quote = char + elif quote == char: + quote = None + continue + if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + +def _decode_quoted_yaml_string(value: str) -> str | None: + if len(value) < 2 or value[0] != value[-1] or value[0] not in ("'", '"'): + return None + if value[0] == "'": + return value[1:-1].replace("''", "'") + + decoded = [] + index = 1 + end = len(value) - 1 + while index < end: + char = value[index] + if char != "\\": + decoded.append(char) + index += 1 + continue + index += 1 + if index >= end: + return None + escape = value[index] + if escape in YAML_DOUBLE_ESCAPES: + decoded.append(YAML_DOUBLE_ESCAPES[escape]) + index += 1 + continue + width = {"x": 2, "u": 4, "U": 8}.get(escape) + if width is None or index + width >= end: + return None + digits = value[index + 1 : index + 1 + width] + if not re.fullmatch(rf"[0-9a-fA-F]{{{width}}}", digits): + return None + try: + decoded.append(chr(int(digits, 16))) + except ValueError: + return None + index += width + 1 + return "".join(decoded) + + +def unquote(value: str) -> str: + decoded = _decode_quoted_yaml_string(value) + return value if decoded is None else decoded + + +def scalar(value: str) -> str: + """Read a single-line YAML scalar: drop an inline comment and its quotes.""" + return unquote(strip_comment(value).strip()) + + +def _flow_value(line: str, start: int, mapping_depth: int) -> str: + index = start + quote = None + escaped = False + depth = mapping_depth + while index < len(line): + char = line[index] + if escaped: + escaped = False + elif char == "\\" and quote == '"': + escaped = True + elif quote is not None: + if char == quote: + quote = None + elif char in ("'", '"'): + quote = char + elif char in ("{", "["): + depth += 1 + elif char in ("}", "]"): + if depth == mapping_depth: + break + depth -= 1 + elif char == "," and depth == mapping_depth: + break + index += 1 + return unquote(line[start:index].strip()) + + +def _has_unsupported_block_mapping_key(line: str) -> bool: + candidate = line.lstrip() + if candidate.startswith("-") and not candidate.startswith("---"): + candidate = candidate[1:].lstrip() + if not candidate: + return False + if candidate[0] in "!&*": + return True + if candidate[0] not in ("'", '"'): + return False + + quote = candidate[0] + escaped = False + index = 1 + while index < len(candidate): + char = candidate[index] + if quote == "'" and char == "'" and index + 1 < len(candidate) and candidate[index + 1] == "'": + index += 2 + continue + if escaped: + escaped = False + elif quote == '"' and char == "\\": + escaped = True + elif char == quote: + return False + index += 1 + return True + + +def _flow_uses_references(line: str, initial_depth: int) -> tuple[list[str], int]: + references = [] + depth = initial_depth + index = 0 + while index < len(line): + char = line[index] + if char in ("'", '"'): + quote = char + escaped = False + end = index + 1 + while end < len(line): + quoted_char = line[end] + if escaped: + escaped = False + elif quoted_char == "\\" and quote == '"': + escaped = True + elif quoted_char == quote: + break + end += 1 + if end >= len(line): + if depth > 0: + references.append("") + return references, depth + key = _decode_quoted_yaml_string(line[index : end + 1]) + after_key = end + 1 + while after_key < len(line) and line[after_key].isspace(): + after_key += 1 + if depth > 0 and after_key < len(line) and line[after_key] == ":": + if key == "uses": + references.append(_flow_value(line, after_key + 1, depth)) + elif key is None: + references.append("") + index = end + 1 + continue + if line.startswith("${{", index): + expression_end = line.find("}}", index + 3) + if expression_end < 0: + references.append("") + return references, depth + index = expression_end + 2 + continue + if char in ("{", "["): + depth += 1 + index += 1 + continue + if char in ("}", "]"): + depth = max(0, depth - 1) + index += 1 + continue + if depth > 0 and char == "?": + references.append("") + index += 1 + continue + if depth > 0 and char in "!&*": + references.append("") + index += 1 + continue + if depth > 0 and (char.isalpha() or char == "_"): + end = index + 1 + while end < len(line) and (line[end].isalnum() or line[end] in "_-"): + end += 1 + after_key = end + while after_key < len(line) and line[after_key].isspace(): + after_key += 1 + if line[index:end] == "uses" and after_key < len(line) and line[after_key] == ":": + references.append(_flow_value(line, after_key + 1, depth)) + index = end + continue + index += 1 + return references, depth + + +def iter_uses_references(text: str): + """Yield (line number, reference) for every `uses:` value in a workflow. + + Constructs the scanner cannot resolve are yielded as `<...>` placeholders so + that callers fail closed rather than silently skipping an unpinned action. + """ + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content: list[str] = [] + explicit_uses_line = None + flow_start_line = None + flow_depth = 0 + for line_number, raw_line in enumerate(text.splitlines(), 1): + stripped = raw_line.lstrip() + indent = len(raw_line) - len(stripped) + if block_parent_indent is not None: + if not stripped: + if block_uses_line is not None: + block_uses_content.append("") + continue + if indent <= block_parent_indent: + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content = [] + elif block_content_indent is None: + block_content_indent = indent + if block_uses_line is not None: + block_uses_content.append(raw_line[block_content_indent:]) + continue + elif indent >= block_content_indent: + if block_uses_line is not None: + block_uses_content.append(raw_line[block_content_indent:]) + continue + else: + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content = [] + if stripped.startswith("#") or not stripped: + continue + active_line = strip_comment(raw_line) + if explicit_uses_line is not None: + explicit_value = EXPLICIT_VALUE_RE.match(active_line) + if explicit_value is None: + yield explicit_uses_line, "" + else: + value = explicit_value.group("value").strip() + if BLOCK_SCALAR_VALUE_RE.fullmatch(value): + block_parent_indent = indent + block_content_indent = None + block_uses_line = explicit_uses_line + block_uses_content = [] + else: + yield explicit_uses_line, unquote(value) + explicit_uses_line = None + continue + explicit_uses_line = None + explicit_key = EXPLICIT_KEY_RE.match(active_line) + if explicit_key: + if unquote(explicit_key.group("key")) == "uses": + explicit_uses_line = line_number + continue + if re.match(r"^\s*(?:-\s*)?\?", active_line): + yield line_number, "" + continue + if _has_unsupported_block_mapping_key(active_line): + yield line_number, "" + continue + match = MAPPING_RE.match(active_line) if flow_depth == 0 else None + if match: + key = unquote(match.group("key")) + value = match.group("value").strip() + if BLOCK_SCALAR_VALUE_RE.fullmatch(value): + block_parent_indent = indent + block_content_indent = None + if key == "uses": + block_uses_line = line_number + block_uses_content = [] + continue + if key == "uses": + yield line_number, unquote(value) + if BLOCK_SCALAR_RE.search(raw_line): + block_parent_indent = indent + block_content_indent = None + continue + previous_flow_depth = flow_depth + flow_references, flow_depth = _flow_uses_references(active_line, flow_depth) + for reference in flow_references: + yield line_number, reference + if previous_flow_depth == 0 and flow_depth > 0: + flow_start_line = line_number + elif flow_depth == 0: + flow_start_line = None + if explicit_uses_line is not None: + yield explicit_uses_line, "" + if flow_depth > 0: + yield flow_start_line or 1, "" + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() diff --git a/server/artifact_store.go b/server/artifact_store.go new file mode 100644 index 00000000..a2caf70e --- /dev/null +++ b/server/artifact_store.go @@ -0,0 +1,429 @@ +package main + +import ( + "crypto/rand" + "errors" + "io/fs" + "log" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// --- Artifact store --- +// +// Uploaded logs and posters are the same on-disk artifact store: a flat +// directory of `` files whose mtime carries the creation time, written +// through a temp file, capped by a quota and swept for expiry. Files that +// cannot be deleted become pending debt so a failed removal never silently +// frees quota. artifactStore implements all of that once; the two flavours +// differ only in the policy fields below. + +type artifactRemovalError struct { + err error +} + +func (e *artifactRemovalError) Error() string { + return "artifact removal failed" +} + +func (e *artifactRemovalError) Unwrap() error { + return e.err +} + +var errArtifactOutsideStore = errors.New("artifact path outside store") + +func classifyRemovalError(err error) error { + if err == nil || errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func removeArtifact(removeFile func(string) error, root, path string) error { + err := classifyRemovalError(removeFile(path)) + if err == nil { + return nil + } + if !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) { + return &artifactRemovalError{err: err} + } + if err := removeConfinedDirectory(root, path); err != nil { + return &artifactRemovalError{err: err} + } + return nil +} + +func removeConfinedDirectory(root, path string) error { + info, err := os.Lstat(path) + if err != nil { + return classifyRemovalError(err) + } + if !info.IsDir() { + return syscall.ENOTDIR + } + + rootPath, err := filepath.Abs(root) + if err != nil { + return errArtifactOutsideStore + } + rootPath, err = filepath.EvalSymlinks(rootPath) + if err != nil { + return errArtifactOutsideStore + } + artifactPath, err := filepath.Abs(path) + if err != nil { + return errArtifactOutsideStore + } + artifactPath, err = filepath.EvalSymlinks(artifactPath) + if err != nil { + return errArtifactOutsideStore + } + relative, err := filepath.Rel(rootPath, artifactPath) + if err != nil || + relative == "." || + relative == ".." || + filepath.IsAbs(relative) || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errArtifactOutsideStore + } + return os.RemoveAll(artifactPath) +} + +const idChars = "abcdefghijklmnopqrstuvwxyz0123456789" + +func generateID(length int) string { + b := make([]byte, length) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(idChars)))) + b[i] = idChars[n.Int64()] + } + return string(b) +} + +func validID(id string, length int) bool { + if len(id) != length { + return false + } + for _, ch := range id { + if !strings.ContainsRune(idChars, ch) { + return false + } + } + return true +} + +// pendingRemoval is an artifact file that could not be deleted yet. Its size is +// unknown when the file could not be stat'ed or is not a regular file. +type pendingRemoval struct { + size int64 + sizeKnown bool +} + +type artifactEntry struct { + Filename string + Size int64 + ContentType string + CreatedAt time.Time + ExpiresAt time.Time +} + +type artifactStore struct { + entries map[string]artifactEntry + pendingRemovals map[string]pendingRemoval + dir string + name string // log prefix, e.g. "logs" + maxAge time.Duration + removeFile func(string) error + + // Policy. generateID is a field so tests can force ID collisions. + generateID func() string + idFromFilename func(filename string) (string, bool) + // acceptLoaded reports whether a file found on disk is a usable artifact + // and returns the content type recorded for it. + acceptLoaded func(filename string, size int64) (string, bool) + // limit caps accountedLocked, measured in the units cost returns: + // one per artifact for logs, bytes for posters. + limit int64 + cost func(size int64) int64 + pendingCost func(pending pendingRemoval) int64 + // evictToFit admits a new artifact by evicting the oldest live ones; + // stores that leave it false reject the upload with errFull instead. + evictToFit bool + // retryKnownDebtOnPut retries only debt whose size is accounted, leaving + // unknown debt to periodic cleanup. + retryKnownDebtOnPut bool + errFull error + + // Pending removals whose cost is not accounted (pendingCost returns 0) are + // tracked only by their pendingRemovals entry; no separate counter exists. + used int64 // accounted cost of live entries + pendingDebt int64 // accounted cost of pending removals + startupErr error + mu sync.RWMutex +} + +func (as *artifactStore) filePath(filename string) string { + return filepath.Join(as.dir, filename) +} + +func (as *artifactStore) accountedLocked() int64 { + return as.used + as.pendingDebt +} + +func (as *artifactStore) loadExisting(now time.Time) error { + as.mu.Lock() + defer as.mu.Unlock() + + files, err := os.ReadDir(as.dir) + if err != nil { + log.Printf("%s: failed to read dir %s: %v", as.name, as.dir, err) + return nil + } + var removalErr error + drop := func(file fs.DirEntry) { + size, sizeKnown := dirEntrySize(file) + removalErr = errors.Join(removalErr, as.removeUntrackedLocked(file.Name(), size, sizeKnown)) + } + for _, file := range files { + filename := file.Name() + if file.IsDir() || strings.HasSuffix(filename, ".tmp") { + drop(file) + continue + } + id, ok := as.idFromFilename(filename) + if !ok { + drop(file) + continue + } + info, infoErr := file.Info() + if infoErr != nil || !info.Mode().IsRegular() { + drop(file) + continue + } + contentType, ok := as.acceptLoaded(filename, info.Size()) + if !ok { + drop(file) + continue + } + // Several extensions can map to one id; keep the first and drop the rest. + if _, duplicate := as.entries[id]; duplicate { + drop(file) + continue + } + createdAt := info.ModTime() + as.entries[id] = artifactEntry{ + Filename: filename, + Size: info.Size(), + ContentType: contentType, + CreatedAt: createdAt, + ExpiresAt: createdAt.Add(as.maxAge), + } + as.used += as.cost(info.Size()) + } + removalErr = errors.Join(removalErr, as.cleanupExpiredLocked(now)) + removalErr = errors.Join(removalErr, as.evictOldestLocked(0)) + return removalErr +} + +// put writes data as `` once the quota allows it. +func (as *artifactStore) put(data []byte, ext, contentType string, now time.Time) (string, artifactEntry, error) { + as.mu.Lock() + defer as.mu.Unlock() + + size := int64(len(data)) + cost := as.cost(size) + // Reclaim what the quota can get back without touching live entries. + // Removal failures stay accounted as debt instead of blocking the write. + _ = as.retryPendingLocked(as.retryKnownDebtOnPut) + _ = as.cleanupExpiredLocked(now) + var headroom int64 + if as.evictToFit { + headroom = cost + } + if err := as.evictOldestLocked(headroom); err != nil { + return "", artifactEntry{}, err + } + if as.accountedLocked()+cost > as.limit { + return "", artifactEntry{}, as.errFull + } + + id := as.generateID() + for { + if _, exists := as.entries[id]; !exists { + if _, err := os.Stat(as.filePath(id + ext)); errors.Is(err, fs.ErrNotExist) { + break + } + } + id = as.generateID() + } + + filename := id + ext + path := as.filePath(filename) + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + as.cleanupFailedTempLocked(tmpPath) + return "", artifactEntry{}, err + } + if err := os.Rename(tmpPath, path); err != nil { + as.cleanupFailedTempLocked(tmpPath) + return "", artifactEntry{}, err + } + _ = os.Chtimes(path, now, now) + + entry := artifactEntry{ + Filename: filename, + Size: size, + ContentType: contentType, + CreatedAt: now, + ExpiresAt: now.Add(as.maxAge), + } + as.entries[id] = entry + as.used += cost + return id, entry, nil +} + +// lookupEntry returns the live entry for id, dropping it when it has expired. +// match, when set, rejects entries the caller did not ask for before expiry is +// considered, so a mismatched request never triggers a removal. +func (as *artifactStore) lookupEntry( + id string, + now time.Time, + match func(artifactEntry) bool, +) (artifactEntry, bool, error) { + as.mu.Lock() + defer as.mu.Unlock() + entry, ok := as.entries[id] + if !ok || (match != nil && !match(entry)) { + return artifactEntry{}, false, nil + } + if !now.Before(entry.ExpiresAt) { + if err := as.deleteEntryLocked(id); err != nil { + return artifactEntry{}, false, err + } + return artifactEntry{}, false, nil + } + return entry, true, nil +} + +func (as *artifactStore) cleanup(now time.Time) error { + as.mu.Lock() + defer as.mu.Unlock() + return as.cleanupLocked(now) +} + +func (as *artifactStore) cleanupLocked(now time.Time) error { + removalErr := as.retryPendingLocked(false) + removalErr = errors.Join(removalErr, as.cleanupExpiredLocked(now)) + return errors.Join(removalErr, as.evictOldestLocked(0)) +} + +func (as *artifactStore) cleanupExpiredLocked(now time.Time) error { + var removalErr error + for id, entry := range as.entries { + if !now.Before(entry.ExpiresAt) { + removalErr = errors.Join(removalErr, as.deleteEntryLocked(id)) + } + } + return removalErr +} + +// evictOldestLocked deletes oldest-first until headroom more cost units fit. +func (as *artifactStore) evictOldestLocked(headroom int64) error { + for as.accountedLocked()+headroom > as.limit && len(as.entries) > 0 { + var oldestID string + var oldest artifactEntry + for id, entry := range as.entries { + if oldestID == "" || entry.CreatedAt.Before(oldest.CreatedAt) { + oldestID = id + oldest = entry + } + } + if err := as.deleteEntryLocked(oldestID); err != nil { + return err + } + } + return nil +} + +func (as *artifactStore) deleteEntryLocked(id string) error { + entry, ok := as.entries[id] + if !ok { + return nil + } + if err := removeArtifact(as.removeFile, as.dir, as.filePath(entry.Filename)); err != nil { + return err + } + delete(as.entries, id) + as.used -= as.cost(entry.Size) + return nil +} + +// removeUntrackedLocked deletes a file the index does not own, recording it as +// pending debt when the removal fails. +func (as *artifactStore) removeUntrackedLocked(filename string, size int64, sizeKnown bool) error { + if err := removeArtifact(as.removeFile, as.dir, as.filePath(filename)); err != nil { + as.addPendingLocked(filename, size, sizeKnown) + return err + } + as.dropPendingLocked(filename) + return nil +} + +func (as *artifactStore) retryPendingLocked(knownDebtOnly bool) error { + var removalErr error + for filename, pending := range as.pendingRemovals { + if knownDebtOnly && !pending.sizeKnown { + continue + } + if err := removeArtifact(as.removeFile, as.dir, as.filePath(filename)); err != nil { + removalErr = errors.Join(removalErr, err) + continue + } + as.dropPendingLocked(filename) + } + return removalErr +} + +func (as *artifactStore) cleanupFailedTempLocked(tmpPath string) { + size, sizeKnown := fileSize(tmpPath) + _ = as.removeUntrackedLocked(filepath.Base(tmpPath), size, sizeKnown) +} + +func (as *artifactStore) addPendingLocked(filename string, size int64, sizeKnown bool) { + if _, exists := as.pendingRemovals[filename]; exists { + return + } + pending := pendingRemoval{size: size, sizeKnown: sizeKnown} + as.pendingRemovals[filename] = pending + as.pendingDebt += as.pendingCost(pending) +} + +func (as *artifactStore) dropPendingLocked(filename string) { + pending, exists := as.pendingRemovals[filename] + if !exists { + return + } + delete(as.pendingRemovals, filename) + as.pendingDebt -= as.pendingCost(pending) +} + +func dirEntrySize(file fs.DirEntry) (int64, bool) { + info, err := file.Info() + if err != nil || !info.Mode().IsRegular() { + return 0, false + } + return info.Size(), true +} + +func fileSize(path string) (int64, bool) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return 0, false + } + return info.Size(), true +} diff --git a/server/main.go b/server/main.go index 4ab925f6..4c807d04 100644 --- a/server/main.go +++ b/server/main.go @@ -13,7 +13,6 @@ import ( "io" "io/fs" "log" - "math/big" "net" "net/http" "os" @@ -419,100 +418,17 @@ func (r *Room) sendFrom(senderID string, sender *Client, targetID string, msg se } // --- Log store --- -type artifactRemovalError struct { - err error -} -func (e *artifactRemovalError) Error() string { - return "artifact removal failed" -} - -func (e *artifactRemovalError) Unwrap() error { - return e.err -} - -var errArtifactOutsideStore = errors.New("artifact path outside store") - -func classifyRemovalError(err error) error { - if err == nil || errors.Is(err, fs.ErrNotExist) { - return nil - } - return err -} - -func removeArtifact(removeFile func(string) error, root, path string) error { - err := classifyRemovalError(removeFile(path)) - if err == nil { - return nil - } - if !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) { - return &artifactRemovalError{err: err} - } - if err := removeConfinedDirectory(root, path); err != nil { - return &artifactRemovalError{err: err} - } - return nil -} - -func removeConfinedDirectory(root, path string) error { - info, err := os.Lstat(path) - if err != nil { - return classifyRemovalError(err) - } - if !info.IsDir() { - return syscall.ENOTDIR - } - - rootPath, err := filepath.Abs(root) - if err != nil { - return errArtifactOutsideStore - } - rootPath, err = filepath.EvalSymlinks(rootPath) - if err != nil { - return errArtifactOutsideStore - } - artifactPath, err := filepath.Abs(path) - if err != nil { - return errArtifactOutsideStore - } - artifactPath, err = filepath.EvalSymlinks(artifactPath) - if err != nil { - return errArtifactOutsideStore - } - relative, err := filepath.Rel(rootPath, artifactPath) - if err != nil || - relative == "." || - relative == ".." || - filepath.IsAbs(relative) || - strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return errArtifactOutsideStore - } - return os.RemoveAll(artifactPath) -} - -type pendingRemoval struct { - size int64 - sizeKnown bool -} - -type logEntry struct { - Size int - CreatedAt time.Time - ExpiresAt time.Time -} +const logFileExt = ".log" var errLogStoreFull = errors.New("log store full") +// logStore keeps diagnostic uploads capped by artifact count; a full store +// rejects new uploads rather than evicting logs someone may still be reading. type logStore struct { - entries map[string]logEntry - pendingRemovals map[string]pendingRemoval + artifactStore rateLimit map[string]time.Time // IP -> last upload time failedLookupRate map[string]*rateLimiter - dir string - generateID func() string - removeFile func(string) error - startupErr error - mu sync.RWMutex } func newLogStore(dir string) *logStore { @@ -524,31 +440,32 @@ func newLogStoreWithRemover(dir string, removeFile func(string) error) *logStore log.Fatalf("failed to create log dir %s: %v", dir, err) } ls := &logStore{ - entries: make(map[string]logEntry), - pendingRemovals: make(map[string]pendingRemoval), + artifactStore: artifactStore{ + entries: make(map[string]artifactEntry), + pendingRemovals: make(map[string]pendingRemoval), + dir: dir, + name: "logs", + maxAge: logMaxAge, + removeFile: removeFile, + generateID: generateLogID, + idFromFilename: logIDFromFilename, + acceptLoaded: func(_ string, size int64) (string, bool) { + return "", size > 0 && size <= maxLogSize + }, + limit: maxLogEntries, + cost: func(int64) int64 { return 1 }, + pendingCost: func(pendingRemoval) int64 { return 1 }, + errFull: errLogStoreFull, + }, rateLimit: make(map[string]time.Time), failedLookupRate: make(map[string]*rateLimiter), - dir: dir, - generateID: generateLogID, - removeFile: removeFile, } ls.startupErr = ls.loadExisting(time.Now()) return ls } func (ls *logStore) filePath(id string) string { - return filepath.Join(ls.dir, id+".log") -} - -const idChars = "abcdefghijklmnopqrstuvwxyz0123456789" - -func generateID(length int) string { - b := make([]byte, length) - for i := range b { - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(idChars)))) - b[i] = idChars[n.Int64()] - } - return string(b) + return ls.artifactStore.filePath(id + logFileExt) } func generateLogID() string { @@ -556,149 +473,28 @@ func generateLogID() string { } func logIDFromFilename(filename string) (string, bool) { - if filepath.Ext(filename) != ".log" { + if filepath.Ext(filename) != logFileExt { return "", false } - id := strings.TrimSuffix(filename, ".log") + id := strings.TrimSuffix(filename, logFileExt) return id, validID(id, logIDLength) } -func (ls *logStore) loadExisting(now time.Time) error { - ls.mu.Lock() - defer ls.mu.Unlock() - - files, err := os.ReadDir(ls.dir) - if err != nil { - log.Printf("logs: failed to read dir %s: %v", ls.dir, err) - return nil - } - var removalErr error - for _, file := range files { - filename := file.Name() - if file.IsDir() || strings.HasSuffix(filename, ".tmp") { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - id, ok := logIDFromFilename(filename) - if !ok { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - info, infoErr := file.Info() - if infoErr != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxLogSize { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - createdAt := info.ModTime() - ls.entries[id] = logEntry{ - Size: int(info.Size()), - CreatedAt: createdAt, - ExpiresAt: createdAt.Add(logMaxAge), - } - } - removalErr = errors.Join(removalErr, ls.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ls.evictOldestLocked(maxLogEntries)) - return removalErr -} - -func (ls *logStore) removeUntrackedLocked(filename string) error { - if err := removeArtifact(ls.removeFile, ls.dir, filepath.Join(ls.dir, filename)); err != nil { - if _, exists := ls.pendingRemovals[filename]; !exists { - ls.pendingRemovals[filename] = pendingRemoval{} - } - return err - } - delete(ls.pendingRemovals, filename) - return nil -} - -func (ls *logStore) retryPendingLocked() error { - var removalErr error - for filename := range ls.pendingRemovals { - if err := removeArtifact(ls.removeFile, ls.dir, filepath.Join(ls.dir, filename)); err != nil { - removalErr = errors.Join(removalErr, err) - continue - } - delete(ls.pendingRemovals, filename) - } - return removalErr -} - -func (ls *logStore) cleanupFailedTempLocked(tmpPath string) { - _ = ls.removeUntrackedLocked(filepath.Base(tmpPath)) -} - -func (ls *logStore) artifactCountLocked() int { - return len(ls.entries) + len(ls.pendingRemovals) -} - -func (ls *logStore) store(data []byte, now time.Time) (string, logEntry, error) { +func (ls *logStore) store(data []byte, now time.Time) (string, artifactEntry, error) { if len(data) == 0 { - return "", logEntry{}, errors.New("empty log") + return "", artifactEntry{}, errors.New("empty log") } if len(data) > maxLogSize { - return "", logEntry{}, errors.New("log too large") + return "", artifactEntry{}, errors.New("log too large") } - - ls.mu.Lock() - defer ls.mu.Unlock() - _ = ls.retryPendingLocked() - _ = ls.cleanupExpiredLocked(now) - if err := ls.evictOldestLocked(maxLogEntries); err != nil { - return "", logEntry{}, err - } - if ls.artifactCountLocked() >= maxLogEntries { - return "", logEntry{}, errLogStoreFull - } - - id := ls.generateID() - for { - if _, exists := ls.entries[id]; !exists { - if _, err := os.Stat(ls.filePath(id)); errors.Is(err, fs.ErrNotExist) { - break - } - } - id = ls.generateID() - } - - path := ls.filePath(id) - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - ls.cleanupFailedTempLocked(tmpPath) - return "", logEntry{}, err - } - if err := os.Rename(tmpPath, path); err != nil { - ls.cleanupFailedTempLocked(tmpPath) - return "", logEntry{}, err - } - _ = os.Chtimes(path, now, now) - - entry := logEntry{ - Size: len(data), - CreatedAt: now, - ExpiresAt: now.Add(logMaxAge), - } - ls.entries[id] = entry - return id, entry, nil + return ls.put(data, logFileExt, "", now) } -func (ls *logStore) lookup(id string, now time.Time) (logEntry, bool, error) { +func (ls *logStore) lookup(id string, now time.Time) (artifactEntry, bool, error) { if !validID(id, logIDLength) { - return logEntry{}, false, nil + return artifactEntry{}, false, nil } - ls.mu.Lock() - defer ls.mu.Unlock() - entry, ok := ls.entries[id] - if !ok { - return logEntry{}, false, nil - } - if !now.Before(entry.ExpiresAt) { - if err := ls.deleteEntryLocked(id); err != nil { - return logEntry{}, false, err - } - return logEntry{}, false, nil - } - return entry, true, nil + return ls.lookupEntry(id, now, nil) } func (ls *logStore) allowFailedLookup(source string, now time.Time) bool { @@ -716,53 +512,10 @@ func (ls *logStore) allowFailedLookup(source string, now time.Time) bool { return limiter.allowAt(now) } -func (ls *logStore) cleanupExpiredLocked(now time.Time) error { - var removalErr error - for id, entry := range ls.entries { - if !now.Before(entry.ExpiresAt) { - removalErr = errors.Join(removalErr, ls.deleteEntryLocked(id)) - } - } - return removalErr -} - -func (ls *logStore) evictOldestLocked(limit int) error { - for ls.artifactCountLocked() > limit { - var oldestID string - var oldest logEntry - for id, entry := range ls.entries { - if oldestID == "" || entry.CreatedAt.Before(oldest.CreatedAt) { - oldestID = id - oldest = entry - } - } - if oldestID == "" { - return nil - } - if err := ls.deleteEntryLocked(oldestID); err != nil { - return err - } - } - return nil -} - -func (ls *logStore) deleteEntryLocked(id string) error { - if _, ok := ls.entries[id]; !ok { - return nil - } - if err := removeArtifact(ls.removeFile, ls.dir, ls.filePath(id)); err != nil { - return err - } - delete(ls.entries, id) - return nil -} - func (ls *logStore) cleanup(now time.Time) error { ls.mu.Lock() defer ls.mu.Unlock() - removalErr := ls.retryPendingLocked() - removalErr = errors.Join(removalErr, ls.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ls.evictOldestLocked(maxLogEntries)) + removalErr := ls.cleanupLocked(now) cleanupRateWindows(ls.rateLimit, now, logRateInterval) cleanupRateLimiters(ls.failedLookupRate, now, nil) return removalErr @@ -770,25 +523,12 @@ func (ls *logStore) cleanup(now time.Time) error { // --- Poster store --- -type posterEntry struct { - Filename string - Size int64 - ContentType string - CreatedAt time.Time - ExpiresAt time.Time -} +var errPosterStoreFull = errors.New("poster store full") +// posterStore caps shared posters by accounted bytes and evicts the oldest to +// admit a new upload. type posterStore struct { - entries map[string]posterEntry - pendingRemovals map[string]pendingRemoval - dir string - maxBytes int64 - maxAge time.Duration - totalBytes int64 - pendingBytes int64 - removeFile func(string) error - startupErr error - mu sync.RWMutex + artifactStore } func newPosterStore(dir string, maxBytes int64, maxAge time.Duration) *posterStore { @@ -804,22 +544,37 @@ func newPosterStoreWithRemover( if err := os.MkdirAll(dir, 0755); err != nil { log.Fatalf("failed to create poster dir %s: %v", dir, err) } - ps := &posterStore{ - entries: make(map[string]posterEntry), + ps := &posterStore{artifactStore{ + entries: make(map[string]artifactEntry), pendingRemovals: make(map[string]pendingRemoval), dir: dir, - maxBytes: maxBytes, + name: "posters", maxAge: maxAge, removeFile: removeFile, - } + generateID: generatePosterID, + idFromFilename: posterIDFromFilename, + acceptLoaded: func(filename string, _ int64) (string, bool) { + return posterContentTypeForExt(filepath.Ext(filename)) + }, + limit: maxBytes, + cost: func(size int64) int64 { return size }, + pendingCost: func(pending pendingRemoval) int64 { + // Unknown debt cannot be sized safely, so it is kept out of the + // quota: a permanent directory or stat failure must not deny + // otherwise capacity-safe uploads. + if !pending.sizeKnown { + return 0 + } + return pending.size + }, + evictToFit: true, + retryKnownDebtOnPut: true, + errFull: errPosterStoreFull, + }} ps.startupErr = ps.loadExisting(time.Now()) return ps } -func (ps *posterStore) filePath(filename string) string { - return filepath.Join(ps.dir, filename) -} - func generatePosterID() string { return generateID(posterIDLength) } @@ -854,18 +609,6 @@ func posterContentTypeForExt(ext string) (string, bool) { } } -func validID(id string, length int) bool { - if len(id) != length { - return false - } - for _, ch := range id { - if !strings.ContainsRune(idChars, ch) { - return false - } - } - return true -} - func posterIDFromFilename(filename string) (string, bool) { if filename == "" || strings.ContainsAny(filename, `/\\`) { return "", false @@ -881,265 +624,29 @@ func posterIDFromFilename(filename string) (string, bool) { return id, true } -func (ps *posterStore) loadExisting(now time.Time) error { - ps.mu.Lock() - defer ps.mu.Unlock() - - files, err := os.ReadDir(ps.dir) - if err != nil { - log.Printf("posters: failed to read dir %s: %v", ps.dir, err) - return nil - } - var removalErr error - for _, file := range files { - filename := file.Name() - if file.IsDir() || strings.HasSuffix(filename, ".tmp") { - size, known := posterArtifactSize(file) - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, size, known), - ) - continue - } - id, ok := posterIDFromFilename(filename) - if !ok { - size, known := posterArtifactSize(file) - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, size, known), - ) - continue - } - info, infoErr := file.Info() - if infoErr != nil || !info.Mode().IsRegular() { - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, 0, false), - ) - continue - } - if _, duplicate := ps.entries[id]; duplicate { - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, info.Size(), true), - ) - continue - } - createdAt := info.ModTime() - contentType, _ := posterContentTypeForExt(filepath.Ext(filename)) - entry := posterEntry{ - Filename: filename, - Size: info.Size(), - ContentType: contentType, - CreatedAt: createdAt, - ExpiresAt: createdAt.Add(ps.maxAge), - } - ps.entries[id] = entry - ps.totalBytes += entry.Size - } - removalErr = errors.Join(removalErr, ps.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ps.evictOldestLocked(0)) - return removalErr -} - -func posterArtifactSize(file fs.DirEntry) (int64, bool) { - info, err := file.Info() - if err != nil || !info.Mode().IsRegular() { - return 0, false - } - return info.Size(), true -} - -func (ps *posterStore) addPendingLocked(filename string, size int64, known bool) { - if _, exists := ps.pendingRemovals[filename]; exists { - return - } - ps.pendingRemovals[filename] = pendingRemoval{size: size, sizeKnown: known} - if known { - ps.pendingBytes += size - } -} - -func (ps *posterStore) removeUntrackedLocked(filename string, size int64, known bool) error { - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(filename)); err != nil { - ps.addPendingLocked(filename, size, known) - return err - } - return nil -} - -func (ps *posterStore) retryPendingLocked(knownOnly bool) error { - var removalErr error - for filename, pending := range ps.pendingRemovals { - if knownOnly && !pending.sizeKnown { - continue - } - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(filename)); err != nil { - removalErr = errors.Join(removalErr, err) - continue - } - delete(ps.pendingRemovals, filename) - if pending.sizeKnown { - ps.pendingBytes -= pending.size - } - } - return removalErr -} - -func (ps *posterStore) cleanupFailedTempLocked(tmpPath string) { - if err := removeArtifact(ps.removeFile, ps.dir, tmpPath); err == nil { - return - } - info, statErr := os.Stat(tmpPath) - known := statErr == nil && info.Mode().IsRegular() - var size int64 - if known { - size = info.Size() - } - ps.addPendingLocked(filepath.Base(tmpPath), size, known) -} - -func (ps *posterStore) accountedBytesLocked() int64 { - return ps.totalBytes + ps.pendingBytes -} - -func (ps *posterStore) store(data []byte, contentType string, now time.Time) (string, posterEntry, error) { +func (ps *posterStore) store(data []byte, contentType string, now time.Time) (string, artifactEntry, error) { entrySize := int64(len(data)) if entrySize <= 0 { - return "", posterEntry{}, errors.New("empty poster") + return "", artifactEntry{}, errors.New("empty poster") } - if entrySize > ps.maxBytes { - return "", posterEntry{}, errors.New("poster exceeds store size") + if entrySize > ps.limit { + return "", artifactEntry{}, errors.New("poster exceeds store size") } ext, ok := posterExtForContentType(contentType) if !ok { - return "", posterEntry{}, errors.New("unsupported poster type") + return "", artifactEntry{}, errors.New("unsupported poster type") } - - ps.mu.Lock() - defer ps.mu.Unlock() - - // Known regular-file debt counts against quota and is retried on demand. - // Unknown artifacts are left to periodic cleanup: their size cannot be - // accounted safely, and a permanent directory or stat failure must not - // deny otherwise capacity-safe uploads. - _ = ps.retryPendingLocked(true) - _ = ps.cleanupExpiredLocked(now) - if err := ps.evictOldestLocked(entrySize); err != nil { - return "", posterEntry{}, err - } - if ps.accountedBytesLocked()+entrySize > ps.maxBytes { - return "", posterEntry{}, errors.New("poster store full") - } - - id := generatePosterID() - for { - if _, exists := ps.entries[id]; !exists { - if _, err := os.Stat(ps.filePath(id + ext)); errors.Is(err, fs.ErrNotExist) { - break - } - } - id = generatePosterID() - } - - filename := id + ext - path := ps.filePath(filename) - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - ps.cleanupFailedTempLocked(tmpPath) - return "", posterEntry{}, err - } - if err := os.Rename(tmpPath, path); err != nil { - ps.cleanupFailedTempLocked(tmpPath) - return "", posterEntry{}, err - } - _ = os.Chtimes(path, now, now) - - entry := posterEntry{ - Filename: filename, - Size: entrySize, - ContentType: strings.ToLower(strings.SplitN(contentType, ";", 2)[0]), - CreatedAt: now, - ExpiresAt: now.Add(ps.maxAge), - } - ps.entries[id] = entry - ps.totalBytes += entry.Size - return id, entry, nil + return ps.put(data, ext, strings.ToLower(strings.SplitN(contentType, ";", 2)[0]), now) } -func (ps *posterStore) lookup(filename string, now time.Time) (posterEntry, bool, error) { +func (ps *posterStore) lookup(filename string, now time.Time) (artifactEntry, bool, error) { id, ok := posterIDFromFilename(filename) if !ok { - return posterEntry{}, false, nil + return artifactEntry{}, false, nil } - - ps.mu.Lock() - defer ps.mu.Unlock() - entry, ok := ps.entries[id] - if !ok || entry.Filename != filename { - return posterEntry{}, false, nil - } - if !now.Before(entry.ExpiresAt) { - if err := ps.deleteEntryLocked(id); err != nil { - return posterEntry{}, false, err - } - return posterEntry{}, false, nil - } - return entry, true, nil -} - -func (ps *posterStore) cleanup(now time.Time) error { - ps.mu.Lock() - defer ps.mu.Unlock() - removalErr := ps.retryPendingLocked(false) - removalErr = errors.Join(removalErr, ps.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ps.evictOldestLocked(0)) - return removalErr -} - -func (ps *posterStore) cleanupExpiredLocked(now time.Time) error { - var removalErr error - for id, entry := range ps.entries { - if !now.Before(entry.ExpiresAt) { - removalErr = errors.Join(removalErr, ps.deleteEntryLocked(id)) - } - } - return removalErr -} - -func (ps *posterStore) evictOldestLocked(extraBytes int64) error { - for ps.accountedBytesLocked()+extraBytes > ps.maxBytes && len(ps.entries) > 0 { - var oldestID string - var oldest posterEntry - first := true - for id, entry := range ps.entries { - if first || entry.CreatedAt.Before(oldest.CreatedAt) { - oldestID = id - oldest = entry - first = false - } - } - if oldestID == "" { - return nil - } - if err := ps.deleteEntryLocked(oldestID); err != nil { - return err - } - } - return nil -} - -func (ps *posterStore) deleteEntryLocked(id string) error { - entry, ok := ps.entries[id] - if !ok { - return nil - } - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(entry.Filename)); err != nil { - return err - } - delete(ps.entries, id) - ps.totalBytes -= entry.Size - return nil + return ps.lookupEntry(id, now, func(entry artifactEntry) bool { + return entry.Filename == filename + }) } // --- Snapshotter (single-writer, debounced, atomic disk persistence) --- @@ -1996,7 +1503,7 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { } type lookupResult struct { - entry logEntry + entry artifactEntry data []byte status int message string @@ -2057,7 +1564,7 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Content-Length", strconv.Itoa(lookup.entry.Size)) + w.Header().Set("Content-Length", strconv.FormatInt(lookup.entry.Size, 10)) if written, err := w.Write(lookup.data); err != nil || written != len(lookup.data) { log.Printf("logs: response write failed") } diff --git a/server/main_test.go b/server/main_test.go index 33ad200d..9828a981 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -6434,7 +6434,7 @@ func snapshotPosterStore(t *testing.T, store *posterStore) (int, int64, []string t.Helper() store.mu.RLock() entryCount := len(store.entries) - totalBytes := store.totalBytes + totalBytes := store.used store.mu.RUnlock() files, err := os.ReadDir(store.dir) if err != nil { @@ -6819,7 +6819,7 @@ func TestPosterStoreEvictsOldestOverQuota(t *testing.T) { ps.mu.RLock() _, hasFirst := ps.entries[id1] _, hasSecond := ps.entries[id2] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if hasFirst { @@ -6829,7 +6829,7 @@ func TestPosterStoreEvictsOldestOverQuota(t *testing.T) { t.Fatal("newest poster should remain") } if total != int64(len(payload)) { - t.Fatalf("totalBytes=%d want %d", total, len(payload)) + t.Fatalf("stored bytes=%d want %d", total, len(payload)) } if _, err := os.Stat(ps.filePath(entry1.Filename)); !os.IsNotExist(err) { t.Fatalf("oldest file still exists or stat failed unexpectedly: %v", err) @@ -6853,13 +6853,13 @@ func TestPosterStoreCleanupExpiresOldPosters(t *testing.T) { ps.mu.RLock() _, exists := ps.entries[id] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if exists { t.Fatal("expired poster should have been removed") } if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } if _, err := os.Stat(ps.filePath(entry.Filename)); !os.IsNotExist(err) { t.Fatalf("expired file still exists or stat failed unexpectedly: %v", err) @@ -6908,7 +6908,7 @@ func TestLogStoreRemovalFailureRetainsEntryUntilRetry(t *testing.T) { } ls.mu.RLock() _, indexed := ls.entries[id] - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !indexed || artifacts != 1 { t.Fatalf("failed removal changed metadata: indexed=%v artifacts=%d", indexed, artifacts) @@ -7045,7 +7045,7 @@ func TestLogStoreTracksFailedTempCleanup(t *testing.T) { } ls.mu.RLock() _, pending := ls.pendingRemovals[filepath.Base(tmpPath)] - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !pending || artifacts != 1 { t.Fatalf("temp cleanup not tracked: pending=%v artifacts=%d", pending, artifacts) @@ -7092,7 +7092,7 @@ func TestLogStoreStartupReconcilesLiveAndPendingRemovals(t *testing.T) { ls.mu.RLock() _, live := ls.entries[expiredID] pending := len(ls.pendingRemovals) - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !live || pending != 2 || artifacts != 3 { t.Fatalf("startup accounting: live=%v pending=%d artifacts=%d", live, pending, artifacts) @@ -7110,7 +7110,7 @@ func TestLogStoreStartupReconcilesLiveAndPendingRemovals(t *testing.T) { } restarted := newLogStore(dir) restarted.mu.RLock() - restartedArtifacts := restarted.artifactCountLocked() + restartedArtifacts := restarted.accountedLocked() _, newLogRestored := restarted.entries[newID] restarted.mu.RUnlock() if restartedArtifacts != 1 || !newLogRestored { @@ -7202,14 +7202,14 @@ func TestPosterQuotaRemovalFailureDoesNotReclaimAccounting(t *testing.T) { if !errors.Is(err, fs.ErrPermission) { t.Fatalf("quota store error=%v want permission error", err) } - if newID != "" || newEntry != (posterEntry{}) { + if newID != "" || newEntry != (artifactEntry{}) { t.Fatalf("failed store returned success values: id=%q entry=%+v", newID, newEntry) } ps.mu.RLock() _, retained := ps.entries[oldID] - total := ps.totalBytes - pending := ps.pendingBytes - accounted := ps.accountedBytesLocked() + total := ps.used + pending := ps.pendingDebt + accounted := ps.accountedLocked() ps.mu.RUnlock() if !retained || total != int64(len(payload)) || pending != 0 { t.Fatalf("failed eviction accounting: retained=%v total=%d pending=%d", retained, total, pending) @@ -7230,8 +7230,8 @@ func TestPosterQuotaRemovalFailureDoesNotReclaimAccounting(t *testing.T) { t.Fatalf("old poster remove calls=%d want 2", remover.callCount(oldPath)) } ps.mu.RLock() - accounted = ps.accountedBytesLocked() - total = ps.totalBytes + accounted = ps.accountedLocked() + total = ps.used ps.mu.RUnlock() if total != int64(len(payload)) || regularFileBytes(t, dir) != accounted { t.Fatalf("retry accounting: total=%d accounted=%d physical=%d", total, accounted, regularFileBytes(t, dir)) @@ -7260,7 +7260,7 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { } ps.mu.RLock() _, retained := ps.entries[id] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if !retained || total != entry.Size { t.Fatalf("failed expiry accounting: retained=%v total=%d", retained, total) @@ -7277,10 +7277,10 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { t.Fatalf("remove calls=%d want 2", remover.callCount(path)) } ps.mu.RLock() - total = ps.totalBytes + total = ps.used ps.mu.RUnlock() if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } }) @@ -7312,10 +7312,10 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { t.Fatalf("remove calls=%d want 1", remover.callCount(path)) } ps.mu.RLock() - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } }) } @@ -7337,8 +7337,8 @@ func TestPosterStoreKnownCleanupDebtConsumesCapacityAndRetries(t *testing.T) { t.Fatal("upload exceeded capacity after known stale bytes were accounted") } ps.mu.RLock() - pendingBytes := ps.pendingBytes - accountedBytes := ps.accountedBytesLocked() + pendingBytes := ps.pendingDebt + accountedBytes := ps.accountedLocked() ps.mu.RUnlock() if pendingBytes != 4 || accountedBytes != 4 { t.Fatalf("known debt accounting: pending=%d accounted=%d, want 4", pendingBytes, accountedBytes) @@ -7354,7 +7354,7 @@ func TestPosterStoreKnownCleanupDebtConsumesCapacityAndRetries(t *testing.T) { t.Fatalf("stored entry size=%d, want 2", entry.Size) } ps.mu.RLock() - pendingBytes = ps.pendingBytes + pendingBytes = ps.pendingDebt ps.mu.RUnlock() if pendingBytes != 0 { t.Fatalf("known debt remained after successful retry: %d bytes", pendingBytes) @@ -7391,7 +7391,7 @@ func TestPosterStoreUnknownCleanupDebtDoesNotBlockUploadAndRecovers(t *testing.T } ps.mu.RLock() pending, exists := ps.pendingRemovals[filepath.Base(unknownPath)] - pendingBytes := ps.pendingBytes + pendingBytes := ps.pendingDebt ps.mu.RUnlock() if !exists || pending.sizeKnown { t.Fatalf("unknown debt entry=(%+v, exists=%v), want present with unknown size", pending, exists) @@ -7406,7 +7406,7 @@ func TestPosterStoreUnknownCleanupDebtDoesNotBlockUploadAndRecovers(t *testing.T } ps.mu.RLock() pendingCount := len(ps.pendingRemovals) - pendingBytes = ps.pendingBytes + pendingBytes = ps.pendingDebt ps.mu.RUnlock() if pendingCount != 0 || pendingBytes != 0 { t.Fatalf("recovered debt remains: pending=%d bytes=%d", pendingCount, pendingBytes) @@ -7511,7 +7511,7 @@ func TestPosterHandlerRejectsUploadWhenQuotaRemovalFails(t *testing.T) { } posters.mu.RLock() _, retained := posters.entries[oldID] - total := posters.totalBytes + total := posters.used posters.mu.RUnlock() if !retained || total != int64(len(payload)) { t.Fatalf("failed upload changed old poster: retained=%v total=%d", retained, total) diff --git a/shared/mpv/mpv_player_common.h b/shared/mpv/mpv_player_common.h index f60e1ac6..665e6d65 100644 --- a/shared/mpv/mpv_player_common.h +++ b/shared/mpv/mpv_player_common.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -109,6 +110,210 @@ class AsyncRequestRegistry { std::mutex mutex_; }; +// Every libmpv async submission follows the same shape: register the callback, +// hand the request to mpv, and roll back when the submission fails. A negative +// result means the request never reached mpv, so nothing will ever complete it +// and the callback has to be taken back and failed inline. The uninitialized +// handle guard stays with the callers: they own the lifecycle flags and the +// error code they report when the handle is gone. +inline void SubmitCommandAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::vector& args, StatusCallback callback) { + std::vector c_args; + c_args.reserve(args.size() + 1); + for (const auto& arg : args) { + c_args.push_back(arg.c_str()); + } + c_args.push_back(nullptr); + + const uint64_t request_id = callback ? requests.RegisterStatus(std::move(callback)) : 0; + // mpv_command_async returns immediately. + const int result = mpv_command_async(mpv, request_id, c_args.data()); + if (result < 0) { + auto pending = requests.TakeStatus(request_id); + if (pending) pending(result); + } +} + +inline void SubmitSetPropertyAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::string& name, const std::string& value, + StatusCallback callback) { + const uint64_t request_id = callback ? requests.RegisterStatus(std::move(callback)) : 0; + char* property_value = const_cast(value.c_str()); + const int result = mpv_set_property_async(mpv, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); + if (result < 0) { + auto pending = requests.TakeStatus(request_id); + if (pending) pending(result); + } +} + +inline void SubmitGetPropertyAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::string& name, GetPropertyCallback callback) { + const uint64_t request_id = requests.RegisterProperty(std::move(callback)); + const int result = mpv_get_property_async(mpv, request_id, name.c_str(), MPV_FORMAT_STRING); + if (result < 0) { + auto pending = requests.TakeProperty(request_id); + if (pending) pending(result, ""); + } +} + +// Completes a COMMAND_REPLY / SET_PROPERTY_REPLY / GET_PROPERTY_REPLY against +// the pending-request registry. `sanitize` converts mpv's payload (not +// guaranteed to be valid UTF-8) into the string handed to the callback. +// Returns true when the event was a reply event and has been fully handled. +template +inline bool DispatchReplyEvent(AsyncRequestRegistry& requests, const mpv_event* event, const Sanitizer& sanitize) { + switch (event->event_id) { + case MPV_EVENT_COMMAND_REPLY: + case MPV_EVENT_SET_PROPERTY_REPLY: { + StatusCallback callback = requests.TakeStatus(event->reply_userdata); + if (callback) { + callback(event->error); + } + return true; + } + case MPV_EVENT_GET_PROPERTY_REPLY: { + GetPropertyCallback callback = requests.TakeProperty(event->reply_userdata); + if (callback) { + std::string value; + if (event->error >= 0) { + auto* prop = static_cast(event->data); + if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { + auto c_value = *static_cast(prop->data); + if (c_value) value = sanitize(c_value); + } + } + callback(event->error, value); + } + return true; + } + default: + return false; + } +} + +// Copies a PROPERTY_CHANGE payload into a node the platform marshallers can +// convert. mpv owns the storage, so the node only borrows it for the lifetime +// of the event. +inline mpv_node ExtractPropertyNode(const mpv_event_property* prop) { + mpv_node node{}; + node.format = prop ? prop->format : MPV_FORMAT_NONE; + if (!prop) return node; + + switch (prop->format) { + case MPV_FORMAT_STRING: + node.u.string = prop->data ? *static_cast(prop->data) : nullptr; + break; + case MPV_FORMAT_FLAG: + node.u.flag = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_INT64: + node.u.int64 = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_DOUBLE: + node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; + break; + case MPV_FORMAT_NODE: + if (prop->data) { + node = *static_cast(prop->data); + } else { + node.format = MPV_FORMAT_NONE; + } + break; + default: + node.format = MPV_FORMAT_NONE; + break; + } + return node; +} + +// mpv node payloads are untrusted input: a property can nest arbitrarily +// deeply, carry a list as long as mpv claims, and hold strings that are +// neither length-bounded nor valid UTF-8. Every runner walks the same trees +// into its own Flutter value type, so the walk and its bounds live here once. +static constexpr size_t kMaxNodeDepth = 32; +static constexpr int kMaxNodeEntries = 16384; + +struct NodeConversionBudget { + size_t remaining_entries = static_cast(kMaxNodeEntries); + size_t remaining_bytes = 16 * 1024 * 1024; +}; + +// Measures an mpv string without ever reading past the remaining byte budget, +// and charges it to that budget. Returns false for a missing string or one +// that would exceed what is left, which is how the walk rejects a payload. +inline bool ClaimNodeString(const char* input, NodeConversionBudget* budget, size_t* length) { + if (!input || !budget || !length) return false; + const size_t measured = strnlen(input, budget->remaining_bytes + 1); + if (measured > budget->remaining_bytes) return false; + budget->remaining_bytes -= measured; + *length = measured; + return true; +} + +// `Builder` adapts the walk to a platform value type and keeps that platform's +// UTF-8 sanitizer out of this header. It supplies the types `Value`, +// `ListBuilder` and `MapBuilder`, the leaves `Null`, `Bool`, `Int`, `Double` +// and `String(data, length)`, and the containers `NewList`/`Append`/ +// `FinishList` plus `NewMap`/`Insert`/`FinishMap`. A value passed to +// `Append`/`Insert` belongs to the builder from then on, and `AbandonMap` +// releases a partially built map whose key was rejected. +template +typename Builder::Value ConvertNode(const mpv_node* node, size_t depth, NodeConversionBudget* budget) { + if (!node || !budget || depth >= kMaxNodeDepth || budget->remaining_entries == 0) { + return Builder::Null(); + } + --budget->remaining_entries; + + switch (node->format) { + case MPV_FORMAT_STRING: { + size_t length = 0; + if (!ClaimNodeString(node->u.string, budget, &length)) return Builder::Null(); + return Builder::String(node->u.string, length); + } + case MPV_FORMAT_FLAG: + return Builder::Bool(node->u.flag != 0); + case MPV_FORMAT_INT64: + return Builder::Int(node->u.int64); + case MPV_FORMAT_DOUBLE: + return Builder::Double(node->u.double_); + case MPV_FORMAT_NODE_ARRAY: { + const mpv_node_list* list = node->u.list; + if (!list || list->num < 0 || list->num > kMaxNodeEntries || (list->num > 0 && !list->values)) { + return Builder::Null(); + } + typename Builder::ListBuilder result = Builder::NewList(); + for (int i = 0; i < list->num; i++) { + Builder::Append(result, ConvertNode(&list->values[i], depth + 1, budget)); + } + return Builder::FinishList(std::move(result)); + } + case MPV_FORMAT_NODE_MAP: { + const mpv_node_list* map = node->u.list; + if (!map || map->num < 0 || map->num > kMaxNodeEntries || (map->num > 0 && (!map->keys || !map->values))) { + return Builder::Null(); + } + typename Builder::MapBuilder result = Builder::NewMap(); + for (int i = 0; i < map->num; i++) { + size_t key_length = 0; + if (!ClaimNodeString(map->keys[i], budget, &key_length)) { + Builder::AbandonMap(result); + return Builder::Null(); + } + Builder::Insert(result, map->keys[i], key_length, ConvertNode(&map->values[i], depth + 1, budget)); + } + return Builder::FinishMap(std::move(result)); + } + default: + return Builder::Null(); + } +} + +template +typename Builder::Value ConvertNode(const mpv_node* node) { + NodeConversionBudget budget; + return ConvertNode(node, 0, &budget); +} + inline mpv_format ParsePropertyFormat(const std::string& format) { if (format == "string") return MPV_FORMAT_STRING; if (format == "flag" || format == "bool") return MPV_FORMAT_FLAG; @@ -309,6 +514,42 @@ class AudioRecoveryState { mutable std::mutex mutex_; }; +struct AudioRecoveryNotice { + // What to log, or nullptr when the property changed nothing of interest. + const char* message = nullptr; + // True when the state machine now has a reload queued, which platforms that + // drive recovery from a timer rather than an event-loop tick must wake for. + bool scheduled_work = false; +}; + +// Feeds the audio-related properties of a PROPERTY_CHANGE event into the +// recovery state machine, leaving the caller only the platform reporting. +inline AudioRecoveryNotice ObserveAudioRecoveryProperty( + AudioRecoveryState& state, const mpv_event* event, const mpv_event_property* prop) { + if (!event || !prop || !prop->name) return {}; + + if (std::strcmp(prop->name, "current-ao") == 0) { + const char* current_ao = nullptr; + if (prop->format == MPV_FORMAT_STRING && prop->data) { + current_ao = *static_cast(prop->data); + } + const bool is_null = current_ao && std::strcmp(current_ao, "null") == 0; + const auto transition = state.SetCurrentAudioOutputNull(is_null, AudioRecoveryState::Clock::now()); + if (transition == AudioOutputTransition::kFellBackToNull) { + return {"current-ao fell back to null; starting recovery", true}; + } + if (transition == AudioOutputTransition::kRecovered) { + return {"audio recovered (current-ao no longer null)", false}; + } + return {}; + } + if (std::strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && + state.OnAudioDeviceListChanged(AudioRecoveryState::Clock::now())) { + return {"audio-device-list changed while ao=null; rescheduling ao-reload", true}; + } + return {}; +} + } // namespace mpv_common } // namespace plezy diff --git a/shared/mpv/mpv_player_common_test.cpp b/shared/mpv/mpv_player_common_test.cpp index 44d9293f..648d3e44 100644 --- a/shared/mpv/mpv_player_common_test.cpp +++ b/shared/mpv/mpv_player_common_test.cpp @@ -345,6 +345,96 @@ void TestStaleReloadCompletionCannotClearCurrentRequest() { assert(state.CompleteReload(current_request.request_generation)); } +// Renders the shared node walk into text so its bounds can be asserted on +// every platform, without a platform value type in the way. +struct TextNodeBuilder { + using Value = std::string; + using ListBuilder = std::string; + using MapBuilder = std::string; + + static Value Null() { return "null"; } + static Value Bool(bool value) { return value ? "true" : "false"; } + static Value Int(int64_t value) { return std::to_string(value); } + static Value Double(double value) { return std::to_string(value); } + static Value String(const char* value, size_t length) { return "'" + std::string(value, length) + "'"; } + + static ListBuilder NewList() { return std::string("["); } + static void Append(ListBuilder& list, Value value) { list += value + ","; } + static Value FinishList(ListBuilder list) { return list + "]"; } + + static MapBuilder NewMap() { return std::string("{"); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + map += std::string(key, key_length) + ":" + value + ","; + } + static Value FinishMap(MapBuilder map) { return map + "}"; } + static void AbandonMap(MapBuilder& map) { map += ""; } +}; + +void TestNodeConversionBounds() { + using plezy::mpv_common::ConvertNode; + using plezy::mpv_common::NodeConversionBudget; + + char value[] = "hello"; + mpv_node text{}; + text.format = MPV_FORMAT_STRING; + text.u.string = value; + assert(ConvertNode(&text) == "'hello'"); + + // Missing storage is never trusted: no node, no string, no list. + assert(ConvertNode(nullptr) == "null"); + text.u.string = nullptr; + assert(ConvertNode(&text) == "null"); + + mpv_node array{}; + array.format = MPV_FORMAT_NODE_ARRAY; + array.u.list = nullptr; + assert(ConvertNode(&array) == "null"); + + // Neither a negative nor an implausible length reaches the builder. + mpv_node entry{}; + entry.format = MPV_FORMAT_INT64; + entry.u.int64 = 7; + mpv_node_list negative{-1, &entry, nullptr}; + array.u.list = &negative; + assert(ConvertNode(&array) == "null"); + mpv_node_list oversized{plezy::mpv_common::kMaxNodeEntries + 1, &entry, nullptr}; + array.u.list = &oversized; + assert(ConvertNode(&array) == "null"); + + mpv_node_list single{1, &entry, nullptr}; + array.u.list = &single; + assert(ConvertNode(&array) == "[7,]"); + + // A map with a null key is voided rather than half-converted. + char* missing_key[] = {nullptr}; + mpv_node_list keyless{1, &entry, missing_key}; + mpv_node map{}; + map.format = MPV_FORMAT_NODE_MAP; + map.u.list = &keyless; + assert(ConvertNode(&map) == "null"); + + // Depth, entry, and byte budgets each stop the walk. + std::vector chain(plezy::mpv_common::kMaxNodeDepth + 1); + std::vector links(chain.size()); + chain.back() = entry; + for (size_t i = chain.size() - 1; i > 0; --i) { + links[i - 1] = mpv_node_list{1, &chain[i], nullptr}; + chain[i - 1].format = MPV_FORMAT_NODE_ARRAY; + chain[i - 1].u.list = &links[i - 1]; + } + assert(ConvertNode(&chain[0]).find('7') == std::string::npos); + + NodeConversionBudget entries{2, 1024}; + assert(ConvertNode(&array, 0, &entries) == "[7,]"); + assert(entries.remaining_entries == 0); + assert(ConvertNode(&array, 0, &entries) == "null"); + + NodeConversionBudget bytes{8, 4}; + text.u.string = value; + assert(ConvertNode(&text, 0, &bytes) == "null"); + assert(bytes.remaining_bytes == 4); +} + void TestHdrHelpers() { assert(plezy::mpv_common::ParseEnabledFlag("yes")); assert(plezy::mpv_common::ParseEnabledFlag("true")); @@ -368,6 +458,7 @@ int main() { TestFileBoundaryRestartsNullRecoveryOnlyAfterLoad(); TestUnloadedResumeIsConsumed(); TestStaleReloadCompletionCannotClearCurrentRequest(); + TestNodeConversionBounds(); TestHdrHelpers(); return 0; } diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index b81c1fdf..cf6977f4 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/prefs.dart'; final class _EnsureOpenTrackingInterceptor extends QueryInterceptor { @@ -81,8 +82,8 @@ class _AppDatabaseTestSuite { // v20 dropped the profile_id FK so virtual Plex Home profiles can // persist join rows without a parent `profiles` row. Profile deletion // instead cleans up join rows explicitly (via the teardown flow's - // removeAllProfileConnectionsAndCleanup) before deleting the profile, - // so the cascade isn't needed. + // ProfileConnectionCleanup.removeAllProfileConnections) before deleting + // the profile, so the cascade isn't needed. final now = DateTime.now().millisecondsSinceEpoch; await db .into(db.connections) diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 26a709ad..d500e0ca 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -9,6 +9,8 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.dart'; import 'package:plezy/models/download_models.dart'; +import '../test_helpers/download_fixtures.dart'; + void main() { late AppDatabase db; @@ -20,115 +22,6 @@ void main() { await db.close(); }); - // ============================================================ - // insertDownload - // ============================================================ - - group('insertDownload', () { - test('inserts a movie row with defaults', () async { - await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: '100', - globalKey: 'srv:100', - type: 'movie', - status: DownloadStatus.queued.index, - ); - - final rows = await db.select(db.downloadedMedia).get(); - expect(rows, hasLength(1)); - final r = rows.first; - expect(r.serverId, 'srv'); - expect(r.ratingKey, '100'); - expect(r.globalKey, 'srv:100'); - expect(r.type, 'movie'); - expect(r.status, DownloadStatus.queued.index); - expect(r.parentRatingKey, isNull); - expect(r.grandparentRatingKey, isNull); - expect(r.mediaIndex, 0); - }); - - test('inserts an episode with parent and grandparent keys', () async { - await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: 'ep1', - globalKey: 'srv:ep1', - type: 'episode', - parentRatingKey: 'season1', - grandparentRatingKey: 'show1', - status: DownloadStatus.queued.index, - mediaIndex: 7, - ); - - final row = (await db.select(db.downloadedMedia).get()).single; - expect(row.parentRatingKey, 'season1'); - expect(row.grandparentRatingKey, 'show1'); - expect(row.mediaIndex, 7); - }); - - test('atomically updates metadata and attempt state while preserving the row and physical fields', () async { - await db.insertDownload( - serverId: ServerId('srv'), - clientScopeId: 'scope-old', - ratingKey: '100', - globalKey: 'srv:100', - type: 'movie', - status: DownloadStatus.queued.index, - mediaIndex: 1, - mediaSourceId: 'source-old', - ); - final original = (await db.getDownloadedMedia('srv:100'))!; - await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write( - const DownloadedMediaCompanion( - progress: Value(50), - downloadedBytes: Value(500), - totalBytes: Value(1000), - videoFilePath: Value('downloads/video.mkv'), - safRootUri: Value('content://downloads'), - thumbPath: Value('downloads/thumb.jpg'), - downloadedAt: Value(1234), - errorMessage: Value('old error'), - retryCount: Value(2), - bgTaskId: Value('current-task'), - ), - ); - - await db.insertDownload( - serverId: ServerId('srv-new'), - clientScopeId: 'scope-new', - ratingKey: '100-new', - globalKey: 'srv:100', - type: 'episode', - parentRatingKey: 'season-new', - grandparentRatingKey: 'show-new', - status: DownloadStatus.failed.index, - mediaIndex: 3, - mediaSourceId: 'source-new', - ); - - final row = (await db.select(db.downloadedMedia).get()).single; - expect(row.id, original.id); - expect(row.serverId, 'srv-new'); - expect(row.clientScopeId, 'scope-new'); - expect(row.ratingKey, '100-new'); - expect(row.type, 'episode'); - expect(row.parentRatingKey, 'season-new'); - expect(row.grandparentRatingKey, 'show-new'); - expect(row.status, DownloadStatus.failed.index); - expect(row.mediaIndex, 3); - expect(row.mediaSourceId, 'source-new'); - expect(row.progress, 0); - expect(row.downloadedBytes, 0); - expect(row.totalBytes, isNull); - expect(row.errorMessage, isNull); - expect(row.retryCount, 0); - expect(row.videoFilePath, 'downloads/video.mkv'); - expect(row.safRootUri, 'content://downloads'); - expect(row.thumbPath, 'downloads/thumb.jpg'); - expect(row.downloadedAt, 1234); - expect(row.bgTaskId, 'current-task'); - }); - }); - group('insertQueuedDownload', () { test('atomically persists media identity, scope, policy, and queue state', () async { await db.close(); diff --git a/test/focus/dpad_activation_policy_test.dart b/test/focus/dpad_activation_policy_test.dart new file mode 100644 index 00000000..3b315c6d --- /dev/null +++ b/test/focus/dpad_activation_policy_test.dart @@ -0,0 +1,88 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/focusable_wrapper.dart'; +import 'package:plezy/widgets/focusable_tab_chip.dart'; + +void main() { + // FocusableWrapper and FocusableChipStateMixin run the same d-pad activation + // sequence under opposite consume policies. These pin the two differences that + // keep the handlers separate. + group('d-pad activation policies', () { + Future> escapedKeysFor( + WidgetTester tester, + FocusNode node, + Widget child, + LogicalKeyboardKey key, + ) async { + final escaped = []; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Focus( + onKeyEvent: (_, event) { + if (event is KeyDownEvent) escaped.add(event.logicalKey); + return KeyEventResult.handled; + }, + child: child, + ), + ), + ), + ); + node.requestFocus(); + await tester.pump(); + + await tester.sendKeyEvent(key); + await tester.pump(); + return escaped; + } + + testWidgets('wrapper consumes the context menu key with no onLongPress', (tester) async { + final node = FocusNode(debugLabel: 'card'); + addTearDown(node.dispose); + + final escaped = await escapedKeysFor( + tester, + node, + FocusableWrapper(focusNode: node, onSelect: () {}, child: const SizedBox(width: 10, height: 10)), + LogicalKeyboardKey.contextMenu, + ); + + expect(escaped, isEmpty); + }); + + testWidgets('chip leaves the context menu key to its ancestors with no onLongPress', (tester) async { + final node = FocusNode(debugLabel: 'chip'); + addTearDown(node.dispose); + + final escaped = await escapedKeysFor( + tester, + node, + FocusableTabChip(label: 'Tab', isSelected: true, focusNode: node, onSelect: () {}), + LogicalKeyboardKey.contextMenu, + ); + + expect(escaped, [LogicalKeyboardKey.contextMenu]); + }); + + testWidgets('wrapper passes unmapped RIGHT/DOWN through to the framework', (tester) async { + final node = FocusNode(debugLabel: 'card'); + addTearDown(node.dispose); + Widget card() => FocusableWrapper(focusNode: node, onSelect: () {}, child: const SizedBox(width: 10, height: 10)); + + expect(await escapedKeysFor(tester, node, card(), LogicalKeyboardKey.arrowRight), [ + LogicalKeyboardKey.arrowRight, + ]); + expect(await escapedKeysFor(tester, node, card(), LogicalKeyboardKey.arrowDown), [LogicalKeyboardKey.arrowDown]); + }); + + testWidgets('chip traps unmapped RIGHT/DOWN so focus cannot escape the strip', (tester) async { + final node = FocusNode(debugLabel: 'chip'); + addTearDown(node.dispose); + Widget chip() => FocusableTabChip(label: 'Tab', isSelected: true, focusNode: node, onSelect: () {}); + + expect(await escapedKeysFor(tester, node, chip(), LogicalKeyboardKey.arrowRight), isEmpty); + expect(await escapedKeysFor(tester, node, chip(), LogicalKeyboardKey.arrowDown), isEmpty); + }); + }); +} diff --git a/test/media/media_item_test.dart b/test/media/media_item_test.dart index 8a33e890..218318bf 100644 --- a/test/media/media_item_test.dart +++ b/test/media/media_item_test.dart @@ -156,21 +156,18 @@ void main() { final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/art']); - expect(movie.heroArt(containerAspectRatio: 1.0), '/square'); }); test('near-square containers fall back to wide cover art when square art is missing', () { final movie = _movie(artPath: '/art'); expect(movie.heroArtCandidates(containerAspectRatio: 1.0), ['/art']); - expect(movie.heroArt(containerAspectRatio: 1.0), '/art'); }); test('wide containers prefer wide cover art before square art', () { final movie = _movie(artPath: '/art', backgroundSquarePath: '/square'); expect(movie.heroArtCandidates(containerAspectRatio: 16 / 9), ['/art', '/square']); - expect(movie.heroArt(containerAspectRatio: 16 / 9), '/art'); }); test('episodes prefer show art before episode art for wide hero containers', () { @@ -187,7 +184,6 @@ void main() { ); expect(episode.heroArtCandidates(containerAspectRatio: 16 / 9), ['/show-art', '/episode-art', '/square']); - expect(episode.heroArt(containerAspectRatio: 16 / 9), '/show-art'); expect(episode.heroArtCandidates(containerAspectRatio: 1.0), ['/square', '/show-art', '/episode-art']); }); diff --git a/test/media/media_playlist_test.dart b/test/media/media_playlist_test.dart index 2ef0931f..441b48ce 100644 --- a/test/media/media_playlist_test.dart +++ b/test/media/media_playlist_test.dart @@ -126,16 +126,6 @@ void main() { }); }); - group('MediaPlaylist.isEditable', () { - test('smart playlists are read-only (Plex semantics)', () { - expect(_playlist(smart: true).isEditable, isFalse); - }); - - test('manual playlists are editable', () { - expect(_playlist(smart: false).isEditable, isTrue); - }); - }); - group('MediaPlaylist.globalKey', () { test('uses ":" when serverId is set', () { final pl = _playlist(id: 'pl-42', serverId: 'srv-9'); @@ -166,7 +156,6 @@ void main() { expect(minimal.serverName, isNull); expect(minimal.displayImagePath, isNull); expect(minimal.displayTitle, 'Min'); - expect(minimal.isEditable, isTrue); // Without a serverId, globalKey reduces to the bare id. expect(minimal.globalKey, 'pl'); }); diff --git a/test/mixins/library_tab_state_test.dart b/test/mixins/library_tab_state_test.dart index 43a87408..1c7fdec4 100644 --- a/test/mixins/library_tab_state_test.dart +++ b/test/mixins/library_tab_state_test.dart @@ -7,9 +7,10 @@ import 'package:plezy/media/media_library.dart'; import 'package:plezy/mixins/library_tab_state.dart'; import 'package:provider/provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; +import '../test_helpers/multi_server_fixtures.dart'; + class _Probe extends StatefulWidget { const _Probe({required this.library, required this.onState}); @@ -45,8 +46,7 @@ void main() { late _ProbeState state; final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final provider = MultiServerProvider(manager, aggregation); + final provider = testMultiServerProvider(manager); // provider.dispose() cascades to manager.dispose() — only register // the outer teardown to avoid a double-close on the manager's stream. addTearDown(provider.dispose); diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index 01cf7af1..a4fb4358 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/mixins/paginated_item_loader.dart'; +import 'package:plezy/mixins/standard_paginated_view.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import '../test_helpers/media_items.dart'; @@ -27,10 +28,20 @@ class _PaginatedProbe extends StatefulWidget { State<_PaginatedProbe> createState() => _PaginatedProbeState(); } -class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader { +class _PaginatedProbeState extends State<_PaginatedProbe> + with PaginatedItemLoader, StandardPaginatedView { int fetchCalls = 0; final List<({int start, int size})> fetchArgs = []; + /// View fields required by [StandardPaginatedView], mirroring the + /// `items`/`isLoading`/`errorMessage` trio the real screens expose. + @override + List items = []; + @override + bool isLoading = false; + @override + String? errorMessage; + @override Future> fetchPage(int start, int size, AbortController? abort) { fetchCalls++; @@ -109,10 +120,8 @@ void main() { expect(hooked, [(0, 5)]); }); - testWidgets('loadInitialPaginatedItems applies reset, data, and success callback', (tester) async { + testWidgets('loadStandardPaginatedItems resets view state, publishes items, and fires onLoaded', (tester) async { late _PaginatedProbeState state; - var reset = false; - List? applied; (int, int)? counts; await tester.pumpWidget( _PaginatedProbe( @@ -121,25 +130,26 @@ void main() { ), ); - final succeeded = await state.loadInitialPaginatedItems( + // Stale view state from a previous load must be cleared by the reset. + state.items = [_meta(99)]; + state.errorMessage = 'stale error'; + + await state.loadStandardPaginatedItems( pageSize: 3, - resetViewState: () => reset = true, - applyLoadedItems: (items) => applied = items, - applyError: (error, stackTrace) => fail('unexpected error: $error'), + errorMessageFor: (error, stackTrace) => fail('unexpected error: $error'), onLoaded: (loaded, total) => counts = (loaded, total), ); await tester.pump(); - expect(succeeded, isTrue); - expect(reset, isTrue); - expect(applied?.map((item) => item.id), ['k0', 'k1', 'k2']); + expect(state.items.map((item) => item.id), ['k0', 'k1', 'k2']); + expect(state.isLoading, isFalse); + expect(state.errorMessage, isNull); expect(counts, (3, 7)); }); - testWidgets('loadInitialPaginatedItems applies one error transaction', (tester) async { + testWidgets('loadStandardPaginatedItems applies one error transaction', (tester) async { late _PaginatedProbeState state; - Object? appliedError; - Object? loggedError; + Object? reportedError; await tester.pumpWidget( _PaginatedProbe( onState: (s) => state = s, @@ -147,18 +157,20 @@ void main() { ), ); - final succeeded = await state.loadInitialPaginatedItems( + await state.loadStandardPaginatedItems( pageSize: 3, - resetViewState: () {}, - applyLoadedItems: (_) => fail('items must not be applied'), - applyError: (error, stackTrace) => appliedError = error, - onError: (error, stackTrace) => loggedError = error, + errorMessageFor: (error, stackTrace) { + reportedError = error; + return 'could not load'; + }, + onLoaded: (_, _) => fail('items must not be applied'), ); await tester.pump(); - expect(succeeded, isFalse); - expect(appliedError, isA()); - expect(loggedError, same(appliedError)); + expect(reportedError, isA()); + expect(state.errorMessage, 'could not load'); + expect(state.isLoading, isFalse); + expect(state.items, isEmpty); }); testWidgets('totalSize == 0 means no more pages — ensureRangeLoaded is a no-op', (tester) async { diff --git a/test/models/livetv_flexible_parsing_test.dart b/test/models/livetv_flexible_parsing_test.dart index 39e21c6f..6412ab24 100644 --- a/test/models/livetv_flexible_parsing_test.dart +++ b/test/models/livetv_flexible_parsing_test.dart @@ -1,8 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/models/livetv_dvr.dart'; -import 'package:plezy/models/livetv_lineup.dart'; -import 'package:plezy/models/media_grabber_device.dart'; -import 'package:plezy/models/media_provider_info.dart'; import 'package:plezy/models/media_subscription.dart'; void main() { @@ -28,48 +25,6 @@ void main() { {'uuid': 'device-1'}, ]); - final grabber = MediaGrabberDevice.fromJson({ - 'key': 'device-1', - 'uuid': 'device-1', - 'ChannelMapping': [ - {'channelKey': 7}, - {'channelKey': 'channel-1'}, - ], - 'Setting': [ - {'id': 7}, - {'id': 'setting-1'}, - ], - }); - expect(grabber.channelMappings.map((entry) => entry.channelKey), ['channel-1']); - expect(grabber.settings.map((entry) => entry.id), ['setting-1']); - - final lineup = LiveTvLineup.fromJson({ - 'uuid': 'lineup-1', - 'Channel': [ - {'callSign': 7}, - {'key': 'channel-1', 'callSign': 'ONE'}, - ], - }); - expect(lineup.channels.map((entry) => entry.callSign), ['ONE']); - - final provider = MediaProviderInfo.fromJson({ - 'identifier': 'provider-1', - 'Feature': [ - {'type': 7}, - { - 'type': 'livetv', - 'Directory': [ - 'invalid', - {'key': 'guide'}, - ], - }, - ], - }); - expect(provider.features.map((entry) => entry.type), ['livetv']); - expect(provider.features.single.directories, [ - {'key': 'guide'}, - ]); - final template = SubscriptionTemplate.fromJson({ 'MediaSubscription': [ {'title': 7}, diff --git a/test/models/user_switch_response_test.dart b/test/models/plex_switch_response_test.dart similarity index 53% rename from test/models/user_switch_response_test.dart rename to test/models/plex_switch_response_test.dart index 1d0aa50b..122c1de8 100644 --- a/test/models/user_switch_response_test.dart +++ b/test/models/plex_switch_response_test.dart @@ -1,5 +1,5 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/models/user_switch_response.dart'; +import 'package:plezy/models/plex/plex_switch_response.dart'; /// A realistic `/api/v2/home/users/{uuid}/switch` 201 body using the /// July 2026 wire shape where profile language lists are CSV strings (#1488). @@ -49,34 +49,17 @@ Map driftedSwitchJson() => { }; void main() { - group('UserSwitchResponse.fromJson', () { - test('parses a realistic drifted 201 body, preserving the token', () { - final response = UserSwitchResponse.fromJson(driftedSwitchJson()); - - expect(response.authToken, 'minted-user-token'); - expect(response.uuid, 'e443d57860076fc3'); - expect(response.protected, isTrue); - expect(response.homeAdmin, isTrue); - expect(response.profile.defaultAudioLanguages, ['en', 'sv']); - expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + group('parsePlexSwitchAuthToken', () { + test('takes the token out of a realistic drifted 201 body', () { + expect(parsePlexSwitchAuthToken(driftedSwitchJson()), 'minted-user-token'); }); - test('parses a token-only body with defaults everywhere else', () { - final response = UserSwitchResponse.fromJson({'authToken': 'tok'}); - - expect(response.authToken, 'tok'); - expect(response.id, 0); - expect(response.uuid, ''); - expect(response.title, ''); - expect(response.confirmed, isFalse); - expect(response.homeSize, 1); - expect(response.maxHomeSize, 1); - expect(response.profile.autoSelectAudio, isTrue); - expect(response.profile.defaultAudioLanguages, isNull); + test('takes the token out of a token-only body', () { + expect(parsePlexSwitchAuthToken({'authToken': 'tok'}), 'tok'); }); test('never loses the token to wrong-typed decorative fields', () { - final response = UserSwitchResponse.fromJson({ + final token = parsePlexSwitchAuthToken({ 'authToken': 'tok', 'id': {}, 'uuid': 42, @@ -92,20 +75,13 @@ void main() { 'twoFactorEnabled': {}, }); - expect(response.authToken, 'tok'); - expect(response.id, 0); - expect(response.uuid, '42'); - expect(response.title, '7'); - expect(response.confirmed, isFalse); - expect(response.homeSize, 1); - expect(response.profile.autoSelectAudio, isTrue); - expect(response.profile.defaultAudioLanguages, isNull); + expect(token, 'tok'); }); test('throws when authToken is missing, empty, or not a string', () { - expect(() => UserSwitchResponse.fromJson(const {}), throwsFormatException); - expect(() => UserSwitchResponse.fromJson({'authToken': ''}), throwsFormatException); - expect(() => UserSwitchResponse.fromJson({'authToken': 12345}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken(const {}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken({'authToken': ''}), throwsFormatException); + expect(() => parsePlexSwitchAuthToken({'authToken': 12345}), throwsFormatException); }); }); } diff --git a/test/models/plex_user_profile_test.dart b/test/models/plex_user_profile_test.dart index be75576c..5c5a2c26 100644 --- a/test/models/plex_user_profile_test.dart +++ b/test/models/plex_user_profile_test.dart @@ -70,23 +70,5 @@ void main() { expect(profile.watchedIndicator, 2); expect(profile.defaultSubtitleForced, 1); }); - - test('defaults() matches parsing an empty map', () { - final parsed = PlexUserProfile.fromJson(const {}); - final defaults = PlexUserProfile.defaults(); - - expect(defaults.autoSelectAudio, parsed.autoSelectAudio); - expect(defaults.defaultAudioAccessibility, parsed.defaultAudioAccessibility); - expect(defaults.defaultAudioLanguage, parsed.defaultAudioLanguage); - expect(defaults.defaultAudioLanguages, parsed.defaultAudioLanguages); - expect(defaults.defaultSubtitleLanguage, parsed.defaultSubtitleLanguage); - expect(defaults.defaultSubtitleLanguages, parsed.defaultSubtitleLanguages); - expect(defaults.autoSelectSubtitle, parsed.autoSelectSubtitle); - expect(defaults.defaultSubtitleAccessibility, parsed.defaultSubtitleAccessibility); - expect(defaults.defaultSubtitleForced, parsed.defaultSubtitleForced); - expect(defaults.watchedIndicator, parsed.watchedIndicator); - expect(defaults.mediaReviewsVisibility, parsed.mediaReviewsVisibility); - expect(defaults.mediaReviewsLanguages, parsed.mediaReviewsLanguages); - }); }); } diff --git a/test/navigation/profile_session_screen_test.dart b/test/navigation/profile_session_screen_test.dart index 188f60c0..7e771db7 100644 --- a/test/navigation/profile_session_screen_test.dart +++ b/test/navigation/profile_session_screen_test.dart @@ -16,7 +16,6 @@ import 'package:plezy/providers/discover_provider.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/trackers_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/services/storage_service.dart'; @@ -24,6 +23,7 @@ import 'package:plezy/services/system_shelf_service.dart'; import 'package:provider/provider.dart'; import '../test_helpers/io_fakes.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -52,7 +52,7 @@ void main() { storage: storage, ); final serverManager = MultiServerManager(); - final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final multiServer = testMultiServerProvider(serverManager); // The session tree instantiates MusicPlaybackServiceImpl (the mini-player // overlay watches it), which needs the database + offline watch service. final offlineWatch = OfflineWatchSyncService(database: db, serverManager: serverManager); diff --git a/test/profiles/active_profile_binder_test.dart b/test/profiles/active_profile_binder_test.dart index 89261df7..aec5ebc6 100644 --- a/test/profiles/active_profile_binder_test.dart +++ b/test/profiles/active_profile_binder_test.dart @@ -18,13 +18,13 @@ import 'package:plezy/profiles/profile_connection.dart'; import 'package:plezy/profiles/profile_connection_registry.dart'; import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/media_server_timeouts.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; /// Poll [condition] until it holds, failing after [timeout]. Used to observe @@ -74,7 +74,7 @@ void main() { storage: storage, ); manager = MultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); shouldDeferInitialBind = false; binder = ActiveProfileBinder( activeProfile: activeProfile, @@ -193,7 +193,7 @@ void main() { final failingManager = _FailingPlexMultiServerManager(); manager = failingManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -247,7 +247,7 @@ void main() { final mixedManager = _BlockingMixedMultiServerManager(); manager = mixedManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -349,7 +349,7 @@ void main() { final capturingManager = _CapturingMultiServerManager(); manager = capturingManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -591,7 +591,7 @@ void main() { final recoveringManager = _RecordingPlexManager(); manager = recoveringManager; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -644,7 +644,7 @@ void main() { multiServerProvider.dispose(); manager = testManager ?? _CapturingMultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -809,7 +809,7 @@ void main() { final gated = _GatedJellyfinManager(); manager = gated; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -849,7 +849,7 @@ void main() { final gated = _GatedJellyfinManager(); manager = gated; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -890,7 +890,7 @@ void main() { final failing = _CountingFailingJellyfinManager(); manager = failing; - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, @@ -929,7 +929,7 @@ void main() { var pinPrompts = 0; manager = _CapturingMultiServerManager(); - multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + multiServerProvider = testMultiServerProvider(manager); binder = ActiveProfileBinder( activeProfile: activeProfile, connections: connections, diff --git a/test/profiles/profile_connection_cleanup_test.dart b/test/profiles/profile_connection_cleanup_test.dart index 56559f1c..5164747e 100644 --- a/test/profiles/profile_connection_cleanup_test.dart +++ b/test/profiles/profile_connection_cleanup_test.dart @@ -83,6 +83,7 @@ void main() { late ConnectionRegistry connections; late ProfileConnectionRegistry profileConnections; late StorageService storage; + late ProfileConnectionCleanup cleanup; setUp(() async { resetSharedPreferencesForTest(); @@ -90,6 +91,11 @@ void main() { connections = ConnectionRegistry(db); profileConnections = ProfileConnectionRegistry(db); storage = await StorageService.getInstance(); + cleanup = ProfileConnectionCleanup( + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); }); tearDown(() async { @@ -112,13 +118,7 @@ void main() { await storage.saveHiddenLibraries({'jf-machine:movies'}); await storage.saveLibraryOrder(['jf-machine:movies']); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await profileConnections.listForConnection(conn.id), isEmpty); expect(await connections.get(conn.id), isNull); @@ -151,13 +151,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'jf-machine:movies'}); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await connections.get(conn.id), isNotNull); final remaining = await profileConnections.listForConnection(conn.id); @@ -177,11 +171,7 @@ void main() { await storage.saveHiddenLibraries({'jf-machine:movies'}); await storage.saveLibrarySort('jf-machine:movies', 'titleSort'); - final removed = await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removed = await cleanup.pruneUnreferencedJellyfinConnections(); expect(removed, 1); expect(await connections.get(conn.id), isNull); @@ -205,11 +195,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'jf-machine:movies'}); - final removed = await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removed = await cleanup.pruneUnreferencedJellyfinConnections(); expect(removed, 1); expect(await connections.get(orphan.id), isNull); @@ -241,13 +227,7 @@ void main() { expect(await connections.get(acct.id), isNotNull); expect(await profileConnections.listAll(), hasLength(2)); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - plannedRemoval: plannedRemoval, - ); + final removal = await cleanup.removePlexAccountConnection(acct, plannedRemoval: plannedRemoval); expect(removal.removedVirtualProfileIds, {vProfile}); expect(removal.borrowerProfileIds, isEmpty); @@ -270,12 +250,7 @@ void main() { await profileConnections.upsert(_row(vProfile, jf)); await profileConnections.upsert(_row('local-1', jf)); - await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removePlexAccountConnection(acct); expect(await connections.get(jf.id), isNotNull); final remaining = await profileConnections.listAll(); @@ -293,12 +268,7 @@ void main() { await profileConnections.upsert(_row('local-1', acct)); await profileConnections.upsert(_row('local-1', jf)); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removal = await cleanup.removePlexAccountConnection(acct); expect(removal.removedVirtualProfileIds, isEmpty); expect(removal.borrowerProfileIds, {'local-1'}); @@ -323,12 +293,7 @@ void main() { await profileConnections.upsert(_row(v2, acct2, userIdentifier: uuid2)); await storage.savePlexHomeUsersCache(acct2.id, [_homeUser(uuid2).toJson()]); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct1, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removal = await cleanup.removePlexAccountConnection(acct1); expect(removal.removedVirtualProfileIds, {v1}); expect(await connections.get(acct2.id), isNotNull); @@ -348,12 +313,7 @@ void main() { await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid)); await profileConnections.upsert(_row(vProfile, jf)); - Future run() => removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + Future run() => cleanup.removePlexAccountConnection(acct); await run(); final second = await run(); @@ -375,13 +335,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'plex-machine:movies'}); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await connections.get(conn.id), isNotNull); expect(await profileConnections.listForConnection(conn.id), isEmpty); @@ -402,13 +356,7 @@ void main() { Future<({PostRemovalRoute route, List profiles})> resolve({ Map> plexHomeUsers = const {}, }) { - return resolvePostRemovalState( - profileRegistry: profileRegistry, - profileConnections: profileConnections, - connections: connections, - plexHomeUsers: plexHomeUsers, - storage: storage, - ); + return cleanup.resolvePostRemovalState(profileRegistry: profileRegistry, plexHomeUsers: plexHomeUsers); } Profile local(String id) => diff --git a/test/providers/companion_remote_provider_test.dart b/test/providers/companion_remote_provider_test.dart index 44db7c3a..72b63cf8 100644 --- a/test/providers/companion_remote_provider_test.dart +++ b/test/providers/companion_remote_provider_test.dart @@ -1,29 +1,22 @@ import 'dart:async'; -import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/models/plex/plex_home.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/companion_remote/remote_command.dart'; import 'package:plezy/models/companion_remote/remote_session.dart'; -import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/companion_remote_provider.dart'; import 'package:plezy/services/companion_remote/companion_remote_peer_service.dart'; import 'package:plezy/services/companion_remote/lan_discovery_service.dart'; import 'package:plezy/services/companion_remote/remote_auth_context.dart'; import 'package:plezy/services/companion_remote/remote_auth_service.dart'; -import 'package:plezy/services/storage_service.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -473,69 +466,48 @@ void main() { }); test('ensureCryptoReady rebuilds when the active profile/account changes', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profileA = _localProfile('profile-a'); final profileB = _localProfile('profile-b'); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profiles.upsert(profileA); - await profiles.upsert(profileB); - await profileConnections.upsert( + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profiles.upsert(profileA); + await stack.profiles.upsert(profileB); + await stack.profileConnections.upsert( ProfileConnection(profileId: profileA.id, connectionId: accountA.id, userIdentifier: 'admin-a'), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profileB.id, connectionId: accountB.id, userIdentifier: 'admin-b'), makeDefault: true, ); - await storage.setActiveProfileId(profileA.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profileA.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final okA = await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: accountA, ); expect(okA, isTrue); expect(provider.debugCryptoConnectionId, accountA.id); expect(provider.debugCryptoProfileId, profileA.id); - await active.activate(profileB); + await stack.active.activate(profileB); final ok = await provider.ensureCryptoReady( _home('admin-b'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: accountB, ); @@ -545,50 +517,29 @@ void main() { }); test('ensureCryptoReady uses the active local profile Plex row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final accountA = _plexAccount('plex-a', 'client-a'); final accountB = _plexAccount('plex-b', 'client-b'); final profile = _localProfile('profile-local'); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: accountB.id, userIdentifier: 'child-b', isDefault: true), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _homeWithUsers('admin-b', ['child-b']), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isTrue); @@ -598,48 +549,27 @@ void main() { }); test('ensureCryptoReady uses the active local profile Jellyfin row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-jf'); - await connections.upsert(jellyfin); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(jellyfin); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( null, - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isTrue); @@ -649,54 +579,33 @@ void main() { }); test('ensureCryptoReady includes every active local profile remote identity', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final account = _plexAccount('plex-a', 'client-a'); final jellyfin = _jellyfinConnection('jf-a'); final profile = _localProfile('profile-mixed'); final home = _homeWithUsers('admin-a', ['child-a']); - await connections.upsert(account); - await connections.upsert(jellyfin); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.connections.upsert(jellyfin); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'child-a'), ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( home, - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, plexHomeForConnection: (_) async => home, ); @@ -706,41 +615,20 @@ void main() { }); test('ensureCryptoReady does not fall back to an account without an active profile', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); - await connections.upsert(_plexAccount('plex-a', 'client-a')); - await profiles.upsert(_localProfile('profile-a')); - await active.initialize(); + await stack.connections.upsert(_plexAccount('plex-a', 'client-a')); + await stack.profiles.upsert(_localProfile('profile-a')); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); final ok = await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, ); expect(ok, isFalse); @@ -748,48 +636,27 @@ void main() { }); test('resetForLogout clears crypto context', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); - addTearDown(() async { - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); - }); + final stack = await ProfileStack.create(); + addTearDown(stack.dispose); final account = _plexAccount('plex-a', 'client-a'); final profile = _localProfile('profile-a'); - await connections.upsert(account); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'admin-a'), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider(); addTearDown(provider.dispose); await provider.ensureCryptoReady( _home('admin-a'), - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: account, ); expect(provider.isCryptoReady, isTrue); @@ -1061,46 +928,28 @@ class _FakeLanDiscoveryService extends LanDiscoveryService { } class _RemoteHarness { - _RemoteHarness({required this.provider, required this.database, required this.activeProfile, required this.plexHome}); + _RemoteHarness({required this.provider, required this.stack}); final CompanionRemoteProvider provider; - final AppDatabase database; - final ActiveProfileProvider activeProfile; - final PlexHomeService plexHome; + final ProfileStack stack; bool _closed = false; static Future<_RemoteHarness> create( CompanionRemotePeerServiceFactory peerServiceFactory, { LanDiscoveryServiceFactory discoveryServiceFactory = LanDiscoveryService.new, }) async { - final database = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(database); - final profileConnections = ProfileConnectionRegistry(database); - final profiles = ProfileRegistry(database); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfile = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final account = _plexAccount('remote-account', 'remote-client'); final profile = _localProfile('remote-profile'); - await connections.upsert(account); - await profiles.upsert(profile); - await profileConnections.upsert( + await stack.connections.upsert(account); + await stack.profiles.upsert(profile); + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: account.id, userIdentifier: 'remote-admin'), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await activeProfile.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final provider = CompanionRemoteProvider.forTesting( peerServiceFactory: peerServiceFactory, @@ -1108,25 +957,22 @@ class _RemoteHarness { ); final ready = await provider.ensureCryptoReady( _home('remote-admin'), - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, account: account, ); if (!ready) { throw StateError('Remote test harness failed to initialize crypto'); } - return _RemoteHarness(provider: provider, database: database, activeProfile: activeProfile, plexHome: plexHome); + return _RemoteHarness(provider: provider, stack: stack); } Future close() async { if (_closed) return; _closed = true; if (!provider.isDisposed) provider.dispose(); - await activeProfile.resetForTesting(); - activeProfile.dispose(); - await plexHome.dispose(); - await database.close(); + await stack.dispose(); } } diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 1ba021ec..20ba3bc6 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -25,6 +25,7 @@ import 'package:plezy/utils/deletion_notifier.dart'; import 'package:plezy/utils/notification_permission.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/active_client_scope.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/media_items.dart'; /// Implements only [fetchPlayableDescendants], the surface [collectEpisodes] diff --git a/test/providers/libraries_provider_test.dart b/test/providers/libraries_provider_test.dart index de4793d0..d430a7af 100644 --- a/test/providers/libraries_provider_test.dart +++ b/test/providers/libraries_provider_test.dart @@ -8,11 +8,11 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/providers/libraries_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; MediaLibrary _lib(String key, {String type = 'movie', ServerId? serverId, String title = 'L'}) => MediaLibrary( @@ -563,7 +563,7 @@ void main() { test('online-servers listener is removed on dispose', () { final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); final before = multiServer.onlineServersListenerCount; final scoped = LibrariesProvider(multiServer: multiServer); diff --git a/test/providers/offline_mode_provider_test.dart b/test/providers/offline_mode_provider_test.dart index 46c7985e..12f80141 100644 --- a/test/providers/offline_mode_provider_test.dart +++ b/test/providers/offline_mode_provider_test.dart @@ -4,12 +4,11 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/providers/offline_mode_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -105,7 +104,7 @@ void main() { httpClient: MockClient((_) async => http.Response('', 401)), ); manager.debugRegisterJellyfinClientForTesting(client, online: false); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); @@ -122,7 +121,7 @@ void main() { test('expected but unreachable visible servers enter offline without live clients', () async { final manager = MultiServerManager(); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); manager.updateServerStatus(ServerId('plex-server'), false); @@ -146,7 +145,7 @@ void main() { test('expected but unreachable profile servers enter offline once visibility settles', () async { final manager = MultiServerManager(); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); expect(p.isOffline, isFalse); @@ -173,7 +172,7 @@ void main() { test('Plex auth errors without live clients stay out of generic offline', () async { final manager = MultiServerManager(); - final multi = MultiServerProvider(manager, DataAggregationService(manager)); + final multi = testMultiServerProvider(manager); final p = OfflineModeProvider(manager, multiServerProvider: multi); await p.initialize(); diff --git a/test/providers/user_profile_provider_test.dart b/test/providers/user_profile_provider_test.dart index a3da4a3c..6bea338a 100644 --- a/test/providers/user_profile_provider_test.dart +++ b/test/providers/user_profile_provider_test.dart @@ -1,26 +1,19 @@ import 'dart:convert'; -import 'package:drift/native.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/models/plex/plex_home_user.dart'; -import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/profile_connection.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/user_profile_provider.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_auth_service.dart'; -import 'package:plezy/services/storage_service.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { setUp(resetSharedPreferencesForTest); @@ -57,30 +50,11 @@ void main() { }); test('settings connection follows the profile default row', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); @@ -102,10 +76,10 @@ void main() { deviceId: 'device-a', createdAt: DateTime(2026, 1, 1), ); - await profiles.upsert(profile); - await connections.upsert(plex); - await connections.upsert(jellyfin); - await profileConnections.upsert( + await stack.profiles.upsert(profile); + await stack.connections.upsert(plex); + await stack.connections.upsert(jellyfin); + await stack.profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: plex.id, @@ -115,53 +89,36 @@ void main() { ), makeDefault: true, ); - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection(profileId: profile.id, connectionId: jellyfin.id, userIdentifier: jellyfin.userId), ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final p = UserProfileProvider() ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); - await profileConnections.setDefault(profile.id, jellyfin.id); + await stack.profileConnections.setDefault(profile.id, jellyfin.id); expect(await p.debugResolveActiveSettingsConnectionForTesting(), isA()); }); test('watches Plex Home profile connection rows', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, + final stack = await ProfileStack.create( + homeUsers: [_homeUser(uuid: 'home-user-a', title: 'Home User')], ); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final account = PlexAccountConnection( @@ -171,21 +128,23 @@ void main() { accountLabel: 'Plex A', createdAt: DateTime(2026, 1, 1), ); - await connections.upsert(account); - await plexHome.refresh(account); - await storage.setActiveProfileId(plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a')); - await active.initialize(); + await stack.connections.upsert(account); + await stack.plexHome.refresh(account); + await stack.storage.setActiveProfileId( + plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a'), + ); + await stack.active.initialize(); final p = UserProfileProvider() ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); - expect(p.debugWatchedProfileConnectionProfileId, active.activeId); + expect(p.debugWatchedProfileConnectionProfileId, stack.active.activeId); }); test('Plex Home profile without a switched token makes no user request', () async { @@ -231,30 +190,11 @@ void main() { }); test('Plex token fallback uses the selected local profile account', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => const [], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, - ); + final stack = await ProfileStack.create(); final manager = MultiServerManager(); addTearDown(() async { manager.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); }); final profile = Profile.local(id: 'local-owner', displayName: 'Owner', createdAt: DateTime(2026, 1, 1)); @@ -272,10 +212,10 @@ void main() { accountLabel: 'Plex B', createdAt: DateTime(2026, 1, 1), ); - await profiles.upsert(profile); - await connections.upsert(accountA); - await connections.upsert(accountB); - await profileConnections.upsert( + await stack.profiles.upsert(profile); + await stack.connections.upsert(accountA); + await stack.connections.upsert(accountB); + await stack.profileConnections.upsert( ProfileConnection( profileId: profile.id, connectionId: accountB.id, @@ -284,8 +224,8 @@ void main() { ), makeDefault: true, ); - await storage.setActiveProfileId(profile.id); - await active.initialize(); + await stack.storage.setActiveProfileId(profile.id); + await stack.active.initialize(); final requests = []; final auth = _recordingAuth(requests, audioLanguage: 'fra'); @@ -293,9 +233,9 @@ void main() { final p = UserProfileProvider(authService: auth) ..attach( - connections: connections, - activeProfile: active, - profileConnections: profileConnections, + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, serverManager: manager, ); addTearDown(p.dispose); @@ -356,39 +296,16 @@ PlexAuthService _recordingAuth(List requests, {required String aud } class _HomeProfileFixture { - _HomeProfileFixture({ - required this.db, - required this.active, - required this.plexHome, - required this.auth, - required this.provider, - required this.requests, - }); + _HomeProfileFixture({required this.stack, required this.auth, required this.provider, required this.requests}); - final AppDatabase db; - final ActiveProfileProvider active; - final PlexHomeService plexHome; + final ProfileStack stack; final PlexAuthService auth; final UserProfileProvider provider; final List requests; static Future<_HomeProfileFixture> create({String? switchedToken}) async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final profiles = ProfileRegistry(db); - final storage = await StorageService.getInstance(); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - storage: storage, - plexHomeUserFetcher: (_) async => [_homeUser(uuid: 'home-user-a', title: 'Home User')], - ); - final active = ActiveProfileProvider( - registry: profiles, - plexHome: plexHome, - connections: connections, - storage: storage, + final stack = await ProfileStack.create( + homeUsers: [_homeUser(uuid: 'home-user-a', title: 'Home User')], ); final account = PlexAccountConnection( id: 'plex-parent', @@ -397,12 +314,12 @@ class _HomeProfileFixture { accountLabel: 'Plex Parent', createdAt: DateTime(2026, 1, 1), ); - await connections.upsert(account); - await plexHome.refresh(account); + await stack.connections.upsert(account); + await stack.plexHome.refresh(account); final activeId = plexHomeProfileId(accountConnectionId: account.id, homeUserUuid: 'home-user-a'); if (switchedToken != null) { - await profileConnections.upsert( + await stack.profileConnections.upsert( ProfileConnection( profileId: activeId, connectionId: account.id, @@ -413,29 +330,23 @@ class _HomeProfileFixture { makeDefault: true, ); } - await storage.setActiveProfileId(activeId); - await active.initialize(); + await stack.storage.setActiveProfileId(activeId); + await stack.active.initialize(); final requests = []; final auth = _recordingAuth(requests, audioLanguage: 'jpn'); final provider = UserProfileProvider(authService: auth) - ..attach(connections: connections, activeProfile: active, profileConnections: profileConnections); - return _HomeProfileFixture( - db: db, - active: active, - plexHome: plexHome, - auth: auth, - provider: provider, - requests: requests, - ); + ..attach( + connections: stack.connections, + activeProfile: stack.active, + profileConnections: stack.profileConnections, + ); + return _HomeProfileFixture(stack: stack, auth: auth, provider: provider, requests: requests); } Future dispose() async { provider.dispose(); auth.dispose(); - await active.resetForTesting(); - active.dispose(); - await plexHome.dispose(); - await db.close(); + await stack.dispose(); } } diff --git a/test/providers/watch_state_store_test.dart b/test/providers/watch_state_store_test.dart index 960b1c91..612a3f5a 100644 --- a/test/providers/watch_state_store_test.dart +++ b/test/providers/watch_state_store_test.dart @@ -4,6 +4,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/watch_state_store.dart'; +import 'package:plezy/services/watch_state_resolver.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/media_items.dart'; @@ -190,13 +191,13 @@ void main() { store.setHydratedPatches(const [ HydratedWatchStatePatch( globalKey: 'jf-machine:show-1', - patch: WatchStatePatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), + patch: WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), updatedAt: 100, order: 1, ), HydratedWatchStatePatch( globalKey: 'jf-machine:episode-1', - patch: WatchStatePatch(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), + patch: WatchStateSnapshot(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), updatedAt: 200, order: 2, ), @@ -213,13 +214,13 @@ void main() { store.setHydratedPatches(const [ HydratedWatchStatePatch( globalKey: 'jf-machine/user-a:show-1', - patch: WatchStatePatch(isWatched: true), + patch: WatchStateSnapshot(isWatched: true), updatedAt: 100, order: 1, ), HydratedWatchStatePatch( globalKey: 'jf-machine/user-b:show-1', - patch: WatchStatePatch(isWatched: false), + patch: WatchStateSnapshot(isWatched: false), updatedAt: 100, order: 2, ), diff --git a/test/screens/catalog_item_detail_screen_test.dart b/test/screens/catalog_item_detail_screen_test.dart index 4e43dad2..04678b1e 100644 --- a/test/screens/catalog_item_detail_screen_test.dart +++ b/test/screens/catalog_item_detail_screen_test.dart @@ -10,11 +10,9 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/models/catalog/catalog_cast_member.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/providers/catalog_sources_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/catalog_item_detail_screen.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/catalog_library_matcher.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -24,6 +22,7 @@ import 'package:plezy/widgets/media_card.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; class _FakeCatalogSource implements CatalogSource { @@ -127,7 +126,7 @@ Future _pumpDetail( }) async { final sources = _FakeCatalogSourcesProvider(source); final serverManager = MultiServerManager(); - final multiServer = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final multiServer = testMultiServerProvider(serverManager); final matcher = _FakeCatalogLibraryMatcher(multiServer, matches); addTearDown(sources.dispose); addTearDown(source.dispose); diff --git a/test/screens/catalog_search_screen_test.dart b/test/screens/catalog_search_screen_test.dart index 043390e3..cad6b380 100644 --- a/test/screens/catalog_search_screen_test.dart +++ b/test/screens/catalog_search_screen_test.dart @@ -6,12 +6,10 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; import 'package:plezy/models/catalog/catalog_cast_member.dart'; import 'package:plezy/providers/catalog_sources_provider.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/catalog_item_detail_screen.dart'; import 'package:plezy/screens/catalog_search_screen.dart'; import 'package:plezy/services/catalog/catalog_source.dart'; import 'package:plezy/services/catalog/catalog_library_matcher.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -19,6 +17,7 @@ import 'package:plezy/widgets/app_menu.dart'; import 'package:plezy/widgets/media_card.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; /// Only the members the search screen touches; everything else throws. @@ -197,7 +196,7 @@ Future _pumpMenuSearch(WidgetTester tester, _FakeSearchSource source, {req final sources = _FakeCatalogSourcesProvider(source); final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); final matcher = _FakeCatalogLibraryMatcher(multiServer); addTearDown(manager.dispose); addTearDown(multiServer.dispose); diff --git a/test/screens/collection_detail_screen_test.dart b/test/screens/collection_detail_screen_test.dart index 4da96f39..79e2ff0a 100644 --- a/test/screens/collection_detail_screen_test.dart +++ b/test/screens/collection_detail_screen_test.dart @@ -13,7 +13,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/collection_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -29,6 +28,7 @@ import 'package:plezy/widgets/media_card_sliver_layout.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; @@ -133,7 +133,7 @@ Future<_CollectionHarness> _createHarness(List items) async { final client = _CollectionClient(items); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(() async { downloadProvider.dispose(); diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 5685ace4..7562e9c4 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -46,6 +46,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -82,7 +83,7 @@ void main() { final hub = MediaHub(id: 'hub_1', title: 'Recommended', type: 'movie', items: [item], size: 1); final client = _FakeMediaServerClient(hubs: [hub]); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final hiddenLibrariesProvider = HiddenLibrariesProvider(); final librariesProvider = LibrariesProvider(); final watchTogetherProvider = WatchTogetherProvider(); @@ -384,7 +385,7 @@ void main() { ]; final client = _FakeMediaServerClient(hubs: const [], continueWatching: onDeck); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final hiddenLibrariesProvider = HiddenLibrariesProvider(); final librariesProvider = LibrariesProvider(); final watchTogetherProvider = WatchTogetherProvider(); diff --git a/test/screens/downloads/downloads_screen_focus_test.dart b/test/screens/downloads/downloads_screen_focus_test.dart index c9912611..4d9189be 100644 --- a/test/screens/downloads/downloads_screen_focus_test.dart +++ b/test/screens/downloads/downloads_screen_focus_test.dart @@ -18,7 +18,6 @@ import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/downloads/downloads_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -33,6 +32,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; import '../../test_helpers/io_fakes.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; class _FakeConnectionRegistry extends ConnectionRegistry { _FakeConnectionRegistry(super.db); @@ -77,7 +77,7 @@ void main() { await downloadProvider.ensureInitialized(); serverManager = MultiServerManager(); - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); }); tearDown(() async { diff --git a/test/screens/downloads/sync_rules_screen_test.dart b/test/screens/downloads/sync_rules_screen_test.dart index 0bad48ed..6c187457 100644 --- a/test/screens/downloads/sync_rules_screen_test.dart +++ b/test/screens/downloads/sync_rules_screen_test.dart @@ -28,6 +28,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; PlexConnection _plexConnection() { return PlexConnection( @@ -234,7 +235,7 @@ void main() { addTearDown(authClient.close); serverManager.debugRegisterJellyfinClientForTesting(authClient, online: false); serverManager.debugMarkAuthErrorForTesting(ServerId('auth-jf')); - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('plex-srv'), 'show-1'); await insertRule(ServerId('jf-machine'), 'show-2'); @@ -254,7 +255,7 @@ void main() { }); testWidgets('removes orphaned sync rules from the sync rules screen', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester); @@ -329,7 +330,7 @@ void main() { }); testWidgets('provider rebuilds reuse the connection stream subscription', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester); @@ -341,7 +342,7 @@ void main() { }); testWidgets('does not autofocus the first sync rule in pointer mode', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); FocusManager.instance.primaryFocus?.unfocus(); @@ -352,7 +353,7 @@ void main() { }); testWidgets('keyboard navigation reaches and toggles the sync rule switch', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester, keyboardMode: true); @@ -370,7 +371,7 @@ void main() { }); testWidgets('setting sync rule count to zero removes the rule', (tester) async { - multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + multiServerProvider = testMultiServerProvider(serverManager); await insertRule(ServerId('orphan-srv'), '76672'); await pumpScreen(tester, keyboardMode: true); diff --git a/test/screens/hub_detail_screen_test.dart b/test/screens/hub_detail_screen_test.dart index a78966ce..a78b18ab 100644 --- a/test/screens/hub_detail_screen_test.dart +++ b/test/screens/hub_detail_screen_test.dart @@ -11,7 +11,6 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/hub_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -21,6 +20,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -144,7 +144,7 @@ Future<_HubHarness> _createHarness(List items, {required MediaBackend await SettingsService.getInstance(); final client = _PagedHubClient(items, backend: backend); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); return _HubHarness(client: client, provider: provider); } diff --git a/test/screens/libraries/folder_tree_view_test.dart b/test/screens/libraries/folder_tree_view_test.dart index 491bbae7..a2a09374 100644 --- a/test/screens/libraries/folder_tree_view_test.dart +++ b/test/screens/libraries/folder_tree_view_test.dart @@ -8,14 +8,13 @@ import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/folder_tree_view.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; void main() { @@ -62,12 +61,7 @@ void main() { ); }), ); - final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); + final provider = testMultiServer(clients: [client]).provider; await tester.pumpWidget( ChangeNotifierProvider.value( diff --git a/test/screens/libraries/libraries_screen_test.dart b/test/screens/libraries/libraries_screen_test.dart index 2805d434..8c1da0ac 100644 --- a/test/screens/libraries/libraries_screen_test.dart +++ b/test/screens/libraries/libraries_screen_test.dart @@ -11,7 +11,6 @@ import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/libraries_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; @@ -22,6 +21,7 @@ import 'package:shared_preferences_platform_interface/in_memory_shared_preferenc import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; import 'package:shared_preferences_platform_interface/types.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; const _libraryA = MediaLibrary( @@ -135,7 +135,7 @@ final class _Harness { final hiddenLibraries = HiddenLibrariesProvider(); await hiddenLibraries.ensureInitialized(); final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); return _Harness(libraries: libraries, hiddenLibraries: hiddenLibraries, multiServer: multiServer); } diff --git a/test/screens/libraries/library_browse_music_test.dart b/test/screens/libraries/library_browse_music_test.dart index 9a4f61b6..63a15d71 100644 --- a/test/screens/libraries/library_browse_music_test.dart +++ b/test/screens/libraries/library_browse_music_test.dart @@ -6,27 +6,24 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/focus/focusable_button.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/focusable_filter_chip.dart'; import 'package:plezy/widgets/media_card.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _musicLibrary = MediaLibrary( @@ -88,7 +85,7 @@ void main() { expect(retry.focusNode!.hasFocus, isTrue); await tester.sendKeyEvent(LogicalKeyboardKey.enter); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(harness.browseRequestCount, 2); expect(find.byType(ErrorStateWidget), findsNothing); @@ -115,52 +112,17 @@ void main() { } Future _pumpBrowseTab(WidgetTester tester, _MusicBrowseHarness harness, {MediaLibrary? library}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 720); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: LibraryBrowseTab( - library: library ?? _musicLibrary, - canGroupByFolders: true, - suppressAutoFocus: true, - onBack: () {}, - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: LibraryBrowseTab( + library: library ?? _musicLibrary, + canGroupByFolders: true, + suppressAutoFocus: true, + onBack: () {}, ), ); - await _pumpRequestFrames(tester); -} - -Future _pumpRequestFrames(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 500)); + await pumpRequestFrames(tester); } class _MusicBrowseHarness { @@ -203,7 +165,7 @@ class _MusicBrowseHarness { }), ); manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } void dispose() { diff --git a/test/screens/libraries/library_browse_tab_test.dart b/test/screens/libraries/library_browse_tab_test.dart index 47afdcc0..7aba44e8 100644 --- a/test/screens/libraries/library_browse_tab_test.dart +++ b/test/screens/libraries/library_browse_tab_test.dart @@ -4,7 +4,6 @@ import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/library_filter_result.dart'; import 'package:plezy/media/library_query.dart'; @@ -15,22 +14,20 @@ import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/media_sort.dart'; import 'package:plezy/media/server_capabilities.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/state_messages.dart'; import 'package:plezy/screens/libraries/tabs/library_browse_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_mappers.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/storage_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/focusable_filter_chip.dart'; -import 'package:provider/provider.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; void main() { @@ -53,12 +50,12 @@ void main() { expect(harness.clientA.pageRequestCount, 0); harness.selectedLibrary.value = harness.libraryB; - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library B'), findsOneWidget); expect(harness.loadedLibraries, [harness.libraryB.globalKey]); sortA.complete(const []); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library A'), findsNothing); expect(find.text('Library B'), findsOneWidget); @@ -80,7 +77,7 @@ void main() { expect(harness.loadedLibraries, isEmpty); harness.selectedLibrary.value = harness.libraryB; - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.text('Library A'), findsNothing); expect(find.text('Library B'), findsOneWidget); @@ -126,7 +123,7 @@ void main() { await _pumpUntil(tester, () => clientA.pageRequestCount == 2); emptyPage.complete(const LibraryPage(items: [], totalCount: 0)); - await _pumpRequestFrames(tester); + await pumpRequestFrames(tester); expect(find.byType(ErrorStateWidget), findsNothing); expect(find.byType(EmptyStateWidget), findsOneWidget); @@ -155,56 +152,21 @@ void main() { } Future _pumpHarness(WidgetTester tester, _BrowseHarness harness, {bool settle = true}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(1280, 720); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: ValueListenableBuilder( - valueListenable: harness.selectedLibrary, - builder: (context, library, _) => LibraryBrowseTab( - library: library, - canGroupByFolders: true, - isActive: true, - onDataLoaded: () => harness.loadedLibraries.add(library.globalKey), - onBack: () => harness.chromeFocusRequests++, - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: ValueListenableBuilder( + valueListenable: harness.selectedLibrary, + builder: (context, library, _) => LibraryBrowseTab( + library: library, + canGroupByFolders: true, + isActive: true, + onDataLoaded: () => harness.loadedLibraries.add(library.globalKey), + onBack: () => harness.chromeFocusRequests++, ), ), ); - if (settle) await _pumpRequestFrames(tester); -} - -Future _pumpRequestFrames(WidgetTester tester) async { - await tester.pump(); - await tester.pump(const Duration(milliseconds: 100)); - await tester.pump(const Duration(milliseconds: 500)); + if (settle) await pumpRequestFrames(tester); } Future _pumpUntil(WidgetTester tester, bool Function() condition) async { @@ -249,7 +211,7 @@ class _BrowseHarness { manager = MultiServerManager() ..debugRegisterClientForTesting(clientA) ..debugRegisterClientForTesting(clientB); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } MediaLibrary _libraryFor(_BrowseClient client) { diff --git a/test/screens/libraries/library_collections_tab_test.dart b/test/screens/libraries/library_collections_tab_test.dart index 09eaf6a9..72f37f46 100644 --- a/test/screens/libraries/library_collections_tab_test.dart +++ b/test/screens/libraries/library_collections_tab_test.dart @@ -6,7 +6,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -14,23 +13,21 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/models/plex/plex_config.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_collections_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('collection-server'); @@ -93,39 +90,11 @@ void main() { } Future _pumpTab(WidgetTester tester, {required _CollectionHarness harness, required MediaLibrary library}) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 600); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: () {}, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: () {}), - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: LibraryCollectionsTab(library: library, suppressAutoFocus: true, onBack: () {}), + size: const Size(800, 600), ); await tester.pumpAndSettle(); } @@ -137,7 +106,7 @@ class _CollectionHarness { _CollectionHarness._({required this.database, required MediaServerClient client}) { manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } factory _CollectionHarness.plex() { diff --git a/test/screens/libraries/library_playlists_tab_test.dart b/test/screens/libraries/library_playlists_tab_test.dart index 8ba1aa5d..2ecc6c5a 100644 --- a/test/screens/libraries/library_playlists_tab_test.dart +++ b/test/screens/libraries/library_playlists_tab_test.dart @@ -7,7 +7,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; -import 'package:plezy/focus/input_mode_tracker.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -15,22 +14,20 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/media/media_playlist.dart'; import 'package:plezy/models/plex/plex_config.dart'; -import 'package:plezy/navigation/main_screen_scope.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/libraries/tabs/library_playlists_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_client.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/card_inflation_budget.dart'; import 'package:plezy/widgets/focusable_media_card.dart'; import 'package:plezy/widgets/media_card_sliver_layout.dart'; -import 'package:provider/provider.dart'; import '../../test_helpers/backend_client_fixtures.dart'; +import '../../test_helpers/library_tab_scaffold.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; final _serverId = ServerId('playlist-server'); @@ -187,43 +184,15 @@ Future _pumpTab( required VoidCallback onBack, required VoidCallback onSidebar, }) async { - tester.view.devicePixelRatio = 1; - tester.view.physicalSize = const Size(800, 600); - addTearDown(() { - tester.view.resetDevicePixelRatio(); - tester.view.resetPhysicalSize(); - }); - - await tester.pumpWidget( - ChangeNotifierProvider.value( - value: harness.provider, - child: InputModeTracker( - child: MaterialApp( - theme: monoTheme(dark: true), - home: MainScreenFocusScope( - focusSidebar: onSidebar, - focusContent: () {}, - isSidebarFocused: false, - sideNavigationWidth: 0, - child: Scaffold( - body: NestedScrollView( - headerSliverBuilder: (context, _) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), - ), - ], - body: ValueListenableBuilder( - valueListenable: harness.rebuild, - builder: (context, _, _) => - LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack), - ), - ), - ), - ), - ), - ), + await pumpLibraryTab( + tester, + provider: harness.provider, + tab: ValueListenableBuilder( + valueListenable: harness.rebuild, + builder: (context, _, _) => LibraryPlaylistsTab(library: library, suppressAutoFocus: true, onBack: onBack), ), + size: const Size(800, 600), + focusSidebar: onSidebar, ); await tester.pumpAndSettle(); } @@ -277,7 +246,7 @@ class _PlaylistHarness { }), ); manager = MultiServerManager()..debugRegisterClientForTesting(client); - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } Future dispose() async { diff --git a/test/screens/livetv/guide_tab_test.dart b/test/screens/livetv/guide_tab_test.dart index eee2954b..c7be0e3e 100644 --- a/test/screens/livetv/guide_tab_test.dart +++ b/test/screens/livetv/guide_tab_test.dart @@ -19,13 +19,14 @@ import 'package:plezy/models/livetv_channel.dart'; import 'package:plezy/models/livetv_program.dart'; import 'package:plezy/screens/livetv/tabs/guide_tab.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; + const _selectDown = KeyDownEvent( physicalKey: PhysicalKeyboardKey.enter, logicalKey: LogicalKeyboardKey.enter, @@ -233,7 +234,7 @@ final class _GuideHarness { final serverB = includeServerB ? _FakeMediaServerClient(serverId: 'server-b', stationId: 'station-b') : null; final manager = MultiServerManager()..debugRegisterClientForTesting(serverA); if (serverB != null) manager.debugRegisterClientForTesting(serverB); - final provider = MultiServerProvider(manager, DataAggregationService(manager)) + final provider = testMultiServerProvider(manager) ..debugSetLiveTvServersForTesting([ LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a'), if (serverB != null) LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b'), diff --git a/test/screens/livetv/live_tv_screen_test.dart b/test/screens/livetv/live_tv_screen_test.dart index 9d649c39..2b7841a9 100644 --- a/test/screens/livetv/live_tv_screen_test.dart +++ b/test/screens/livetv/live_tv_screen_test.dart @@ -16,12 +16,12 @@ import 'package:plezy/models/livetv_program.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/livetv/live_tv_screen.dart'; import 'package:plezy/screens/livetv/tabs/guide_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; void main() { @@ -75,7 +75,7 @@ void main() { final manager = MultiServerManager() ..debugRegisterClientForTesting(failedClient) ..debugRegisterClientForTesting(healthyClient); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); provider.debugSetLiveTvServersForTesting([ LiveTvServerInfo(serverId: 'server-a', dvrKey: 'dvr-a', lineup: 'provider-a'), LiveTvServerInfo(serverId: 'server-b', dvrKey: 'dvr-b', lineup: 'provider-b'), @@ -148,7 +148,7 @@ Future<_LiveTvHarness> _pumpLiveTvScreen(WidgetTester tester) async { final liveTv = _FakeLiveTvSupport(); final client = _FakeMediaServerClient(liveTv); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); provider.debugSetLiveTvServersForTesting([ LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr-a', lineup: 'provider-a'), ]); diff --git a/test/screens/livetv/live_tv_show_schedule_screen_test.dart b/test/screens/livetv/live_tv_show_schedule_screen_test.dart index b75a8618..06151f58 100644 --- a/test/screens/livetv/live_tv_show_schedule_screen_test.dart +++ b/test/screens/livetv/live_tv_show_schedule_screen_test.dart @@ -14,12 +14,13 @@ import 'package:plezy/models/livetv_program.dart'; import 'package:plezy/models/media_subscription.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/livetv/live_tv_show_schedule_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); setUpAll(() => initializeDateFormatting('en')); @@ -113,7 +114,7 @@ LiveTvProgram _program({String? guid}) => LiveTvProgram( MultiServerProvider _providerFor(MediaServerClient client) { final manager = MultiServerManager()..debugRegisterClientForTesting(client); - return MultiServerProvider(manager, DataAggregationService(manager)); + return testMultiServerProvider(manager); } Future _pumpScreen(WidgetTester tester, MultiServerProvider provider) async { diff --git a/test/screens/livetv/recordings_tab_test.dart b/test/screens/livetv/recordings_tab_test.dart index ed38a978..bf27bd99 100644 --- a/test/screens/livetv/recordings_tab_test.dart +++ b/test/screens/livetv/recordings_tab_test.dart @@ -13,11 +13,12 @@ import 'package:plezy/models/media_grab_operation.dart'; import 'package:plezy/models/media_subscription.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/livetv/tabs/recordings_tab.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -27,7 +28,7 @@ void main() { final dvr = _ControllableDvr(); final client = _FakeMediaServerClient(dvr); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)) + final provider = testMultiServerProvider(manager) ..debugSetLiveTvServersForTesting([LiveTvServerInfo(serverId: client.serverId.value, dvrKey: 'dvr')]); addTearDown(provider.dispose); final tabKey = GlobalKey(); diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 03f95716..911793e8 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -22,7 +22,6 @@ import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/watch_state_store.dart'; import 'package:plezy/screens/media_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import '../test_helpers/paged_fakes.dart'; import 'package:plezy/services/download_manager_service.dart'; @@ -46,6 +45,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/profile_navigation.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -308,7 +308,7 @@ void main() { pendingPlayableDescendants: descendantsCompleter.future, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -434,7 +434,7 @@ void main() { }, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -531,7 +531,7 @@ void main() { childrenPageErrors: {season1.id: Exception('season cache failed')}, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -622,7 +622,7 @@ void main() { childrenPageFutures: {season2.id: season2Completer.future}, ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); await tester.pumpWidget( @@ -708,7 +708,7 @@ void main() { PlexApiCache.initialize(database); JellyfinApiCache.initialize(database); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final offlineWatch = OfflineWatchSyncService(database: database, serverManager: manager); final downloadManager = DownloadManagerService( database: database, @@ -832,7 +832,7 @@ void main() { await downloadProvider.ensureInitialized(); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final watchStateOverlay = WatchStateStore(); addTearDown(() async { diff --git a/test/screens/metadata_edit_screen_test.dart b/test/screens/metadata_edit_screen_test.dart index 074c4ca6..82742ae4 100644 --- a/test/screens/metadata_edit_screen_test.dart +++ b/test/screens/metadata_edit_screen_test.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:collection'; -import 'dart:convert'; import 'dart:typed_data'; import 'package:drift/native.dart'; @@ -20,7 +19,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/metadata_edit/metadata_edit_models.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/metadata_edit_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/file_picker_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; @@ -31,7 +29,9 @@ import 'package:plezy/widgets/loading_indicator_box.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { setUp(() { @@ -327,7 +327,7 @@ Future<_EditorHarness> _pumpEditor(WidgetTester tester, _PlexMetadataRequests re PlexApiCache.initialize(database); final client = testPlexClient(serverId: ServerId('server-1'), handler: requests.handle); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); final metadata = ValueNotifier(_show()); await tester.pumpWidget( @@ -448,7 +448,7 @@ class _PlexMetadataRequests { path.startsWith('/library/metadata/') && request.url.queryParameters['includePreferences'] == '1') { final id = path.split('/').last; - return _jsonResponse({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [ { @@ -644,10 +644,6 @@ class _FakeFilePicker implements FilePickerDelegate { } } -http.Response _jsonResponse(Object body) { - return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); -} - http.Response _ok() => _response(200); http.Response _response(int statusCode) { diff --git a/test/screens/music/album_detail_screen_test.dart b/test/screens/music/album_detail_screen_test.dart index 4c735f5c..48de3c1e 100644 --- a/test/screens/music/album_detail_screen_test.dart +++ b/test/screens/music/album_detail_screen_test.dart @@ -9,7 +9,6 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/music/album_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; @@ -19,6 +18,7 @@ import 'package:provider/provider.dart'; import '../../test_helpers/prefs.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -52,19 +52,6 @@ void main() { // Track numbers restart per disc. expect(find.text('1'), findsNWidgets(2)); }); - - testWidgets('tapping a track on the stub service shows the not-supported notice', (tester) async { - final harness = await _createHarness(_multiDiscTracks()); - - await tester.pumpWidget(harness.wrap(const AlbumDetailScreen(album: _album))); - await tester.pumpAndSettle(); - - await tester.tap(find.text('Track One')); - await tester.pump(); - await tester.pump(const Duration(milliseconds: 300)); - - expect(find.text(t.messages.musicNotSupported), findsOneWidget); - }); } const _album = MediaItem.plex( @@ -109,7 +96,7 @@ Future<_AlbumHarness> _createHarness(List tracks) async { final client = _FakeMusicClient(tracks); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); diff --git a/test/screens/music/now_playing_screen_test.dart b/test/screens/music/now_playing_screen_test.dart index c3fd012a..364fdd78 100644 --- a/test/screens/music/now_playing_screen_test.dart +++ b/test/screens/music/now_playing_screen_test.dart @@ -10,14 +10,13 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/music/now_playing_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; MediaItem _track({required String id, required String title, required String album, required int year}) { return testMediaItem( @@ -55,9 +54,6 @@ class _FakeMusicService extends StubMusicPlaybackService { _positionController.add(position); } - @override - bool get isAvailable => true; - @override MediaItem get currentTrack => track; @@ -118,13 +114,8 @@ void main() { TvDetectionService.debugSetAppleTVOverride(isTv); PlatformDetector.debugSetIsDesktopOSOverride(!isTv); - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(service.dispose); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); + final multiServerProvider = testMultiServer().provider; await tester.pumpWidget( InputModeTracker( diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart index ac548bfe..688e7e93 100644 --- a/test/screens/music/queue_sheet_test.dart +++ b/test/screens/music/queue_sheet_test.dart @@ -7,8 +7,6 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/music/queue_sheet.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -17,6 +15,7 @@ import 'package:plezy/widgets/music/track_row.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; MediaItem _track(String id, String title) => testMediaItem( @@ -41,9 +40,6 @@ class _FakeQueueService extends StubMusicPlaybackService { _FakeQueueService(this.tracks); - @override - bool get isAvailable => true; - @override MediaItem? get currentTrack => tracks[1]; @@ -73,13 +69,8 @@ void main() { }); Widget wrap(MusicPlaybackService service) { - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(service.dispose); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); + final multiServerProvider = testMultiServer().provider; return TranslationProvider( child: MultiProvider( diff --git a/test/screens/playlist_detail_screen_test.dart b/test/screens/playlist_detail_screen_test.dart index 69151ff1..052fc93e 100644 --- a/test/screens/playlist_detail_screen_test.dart +++ b/test/screens/playlist_detail_screen_test.dart @@ -20,7 +20,6 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/screens/playlist/playlist_detail_screen.dart'; import 'package:plezy/screens/playlist/playlist_item_card.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -41,6 +40,7 @@ import 'package:plezy/utils/media_image_helper.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/prefs.dart'; @@ -671,7 +671,7 @@ Future<_PlaylistHarness> _createHarness( final client = _PagedPlaylistClient(items, failOnceAt: failOnceAt, deleteResult: deleteResult, backend: backend); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final playbackState = PlaybackStateProvider(); addTearDown(() async { diff --git a/test/screens/profile/profile_teardown_test.dart b/test/screens/profile/profile_teardown_test.dart index 0fa02b79..8895bb1a 100644 --- a/test/screens/profile/profile_teardown_test.dart +++ b/test/screens/profile/profile_teardown_test.dart @@ -20,12 +20,12 @@ import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/providers/user_profile_provider.dart'; import 'package:plezy/screens/profile/profile_teardown.dart'; import 'package:plezy/services/plex_auth_service.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; import 'package:plezy/services/system_shelf_service.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; class _PlexHome extends PlexHomeService { @@ -275,7 +275,7 @@ Future<_Harness> _pumpHarness( await storage.setActiveProfileId(profile.id); await active.initialize(); final manager = MultiServerManager(); - final multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServer = testMultiServerProvider(manager); final shelf = SystemShelfService.forTesting(channel: channel, isSupported: () async => true); shelf.beginProfileSession(profile.id); SystemShelfService.debugOverrideInstance(shelf); diff --git a/test/screens/search_screen_test.dart b/test/screens/search_screen_test.dart index 5063c6ca..b2a7191a 100644 --- a/test/screens/search_screen_test.dart +++ b/test/screens/search_screen_test.dart @@ -18,7 +18,6 @@ import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/mixins/refreshable.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/search_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -30,6 +29,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -395,7 +395,7 @@ Future<(_FakeMediaServerClient, GlobalKey>)> _pumpTvSearchSc for (final additionalClient in additionalClients) { manager.debugRegisterClientForTesting(additionalClient); } - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(provider.dispose); final key = GlobalKey>(); diff --git a/test/screens/settings/add_jellyfin_screen_test.dart b/test/screens/settings/add_jellyfin_screen_test.dart index 4895b0be..01aa5d7a 100644 --- a/test/screens/settings/add_jellyfin_screen_test.dart +++ b/test/screens/settings/add_jellyfin_screen_test.dart @@ -23,7 +23,6 @@ import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/settings/add_jellyfin_screen.dart'; import 'package:plezy/services/jellyfin_auth_service.dart'; import 'package:plezy/services/credential_vault.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_lan_discovery_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/storage_service.dart'; @@ -31,6 +30,7 @@ import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:provider/provider.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; Profile _profile(String id) => @@ -245,7 +245,7 @@ class _RouteHarness { storage: storage, ); final manager = _CountingJellyfinManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServerProvider(manager); final binder = _CountingActiveProfileBinder( activeProfile: activeProfiles, connections: connections, diff --git a/test/services/catalog/plex_catalog_source_test.dart b/test/services/catalog/plex_catalog_source_test.dart index c276e9a1..eade4e42 100644 --- a/test/services/catalog/plex_catalog_source_test.dart +++ b/test/services/catalog/plex_catalog_source_test.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; @@ -12,10 +10,9 @@ import 'package:plezy/services/catalog/plex_catalog_source.dart'; import 'package:plezy/services/plex_discover_client.dart'; import 'package:plezy/utils/external_ids.dart'; -const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); +import '../../test_helpers/http_fixtures.dart'; -http.Response _json(Object body, [int status = 200]) => - http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'}); +const _session = PlexDiscoverSession(accessToken: 'profile-token', clientIdentifier: 'client-id'); Map _metadata({ String ratingKey = 'plex-movie-1', @@ -51,7 +48,7 @@ void main() { _session, httpClient: MockClient((request) async { captured = request; - return _json({ + return jsonResponse({ 'MediaContainer': { 'offset': 25, 'size': 1, @@ -94,7 +91,7 @@ void main() { httpClient: MockClient((request) async { requests.add(request); if (request.url.path == '/hubs/sections/watchlist') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'Hub': [ { @@ -122,7 +119,7 @@ void main() { }); } if (request.url.path == '/hubs/sections/watchlist/because-watchlisted') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'offset': 2, 'totalSize': 3, @@ -130,7 +127,7 @@ void main() { }, }); } - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); @@ -162,7 +159,7 @@ void main() { _session, httpClient: MockClient((request) async { requests.add(request); - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); @@ -181,7 +178,7 @@ void main() { _session, httpClient: MockClient((request) async { captured = request; - return _json({ + return jsonResponse({ 'MediaContainer': { 'SearchResults': [ { @@ -221,7 +218,7 @@ void main() { httpClient: MockClient((request) async { requests.add(request); if (request.url.path == '/library/sections/watchlist/all') { - return _json({ + return jsonResponse({ 'MediaContainer': { 'totalSize': watchlisted ? 1 : 0, 'Metadata': watchlisted ? [_metadata()] : [], @@ -232,7 +229,7 @@ void main() { expect(request.url.path, '/actions/removeFromWatchlist'); expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); watchlisted = false; - return _json(const {}); + return jsonResponse(const {}); }), ), ); @@ -256,7 +253,7 @@ void main() { requests.add(request); if (request.url.path == '/library/metadata/matches') { expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [_metadata()], }, @@ -265,7 +262,7 @@ void main() { expect(request.method, 'PUT'); expect(request.url.path, '/actions/addToWatchlist'); expect(request.url.queryParameters['ratingKey'], 'plex-movie-1'); - return _json(const {}); + return jsonResponse(const {}); }), ), ); @@ -283,13 +280,13 @@ void main() { switch (request.url.path) { case '/library/metadata/matches': expect(request.url.queryParameters['guid'], 'imdb://tt1375666'); - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [_metadata(type: 'show')], }, }); case '/library/metadata/plex-movie-1': - return _json({ + return jsonResponse({ 'MediaContainer': { 'Metadata': [ { @@ -302,7 +299,7 @@ void main() { }, }); case '/library/metadata/plex-movie-1/related': - return _json({ + return jsonResponse({ 'MediaContainer': { 'Hub': [ { @@ -312,7 +309,7 @@ void main() { }, }); } - return _json({'error': 'unexpected'}, 500); + return jsonResponse({'error': 'unexpected'}, status: 500); }), ), ); diff --git a/test/services/catalog/seerr_catalog_source_test.dart b/test/services/catalog/seerr_catalog_source_test.dart index 30ae278a..6945e650 100644 --- a/test/services/catalog/seerr_catalog_source_test.dart +++ b/test/services/catalog/seerr_catalog_source_test.dart @@ -1,7 +1,4 @@ -import 'dart:convert'; - import 'package:flutter_test/flutter_test.dart'; -import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/catalog/catalog_item.dart'; @@ -11,6 +8,8 @@ import 'package:plezy/services/catalog/seerr_catalog_source.dart'; import 'package:plezy/services/seerr/seerr_client.dart'; import 'package:plezy/utils/external_ids.dart'; +import '../../test_helpers/http_fixtures.dart'; + SeerrCatalogSource _source(MockClient mock) { final client = SeerrClient( const SeerrSession( @@ -36,15 +35,13 @@ SeerrCatalogSource _source(MockClient mock) { return source; } -http.Response _json(Object body) => http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json'}); - void main() { group('SeerrCatalogSource', () { test('trending row keeps movies and shows, drops people, maps TMDB images', () async { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/discover/trending'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 3, 'results': [ @@ -89,7 +86,7 @@ void main() { final source = _source( MockClient((request) async { paths.add('${request.url.path}?${request.url.query}'); - return _json({ + return jsonResponse({ 'page': 2, 'totalPages': 2, 'results': [ @@ -106,13 +103,13 @@ void main() { }); test('rows Seerr does not serve throw', () { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(() => source.fetchRow(CatalogRowId.watchlist), throwsArgumentError); expect(() => source.fetchRow(CatalogRowId.suggestedAnime), throwsArgumentError); }); test('resolveItemIds needs a tmdb id', () async { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); final resolved = await source.resolveItemIds(MediaKind.movie, const ExternalIds(tmdb: 603, imdb: 'tt0133093')); expect(resolved?.tmdb, 603); expect(resolved?.imdb, 'tt0133093'); @@ -123,7 +120,7 @@ void main() { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/tv/1396'); - return _json({ + return jsonResponse({ 'id': 1396, 'name': 'Breaking Bad', 'credits': { @@ -156,7 +153,7 @@ void main() { MockClient((request) async { expect(request.url.path, '/api/v1/search'); expect(request.url.queryParameters['query'], 'the matrix'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 1, 'results': [ @@ -175,7 +172,7 @@ void main() { final source = _source( MockClient((request) async { expect(request.url.path, '/api/v1/movie/603/recommendations'); - return _json({ + return jsonResponse({ 'page': 1, 'totalPages': 1, 'results': [ @@ -197,13 +194,13 @@ void main() { test('canRequest honors the per-kind permission split', () { // permissions: 2 = ADMIN in the fixture session → everything allowed. - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(source.canRequest(MediaKind.movie), isTrue); expect(source.canRequest(MediaKind.show), isTrue); }); test('has no watchlist: membership unknown, mutations unsupported', () async { - final source = _source(MockClient((request) async => _json({}))); + final source = _source(MockClient((request) async => jsonResponse({}))); expect(source.supportsWatchlist, isFalse); expect(source.isOnWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), isNull); expect(() => source.addToWatchlist(MediaKind.movie, const CatalogItemIds(tmdb: 603)), throwsUnsupportedError); diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index f19af6d0..2b592554 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -33,6 +33,7 @@ import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/active_client_scope.dart'; import 'package:saf_util/saf_util_platform_interface.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 4b40b976..40a1b6f2 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -14,6 +14,7 @@ import 'package:plezy/services/episode_navigation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:provider/provider.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; MediaItem _meta(String id, {String? title}) => testMediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: title ?? 'Episode $id'); @@ -118,7 +119,7 @@ void main() { final playback = PlaybackStateProvider(); addTearDown(playback.dispose); final manager = _StubManager(null); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -395,8 +396,7 @@ void main() { ], ); final manager = _StubManager(client); - final aggregation = DataAggregationService(manager); - final serverProvider = MultiServerProvider(manager, aggregation); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -430,7 +430,7 @@ void main() { addTearDown(playback.dispose); final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3], clientBackend: MediaBackend.plex); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -463,7 +463,7 @@ void main() { fetchError: StateError('network unavailable'), ); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -491,7 +491,7 @@ void main() { addTearDown(playback.dispose); final client = _RecordingClient(seriesEpisodes: [ep1, ep2], clientBackend: MediaBackend.plex); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); AdjacentEpisodes? result; @@ -549,7 +549,7 @@ void main() { ); final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); return (playback, client, serverProvider); } @@ -622,7 +622,7 @@ void main() { addTearDown(playback.dispose); final client = _RecordingClient(seriesEpisodes: [ep1, ep2, ep3, ep4, ep5]); final manager = _StubManager(client); - final serverProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final serverProvider = testMultiServerProvider(manager); addTearDown(serverProvider.dispose); final result = await probe(tester, playback, serverProvider, ep3); diff --git a/test/services/external_player_service_test.dart b/test/services/external_player_service_test.dart index f0149417..8d479b83 100644 --- a/test/services/external_player_service_test.dart +++ b/test/services/external_player_service_test.dart @@ -6,7 +6,6 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/models/external_player_models.dart'; import 'package:plezy/services/external_player_service.dart'; import 'package:plezy/services/jellyfin_api_cache.dart'; @@ -15,8 +14,9 @@ import 'package:plezy/services/offline_watch_sync_service.dart'; import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/playback_report_fakes.dart'; -class _RecordingClient implements MediaServerClient, ScopedMediaServerClient { +class _RecordingClient with PlaybackReportRecorder implements MediaServerClient, ScopedMediaServerClient { _RecordingClient({this.backend = MediaBackend.plex, String? scopedServerId}) : scopedServerId = scopedServerId ?? @@ -48,33 +48,18 @@ class _RecordingClient implements MediaServerClient, ScopedMediaServerClient { bool get marksWatchedOnPlaybackStopped => backend == MediaBackend.jellyfin; @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - started.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - if (failStart) throw StateError('start failed'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - stopped.add((positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - if (failStop) throw StateError('stop failed'); + Future onPlaybackReport(PlaybackReportCall call) async { + final entry = (positionMs: call.position.inMilliseconds, durationMs: call.duration?.inMilliseconds); + switch (call.kind) { + case PlaybackReportKind.started: + started.add(entry); + if (failStart) throw StateError('start failed'); + case PlaybackReportKind.progress: + throw UnimplementedError(); + case PlaybackReportKind.stopped: + stopped.add(entry); + if (failStop) throw StateError('stop failed'); + } } @override diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index a3966840..f499adae 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -19,6 +19,7 @@ import 'package:plezy/utils/device_identity.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/http_fixtures.dart'; import '../test_helpers/paged_fakes.dart'; import '../test_helpers/media_items.dart'; @@ -50,13 +51,7 @@ JellyfinClient _clientWithPlaybackInfo( connection: _conn(), httpClient: MockClient((request) { if (request.url.path == '/Users/user-1/Items/item-1') { - return Future.value( - http.Response( - jsonEncode({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources}), - 200, - headers: {'content-type': 'application/json'}, - ), - ); + return Future.value(jsonResponse({'Id': 'item-1', 'Type': 'Movie', 'Name': 'Movie', 'MediaSources': sources})); } if (request.url.path == '/Items/item-1/PlaybackInfo') { return playbackInfo(request); @@ -66,6 +61,23 @@ JellyfinClient _clientWithPlaybackInfo( ); } +/// Serves [routes] as JSON keyed by request path and records the last URL seen +/// for each path; every other path answers 404. +({JellyfinClient client, Map requests}) _routedClient(Map routes) { + final requests = {}; + final client = JellyfinClient.forTesting( + connection: _conn(), + httpClient: MockClient((req) async { + final body = routes[req.url.path]; + if (body == null) return http.Response('not found', 404); + requests[req.url.path] = req.url; + return jsonResponse(body); + }), + ); + addTearDown(client.close); + return (client: client, requests: requests); +} + /// URL-builder smoke tests. We can't unit-test a network round-trip without /// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work: /// query parameters must include the right keys and the auth token. These @@ -212,38 +224,30 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/$encodedItemId/LocalTrailers') { - return http.Response( - jsonEncode([ - { - 'Id': 'trailer-1', - 'Name': 'Trailer', - 'Type': 'Trailer', - 'ExtraType': 'Trailer', - 'RunTimeTicks': 900000000, - 'ImageTags': {'Primary': 'trailer-tag'}, - }, - {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, - ]), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse([ + { + 'Id': 'trailer-1', + 'Name': 'Trailer', + 'Type': 'Trailer', + 'ExtraType': 'Trailer', + 'RunTimeTicks': 900000000, + 'ImageTags': {'Primary': 'trailer-tag'}, + }, + {'Id': 'theme-song', 'Name': 'Theme Song', 'Type': 'Audio', 'ExtraType': 'ThemeSong'}, + ]); } if (request.url.path == '/Items/$encodedItemId/SpecialFeatures') { - return http.Response( - jsonEncode([ - {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, - { - 'Id': 'featurette-1', - 'Name': 'Making Of', - 'Type': 'Video', - 'ExtraType': 'Featurette', - 'RunTimeTicks': 1800000000, - 'BackdropImageTags': ['featurette-backdrop'], - }, - ]), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse([ + {'Id': 'trailer-1', 'Name': 'Trailer Duplicate', 'Type': 'Trailer', 'ExtraType': 'Trailer'}, + { + 'Id': 'featurette-1', + 'Name': 'Making Of', + 'Type': 'Video', + 'ExtraType': 'Featurette', + 'RunTimeTicks': 1800000000, + 'BackdropImageTags': ['featurette-backdrop'], + }, + ]); } return http.Response('unexpected ${request.url}', 500); }), @@ -405,57 +409,49 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-2', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'MediaStreams': []}, - { - 'Id': 'src-2', - 'MediaStreams': [ - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayLanguage': 'English', - 'DisplayTitle': 'English - SRT', - 'IsExternal': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', - }, - { - 'Index': 4, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'fra', - 'DisplayLanguage': 'French', - 'DisplayTitle': 'French - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + { + 'Id': 'src-2', + 'MediaStreams': [ + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayLanguage': 'English', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/3/Stream.srt', + }, + { + 'Index': 4, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'fra', + 'DisplayLanguage': 'French', + 'DisplayTitle': 'French - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-2/Subtitles/4/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -503,14 +499,7 @@ void main() { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), - ( - 'malformed success', - (_) async => http.Response( - jsonEncode({'MediaSources': 'invalid'}), - 200, - headers: {'content-type': 'application/json'}, - ), - ), + ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { @@ -530,15 +519,11 @@ void main() { test('resolveDownload keeps the static stream when subtitle metadata is malformed', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), ); addTearDown(scoped.close); @@ -569,14 +554,7 @@ void main() { final cases = <(String, Future Function(http.Request))>[ ('server error', (_) async => http.Response('{}', 500, headers: {'content-type': 'application/json'})), ('client error', (_) async => http.Response('{}', 400, headers: {'content-type': 'application/json'})), - ( - 'malformed success', - (_) async => http.Response( - jsonEncode({'MediaSources': 'invalid'}), - 200, - headers: {'content-type': 'application/json'}, - ), - ), + ('malformed success', (_) async => jsonResponse({'MediaSources': 'invalid'})), ]; for (final (name, handler) in cases) { @@ -635,19 +613,15 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, - {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'item-1', 'Container': 'mp4', 'MediaStreams': []}, + {'Id': 'src-alt', 'Container': 'mkv', 'MediaStreams': []}, + ], + }); } return http.Response('{}', 404); }), @@ -672,46 +646,38 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUris.add(request.url); playbackInfoBodies.add(request.body); - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Id': 'src-1', - 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, - { - 'Index': 2, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-1', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 2, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/2/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -762,33 +728,25 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }); } return http.Response('{}', 404); }), @@ -826,57 +784,49 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'DefaultAudioStreamIndex': 1, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - 'TranscodingUrl': - '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'IsExternal': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 1, + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'DisplayTitle': 'English - AAC'}, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'IsExternal': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -931,77 +881,69 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, - {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, - {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'fra'}, + {'Index': 5, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsForced': true}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'DefaultSubtitleStreamIndex': 4, - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - { - 'Index': 3, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', - }, - { - 'Index': 4, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'fra', - 'DisplayTitle': 'French - SRT', - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', - }, - { - 'Index': 5, - 'Type': 'Subtitle', - 'Codec': 'srt', - 'Language': 'eng', - 'DisplayTitle': 'English Forced - SRT', - 'IsForced': true, - 'DeliveryMethod': 'External', - 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', - }, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'DefaultSubtitleStreamIndex': 4, + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + { + 'Index': 3, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/3/Stream.srt', + }, + { + 'Index': 4, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'fra', + 'DisplayTitle': 'French - SRT', + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/4/Stream.srt', + }, + { + 'Index': 5, + 'Type': 'Subtitle', + 'Codec': 'srt', + 'Language': 'eng', + 'DisplayTitle': 'English Forced - SRT', + 'IsForced': true, + 'DeliveryMethod': 'External', + 'DeliveryUrl': '/Videos/item-1/src-1/Subtitles/5/Stream.srt', + }, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -1075,42 +1017,34 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Id': 'src-1', - 'TranscodingUrl': - '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Id': 'src-1', + 'TranscodingUrl': + '/Videos/item-1/master.m3u8?MediaSourceId=src-1&PlaySessionId=play-session-transcode', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + ], + }, + ], + }); } return http.Response('{}', 404); }), @@ -1152,39 +1086,31 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video'}, - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, - {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video'}, + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng', 'IsDefault': true}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn', 'DeliveryMethod': 'External'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1226,46 +1152,38 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, - ], - }, - { - 'Id': 'src-2', - 'Container': 'mp4', - 'DefaultAudioStreamIndex': 8, - 'MediaStreams': [ - {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 1, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + {'Index': 4, 'Type': 'Audio', 'Codec': 'flac', 'Language': 'jpn'}, + ], + }, + { + 'Id': 'src-2', + 'Container': 'mp4', + 'DefaultAudioStreamIndex': 8, + 'MediaStreams': [ + {'Index': 8, 'Type': 'Audio', 'Codec': 'aac', 'Language': 'eng'}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-2'}, + ], + }); } return http.Response('{}', 404); }), @@ -1303,44 +1221,36 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, - ], - }, - { - 'Id': 'src-1080', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 1608, 'Width': 3840}, + ], + }, + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 804, 'Width': 1920}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1080'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1080'}, + ], + }); } return http.Response('{}', 404); }), @@ -1376,44 +1286,36 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'item-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, - ], - }, - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'item-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { playbackInfoUri = request.url; playbackInfoBody = request.body; - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'item-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'item-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1448,47 +1350,39 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1080', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, - ], - }, - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'MediaStreams': [ - {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1080', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'h264', 'Height': 1080, 'Width': 1920}, + ], + }, + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'MediaStreams': [ + {'Index': 0, 'Type': 'Video', 'Codec': 'hevc', 'Height': 2160, 'Width': 3840}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'wrong-session', - 'MediaSources': [ - { - 'Id': 'src-4k', - 'Container': 'mkv', - 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'wrong-session', + 'MediaSources': [ + { + 'Id': 'src-4k', + 'Container': 'mkv', + 'DirectStreamUrl': '/Videos/item-1/stream?MediaSourceId=src-4k&PlaySessionId=wrong-session', + }, + ], + }); } return http.Response('{}', 404); }), @@ -1516,10 +1410,7 @@ void main() { ); test('empty successful negotiation falls back to the static VOD stream', () async { - final scoped = _clientWithPlaybackInfo( - (_) async => - http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}), - ); + final scoped = _clientWithPlaybackInfo((_) async => jsonResponse({'MediaSources': []})); addTearDown(scoped.close); final result = await scoped.getPlaybackInitialization( @@ -1540,15 +1431,11 @@ void main() { test('applicable source without negotiated URL falls back to static direct play', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }), ); addTearDown(scoped.close); @@ -1627,7 +1514,7 @@ void main() { for (final testCase in cases) { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response(jsonEncode(testCase.response), 200, headers: {'content-type': 'application/json'}), + (_) async => jsonResponse(testCase.response), itemSources: testCase.itemSources, ); addTearDown(scoped.close); @@ -1640,15 +1527,11 @@ void main() { test('matching download source with empty streams is a complete empty-sidecar plan', () async { final scoped = _clientWithPlaybackInfo( - (_) async => http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ), + (_) async => jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'MediaStreams': []}, + ], + }), ); addTearDown(scoped.close); @@ -1683,7 +1566,7 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'MediaSources': []}); }), ); addTearDown(scoped.close); @@ -1701,7 +1584,7 @@ void main() { httpClient: MockClient((request) async { capturedUri = request.url; capturedBody = request.body; - return http.Response(jsonEncode({'MediaSources': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'MediaSources': []}); }), ); addTearDown(scoped.close); @@ -1746,7 +1629,7 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { captured.add(request.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }), ); addTearDown(scoped.close); @@ -1805,29 +1688,21 @@ void main() { connection: _conn(accessToken: 'tok+with spaces/?&'), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1', 'TranscodingUrl': '/Videos/item-1/master.m3u8?MediaSourceId=src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1856,35 +1731,27 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - { - 'Id': 'src-1', - 'Container': 'mp4', - 'MediaStreams': [ - {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, - ], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + { + 'Id': 'src-1', + 'Container': 'mp4', + 'MediaStreams': [ + {'Index': 3, 'Type': 'Subtitle', 'Codec': 'srt', 'Language': 'eng', 'IsExternal': true}, + ], + }, + ], + }); } if (request.url.path == '/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'Id': 'src-1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'Id': 'src-1'}, + ], + }); } return http.Response('{}', 404); }), @@ -1919,21 +1786,17 @@ void main() { requests.add(request.url); capturedBody = request.body; if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'live-session-1', - 'MediaSources': [ - { - 'Id': 'source-1', - 'Container': 'ts', - 'LiveStreamId': 'open-stream-1', - 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'live-session-1', + 'MediaSources': [ + { + 'Id': 'source-1', + 'Container': 'ts', + 'LiveStreamId': 'open-stream-1', + 'TranscodingUrl': '/Videos/channel-1/live.m3u8?PlaySessionId=live-session-1', + }, + ], + }); } return http.Response('{}', 404); }), @@ -1970,19 +1833,15 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - { - 'Container': 'ts', - 'TranscodingUrl': - '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + { + 'Container': 'ts', + 'TranscodingUrl': + '/Videos/channel-1/live.m3u8?MediaSourceId=source-url&LiveStreamId=live-url&PlaySessionId=play-url', + }, + ], + }); } return http.Response('{}', 404); }), @@ -2003,15 +1862,11 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Items/channel-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'MediaSources': [ - {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'MediaSources': [ + {'DirectStreamUrl': '/Videos/channel-1/stream.ts'}, + ], + }); } return http.Response('{}', 404); }), @@ -2077,33 +1932,25 @@ void main() { connection: _conn(baseUrl: 'https://jf.example.com/jellyfin'), httpClient: MockClient((request) async { if (request.url.path == '/jellyfin/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-1', 'Container': 'mp4', 'MediaStreams': []}, + ], + }); } if (request.url.path == '/jellyfin/Items/item-1/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-session-direct', - 'MediaSources': [ - { - 'Id': 'src-1', - 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-session-direct', + 'MediaSources': [ + { + 'Id': 'src-1', + 'DirectStreamUrl': 'Videos/item-1/stream?MediaSourceId=src-1&PlaySessionId=play-session-direct', + }, + ], + }); } return http.Response('{}', 404); }), @@ -2140,48 +1987,40 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { if (request.url.path == '/Users/user-1/Items/item-trickplay') { - return http.Response( - jsonEncode({ - 'Id': 'item-trickplay', - 'Type': 'Movie', - 'Name': 'Movie', - 'MediaSources': [ - {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, - {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, - ], - 'Trickplay': { - 'src-a': { - '160': { - 'Width': 160, - 'Height': 90, - 'TileWidth': 4, - 'TileHeight': 4, - 'ThumbnailCount': 16, - 'Interval': 10000, - }, + return jsonResponse({ + 'Id': 'item-trickplay', + 'Type': 'Movie', + 'Name': 'Movie', + 'MediaSources': [ + {'Id': 'src-a', 'Container': 'mkv', 'MediaStreams': []}, + {'Id': 'src-b', 'Container': 'mp4', 'MediaStreams': []}, + ], + 'Trickplay': { + 'src-a': { + '160': { + 'Width': 160, + 'Height': 90, + 'TileWidth': 4, + 'TileHeight': 4, + 'ThumbnailCount': 16, + 'Interval': 10000, }, }, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + }, + }); } if (request.url.path == '/Items/item-trickplay/PlaybackInfo') { - return http.Response( - jsonEncode({ - 'PlaySessionId': 'play-b', - 'MediaSources': [ - { - 'Id': 'src-b', - 'Container': 'mp4', - 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', - 'MediaStreams': [], - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'PlaySessionId': 'play-b', + 'MediaSources': [ + { + 'Id': 'src-b', + 'Container': 'mp4', + 'DirectStreamUrl': '/Videos/item-trickplay/stream?MediaSourceId=src-b', + 'MediaStreams': [], + }, + ], + }); } return http.Response('{}', 404); }), @@ -2269,21 +2108,17 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'movie-1', - 'Type': 'Movie', - 'Name': 'Movie', - 'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'], - }, - ], - 'TotalRecordCount': 123, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'movie-1', + 'Type': 'Movie', + 'Name': 'Movie', + 'BackdropImageTags': ['backdrop-0', 'backdrop-1', 'backdrop-2'], + }, + ], + 'TotalRecordCount': 123, + }); }), ); addTearDown(scoped.close); @@ -2318,11 +2153,7 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); @@ -2356,16 +2187,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Genres': ['Drama', 'Action'], - 'OfficialRatings': ['PG-13'], - 'Tags': ['Holiday'], - 'Years': [2024, 1999], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Genres': ['Drama', 'Action'], + 'OfficialRatings': ['PG-13'], + 'Tags': ['Holiday'], + 'Years': [2024, 1999], + }); }), ); addTearDown(scoped.close); @@ -2404,15 +2231,11 @@ void main() { httpClient: MockClient((req) async { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final limit = int.parse(req.url.queryParameters['Limit'] ?? '25'); - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < start + limit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }); }), ); addTearDown(scoped.close); @@ -2432,11 +2255,7 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); }), ); addTearDown(scoped.close); @@ -2470,11 +2289,7 @@ void main() { captured.add(req.url); final foldersOnly = req.url.queryParameters['IncludeItemTypes'] == 'Folder,CollectionFolder'; final items = allChildren.where((c) => (c['Type'] == 'Folder') == foldersOnly).toList(); - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': items.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); }), ); addTearDown(scoped.close); @@ -2521,27 +2336,19 @@ void main() { httpClient: MockClient((req) async { if (req.url.queryParameters.containsKey('IncludeItemTypes')) { // Folders query — this directory has none. - return http.Response( - jsonEncode({'Items': const [], 'TotalRecordCount': 0}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': const [], 'TotalRecordCount': 0}); } mediaStarts.add(req.url.queryParameters['StartIndex']); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < end; i++) - {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false}, - ], - 'TotalRecordCount': total, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < end; i++) + {'Id': 'child-$i', 'Type': 'Movie', 'Name': 'Child $i', 'IsFolder': false}, + ], + 'TotalRecordCount': total, + }); }), ); addTearDown(scoped.close); @@ -2574,16 +2381,12 @@ void main() { final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); const total = 501; final end = start == 0 ? 500 : total; - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'}, - ], - 'TotalRecordCount': total, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = start; i < end; i++) {'Id': 'ep-$i', 'Type': 'Episode', 'Name': 'Episode $i'}, + ], + 'TotalRecordCount': total, + }); }), ); addTearDown(scoped.close); @@ -2625,11 +2428,7 @@ void main() { 'UserData': {'PlayCount': 0}, }, ]; - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': total}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': total}); }), ); addTearDown(pagedClient.close); @@ -2648,16 +2447,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { captured = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, - ], - 'TotalRecordCount': 1, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'movie-1', 'Type': 'Movie', 'Name': 'Movie'}, + ], + 'TotalRecordCount': 1, + }); }), ); addTearDown(scoped.close); @@ -2684,15 +2479,11 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/show-1') { - return http.Response( - jsonEncode({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Id': 'show-1', 'Type': 'Series', 'Name': 'Show 1'}); } if (req.url.path == '/Shows/NextUp') { capturedNextUp = req.url; - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } return http.Response('not found', 404); }), @@ -2717,23 +2508,15 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Id': 'item-1', 'Type': 'Episode', 'Name': 'Episode', 'Chapters': []}); } if (req.url.path == '/MediaSegments/item-1') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, - {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Type': 'Intro', 'StartTicks': 50000000, 'EndTicks': 450000000}, + {'Type': 'Outro', 'StartTicks': 900000000, 'EndTicks': 1000000000}, + ], + }); } return http.Response('not found', 404); }), @@ -2753,21 +2536,17 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/Users/user-1/Items/item-1') { - return http.Response( - jsonEncode({ - 'Id': 'item-1', - 'Type': 'Episode', - 'Name': 'Episode', - 'RunTimeTicks': 1200000000, - 'Chapters': [ - {'Name': 'OP', 'StartPositionTicks': 100000000}, - {'Name': 'Episode', 'StartPositionTicks': 450000000}, - {'Name': 'ED', 'StartPositionTicks': 900000000}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'item-1', + 'Type': 'Episode', + 'Name': 'Episode', + 'RunTimeTicks': 1200000000, + 'Chapters': [ + {'Name': 'OP', 'StartPositionTicks': 100000000}, + {'Name': 'Episode', 'StartPositionTicks': 450000000}, + {'Name': 'ED', 'StartPositionTicks': 900000000}, + ], + }); } if (req.url.path == '/MediaSegments/item-1') { return http.Response('not found', 404); @@ -2791,28 +2570,20 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'}, - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-show-1', 'Type': 'Episode', 'Name': 'Resume Show 1', 'SeriesId': 'show-1'}, + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, - {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, + {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, + ], + }); } return http.Response('not found', 404); }), @@ -2847,47 +2618,35 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'resume-old', - 'Type': 'Movie', - 'Name': 'Old Movie', - 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'resume-old', + 'Type': 'Movie', + 'Name': 'Old Movie', + 'UserData': {'LastPlayedDate': '2020-01-01T00:00:00.0000000Z'}, + }, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'ep-played', - 'Type': 'Episode', - 'SeriesId': 'show-recent', - 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }); } return http.Response('not found', 404); }), @@ -2917,53 +2676,41 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'resume-old-1', - 'Type': 'Movie', - 'Name': 'Old Movie 1', - 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, - }, - { - 'Id': 'resume-old-2', - 'Type': 'Movie', - 'Name': 'Old Movie 2', - 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'resume-old-1', + 'Type': 'Movie', + 'Name': 'Old Movie 1', + 'UserData': {'LastPlayedDate': '2021-01-01T00:00:00.0000000Z'}, + }, + { + 'Id': 'resume-old-2', + 'Type': 'Movie', + 'Name': 'Old Movie 2', + 'UserData': {'LastPlayedDate': '2022-01-01T00:00:00.0000000Z'}, + }, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-recent', 'Type': 'Episode', 'Name': 'Next Recent', 'SeriesId': 'show-recent'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'ep-played', - 'Type': 'Episode', - 'SeriesId': 'show-recent', - 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'ep-played', + 'Type': 'Episode', + 'SeriesId': 'show-recent', + 'UserData': {'LastPlayedDate': '2026-06-01T00:00:00.0000000Z'}, + }, + ], + }); } return http.Response('not found', 404); }), @@ -2982,15 +2729,11 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { return http.Response('server error', 500); @@ -3012,28 +2755,20 @@ void main() { httpClient: MockClient((req) async { requests.add(req.url); if (req.url.path == '/UserItems/Resume') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, - {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'resume-movie-1', 'Type': 'Movie', 'Name': 'Resume Movie 1'}, + {'Id': 'resume-movie-2', 'Type': 'Movie', 'Name': 'Resume Movie 2'}, + ], + }); } if (req.url.path == '/Shows/NextUp') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, - {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'next-show-1', 'Type': 'Episode', 'Name': 'Next Show 1', 'SeriesId': 'show-1'}, + {'Id': 'next-show-2', 'Type': 'Episode', 'Name': 'Next Show 2', 'SeriesId': 'show-2'}, + ], + }); } return http.Response('not found', 404); }), @@ -3057,7 +2792,7 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } @@ -3068,15 +2803,11 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response( - jsonEncode({ - 'Items': [ - for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + for (var i = 0; i < defaultHubPreviewLimit; i++) {'Id': 'movie-$i', 'Type': 'Movie', 'Name': 'Movie $i'}, + ], + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -3126,7 +2857,7 @@ void main() { captured = []; final mock = MockClient((req) async { captured.add(req.url); - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); }); return JellyfinClient.forTesting(connection: _conn(), httpClient: mock); } @@ -3338,16 +3069,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 30, - 'Items': [ - {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 30, + 'Items': [ + {'Id': 'resume-20', 'Name': 'Resume', 'Type': 'Movie'}, + ], + }); }), ); addTearDown(client.close); @@ -3370,16 +3097,12 @@ void main() { connection: _conn(), httpClient: MockClient((req) async { requestUri = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 321, - 'Items': [ - {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 321, + 'Items': [ + {'Id': 'recent-20', 'Name': 'Recent', 'Type': 'Movie'}, + ], + }); }), ); addTearDown(client.close); @@ -3438,28 +3161,20 @@ void main() { final mock = MockClient((req) async { requests.add(req.url); if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'TotalRecordCount': 1, - 'Items': [ - {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 1, + 'Items': [ + {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3493,28 +3208,20 @@ void main() { Uri? itemsRequest; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { itemsRequest = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 30, - 'Items': [ - {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 30, + 'Items': [ + {'Id': 'collection-20', 'Name': 'Collection 20', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3536,27 +3243,19 @@ void main() { test('fetchCollectionsPage uses sentinel total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, - {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'collection-1', 'Name': 'Collection 1', 'Type': 'BoxSet'}, + {'Id': 'collection-2', 'Name': 'Collection 2', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3573,29 +3272,21 @@ void main() { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-boxsets', 'Name': 'Collections', 'CollectionType': 'boxsets'}, + ], + }); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = req.url.queryParameters['StartIndex']; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 2, - 'Items': [ - {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 2, + 'Items': [ + {'Id': start == '0' ? 'collection-1' : 'collection-2', 'Name': 'Collection', 'Type': 'BoxSet'}, + ], + }); } return http.Response('not found', 404); }); @@ -3613,19 +3304,15 @@ void main() { var itemsRequested = false; final mock = MockClient((req) async { if (req.url.path == '/Users/user-1/Views') { - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'lib-movies', 'Name': 'Movies', 'CollectionType': 'movies'}, + ], + }); } if (req.url.path == '/Items') { itemsRequested = true; - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } return http.Response('not found', 404); }); @@ -3643,16 +3330,12 @@ void main() { final mock = MockClient((req) async { if (req.url.path == '/Items') { itemsRequest = req.url; - return http.Response( - jsonEncode({ - 'TotalRecordCount': 25, - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 25, + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie 1', 'Type': 'Movie'}, + ], + }); } return http.Response('not found', 404); }); @@ -3709,176 +3392,119 @@ void main() { group('JellyfinClient paged media lists', () { test('fetchPersonMediaPage uses requested page bounds', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchPersonMediaPage('person-1', start: 20, size: 10); + final page = await routed.client.fetchPersonMediaPage('person-1', start: 20, size: 10); expect(page.items.single.id, 'movie-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['PersonIds'], 'person-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['PersonIds'], 'person-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); }); test('fetchPlayableDescendantsPage uses requested page bounds', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10); + final page = await routed.client.fetchPlayableDescendantsPage('show-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'show-1'); - expect(requestUri!.queryParameters['Recursive'], 'true'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'show-1'); + expect(query['Recursive'], 'true'); // Audio rides along so albums/artists/audio playlists expand to tracks. - expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Audio'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); + expect(query['IncludeItemTypes'], 'Movie,Episode,Audio'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); }); test('fetchSeasonEpisodesPage uses Jellyfin episode endpoint scoped to season', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Shows/show-1/Episodes') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Shows/show-1/Episodes': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10); + final page = await routed.client.fetchSeasonEpisodesPage('show-1', 'season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['SeasonId'], 'season-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); - expect(requestUri!.queryParameters['EnableTotalRecordCount'], 'true'); - expect(requestUri!.queryParameters['IsMissing'], 'false'); - expect(requestUri!.queryParameters['IsVirtualUnaired'], 'false'); - expect(requestUri!.queryParameters['Fields']!.split(','), contains('MediaSources')); - expect(requestUri!.queryParameters.containsKey('SortBy'), isFalse); - expect(requestUri!.queryParameters.containsKey('SortOrder'), isFalse); + final query = routed.requests['/Shows/show-1/Episodes']!.queryParameters; + expect(query['SeasonId'], 'season-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); + expect(query['EnableTotalRecordCount'], 'true'); + expect(query['IsMissing'], 'false'); + expect(query['IsVirtualUnaired'], 'false'); + expect(query['Fields']!.split(','), contains('MediaSources')); + expect(query.containsKey('SortBy'), isFalse); + expect(query.containsKey('SortOrder'), isFalse); }); test('fetchChildrenPage orders direct episode children by season and episode index', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Shows/season-1/Seasons') { - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); - } - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Shows/season-1/Seasons': {'Items': []}, + '/Items': { + 'Items': [ + {'Id': 'episode-1', 'Name': 'Episode', 'Type': 'Episode'}, + ], + 'TotalRecordCount': 40, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final page = await client.fetchChildrenPage('season-1', start: 20, size: 10); + final page = await routed.client.fetchChildrenPage('season-1', start: 20, size: 10); expect(page.items.single.id, 'episode-1'); expect(page.totalCount, 40); expect(page.offset, 20); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'season-1'); - expect(requestUri!.queryParameters['StartIndex'], '20'); - expect(requestUri!.queryParameters['Limit'], '10'); - expect(requestUri!.queryParameters['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); - expect(requestUri!.queryParameters['SortOrder'], 'Ascending,Ascending,Ascending'); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'season-1'); + expect(query['StartIndex'], '20'); + expect(query['Limit'], '10'); + expect(query['SortBy'], 'ParentIndexNumber,IndexNumber,SortName'); + expect(query['SortOrder'], 'Ascending,Ascending,Ascending'); }); test('fetchPlayableFolderDescendants includes generic video but excludes audio', () async { - Uri? requestUri; - final mock = MockClient((req) async { - if (req.url.path == '/Items') { - requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'}, - ], - 'TotalRecordCount': 1, - }), - 200, - headers: {'content-type': 'application/json'}, - ); - } - return http.Response('not found', 404); + final routed = _routedClient({ + '/Items': { + 'Items': [ + {'Id': 'video-1', 'Name': 'Home Video', 'Type': 'Video'}, + ], + 'TotalRecordCount': 1, + }, }); - final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); - addTearDown(client.close); - final items = await client.fetchPlayableFolderDescendants('folder-1'); + final items = await routed.client.fetchPlayableFolderDescendants('folder-1'); expect(items.single.kind, MediaKind.clip); - expect(requestUri, isNotNull); - expect(requestUri!.queryParameters['ParentId'], 'folder-1'); - expect(requestUri!.queryParameters['Recursive'], 'true'); - expect(requestUri!.queryParameters['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo'); - expect(requestUri!.queryParameters['IncludeItemTypes'], isNot(contains('Audio'))); + final query = routed.requests['/Items']!.queryParameters; + expect(query['ParentId'], 'folder-1'); + expect(query['Recursive'], 'true'); + expect(query['IncludeItemTypes'], 'Movie,Episode,Video,MusicVideo'); + expect(query['IncludeItemTypes'], isNot(contains('Audio'))); }); test('fetchPlayableDescendants cancellation stops before a second page', () async { @@ -3889,14 +3515,10 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(500, (i) => {'Id': 'movie-$i', 'Name': 'Movie $i', 'Type': 'Movie'}), + 'TotalRecordCount': 501, + }); }), ); addTearDown(client.close); @@ -3916,14 +3538,10 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(500, (i) => {'Id': 'video-$i', 'Name': 'Video $i', 'Type': 'Video'}), + 'TotalRecordCount': 501, + }); }), ); addTearDown(client.close); @@ -3943,17 +3561,13 @@ void main() { httpClient: MockClient((request) async { starts.add(request.url.queryParameters['StartIndex']); abort.abort(); - return http.Response( - jsonEncode({ - 'Items': List.generate( - 200, - (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, - ), - 'TotalRecordCount': 201, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate( + 200, + (i) => {'Id': 'episode-$i', 'Name': 'Episode $i', 'Type': 'Episode', 'IndexNumber': i + 1}, + ), + 'TotalRecordCount': 201, + }); }), ); addTearDown(client.close); @@ -3969,23 +3583,16 @@ void main() { final itemRequests = []; final mock = MockClient((req) async { if (req.url.path == '/Shows/season-1/Seasons') { - return http.Response(jsonEncode({'Items': []}), 200, headers: {'content-type': 'application/json'}); + return jsonResponse({'Items': []}); } if (req.url.path == '/Items') { itemRequests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex'] ?? '0'); final count = start == 0 ? 500 : 1; - return http.Response( - jsonEncode({ - 'Items': List.generate( - count, - (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}, - ), - 'TotalRecordCount': 501, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(count, (i) => {'Id': 'episode-${start + i}', 'Name': 'Episode', 'Type': 'Episode'}), + 'TotalRecordCount': 501, + }); } return http.Response('not found', 404); }); @@ -4014,11 +3621,7 @@ void main() { if (requestedMediaType == null) return true; return (item['MediaType'] as String).toLowerCase() == requestedMediaType; }).toList(); - return http.Response( - jsonEncode({'Items': items, 'TotalRecordCount': items.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({'Items': items, 'TotalRecordCount': items.length}); } return http.Response('not found', 404); }); @@ -4052,14 +3655,10 @@ void main() { final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({ - 'Items': sliceFakePage(filtered, start: start, size: limit), - 'TotalRecordCount': filtered.length, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4094,14 +3693,10 @@ void main() { final filtered = allItems.where((item) => item['MediaType'] == mediaType).toList(); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({ - 'Items': sliceFakePage(filtered, start: start, size: limit), - 'TotalRecordCount': filtered.length, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(filtered, start: start, size: limit), + 'TotalRecordCount': filtered.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4130,11 +3725,10 @@ void main() { requests.add(req.url); final start = int.parse(req.url.queryParameters['StartIndex']!); final limit = int.parse(req.url.queryParameters['Limit']!); - return http.Response( - jsonEncode({'Items': sliceFakePage(videos, start: start, size: limit), 'TotalRecordCount': videos.length}), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': sliceFakePage(videos, start: start, size: limit), + 'TotalRecordCount': videos.length, + }); }); final client = JellyfinClient.forTesting(connection: _conn(), httpClient: mock); addTearDown(client.close); @@ -4172,16 +3766,12 @@ void main() { final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { requestUri = req.url; - return http.Response( - jsonEncode({ - 'Items': [ - {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, - ], - 'TotalRecordCount': 40, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + {'Id': 'movie-1', 'Name': 'Movie', 'Type': 'Movie'}, + ], + 'TotalRecordCount': 40, + }); } return http.Response('not found', 404); }); @@ -4201,13 +3791,9 @@ void main() { test('fetchPlaylistPage uses minimal fallback total when total count is missing', () async { final mock = MockClient((req) async { if (req.url.path == '/Playlists/pl-1/Items') { - return http.Response( - jsonEncode({ - 'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}), - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': List.generate(10, (i) => {'Id': 'movie-$i', 'Name': 'Movie', 'Type': 'Movie'}), + }); } return http.Response('not found', 404); }); @@ -4224,21 +3810,17 @@ void main() { test('absolutizes playlist thumbnail artwork with reverse-proxy subpath', () async { final mock = MockClient((req) async { if (req.url.path == '/jellyfin/Items') { - return http.Response( - jsonEncode({ - 'Items': [ - { - 'Id': 'video-1', - 'Name': 'Video Playlist', - 'Type': 'Playlist', - 'MediaType': 'Video', - 'ImageTags': {'Primary': 'tag 1'}, - }, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Items': [ + { + 'Id': 'video-1', + 'Name': 'Video Playlist', + 'Type': 'Playlist', + 'MediaType': 'Video', + 'ImageTags': {'Primary': 'tag 1'}, + }, + ], + }); } return http.Response('not found', 404); }); @@ -4262,16 +3844,12 @@ void main() { connection: _conn(), httpClient: MockClient((request) async { capturedUri = request.url; - return http.Response( - jsonEncode({ - 'Id': 'folder/item #1?x', - 'Name': 'Movie', - 'Type': 'Movie', - 'ProviderIds': {'Tmdb': '1'}, - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'Id': 'folder/item #1?x', + 'Name': 'Movie', + 'Type': 'Movie', + 'ProviderIds': {'Tmdb': '1'}, + }); }), ); addTearDown(client.close); @@ -4319,17 +3897,13 @@ void main() { httpClient: MockClient((request) async { requests.add(request.url); if (request.url.path == '/Items/item-1/RemoteImages') { - return http.Response( - jsonEncode({ - 'TotalRecordCount': 1, - 'Providers': ['TheMovieDb'], - 'Images': [ - {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, - ], - }), - 200, - headers: {'content-type': 'application/json'}, - ); + return jsonResponse({ + 'TotalRecordCount': 1, + 'Providers': ['TheMovieDb'], + 'Images': [ + {'ProviderName': 'TheMovieDb', 'Url': 'https://img.example/poster.jpg', 'Type': 'Primary'}, + ], + }); } return http.Response('', 204); }), diff --git a/test/services/live_session_tracker_test.dart b/test/services/live_session_tracker_test.dart index 8c8a18f5..120538f9 100644 --- a/test/services/live_session_tracker_test.dart +++ b/test/services/live_session_tracker_test.dart @@ -1,57 +1,27 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/live_session_tracker.dart'; -class _FakeJellyfinClient implements JellyfinClient { +import '../test_helpers/playback_report_fakes.dart'; + +class _FakeJellyfinClient with PlaybackReportRecorder implements JellyfinClient { final calls = []; final startGate = Completer(); @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - await startGate.future; - calls.add('started:$itemId:$playSessionId:$mediaSourceId:$liveStreamId:$playMethod'); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - calls.add('${isPaused ? 'paused' : 'playing'}:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - calls.add('stopped:$itemId:$playSessionId:$mediaSourceId:$liveStreamId'); + Future onPlaybackReport(PlaybackReportCall call) async { + final identity = '${call.itemId}:${call.playSessionId}:${call.mediaSourceId}:${call.liveStreamId}'; + switch (call.kind) { + case PlaybackReportKind.started: + await startGate.future; + calls.add('started:$identity:${call.playMethod}'); + case PlaybackReportKind.progress: + calls.add('${call.isPaused ? 'paused' : 'playing'}:$identity'); + case PlaybackReportKind.stopped: + calls.add('stopped:$identity'); + } } @override diff --git a/test/services/live_tv_capability_contract_test.dart b/test/services/live_tv_capability_contract_test.dart index 6c97b18f..7ff303ea 100644 --- a/test/services/live_tv_capability_contract_test.dart +++ b/test/services/live_tv_capability_contract_test.dart @@ -8,14 +8,13 @@ import 'package:http/testing.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/services/plex_client.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { late AppDatabase db; @@ -107,7 +106,7 @@ void main() { final manager = MultiServerManager(); manager.debugRegisterClientForTesting(plex); manager.debugRegisterJellyfinClientForTesting(jellyfin); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); + final provider = testMultiServerProvider(manager); addTearDown(() { provider.dispose(); manager.dispose(); diff --git a/test/screens/video_player/media_control_router_test.dart b/test/services/media_control_router_test.dart similarity index 94% rename from test/screens/video_player/media_control_router_test.dart rename to test/services/media_control_router_test.dart index 8d2f20fd..dd5bcffd 100644 --- a/test/screens/video_player/media_control_router_test.dart +++ b/test/services/media_control_router_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:os_media_controls/os_media_controls.dart'; -import 'package:plezy/screens/video_player/media_control_router.dart'; +import 'package:plezy/services/media_control_router.dart'; void main() { test('denied playback and media-item commands are consumed without mutation', () { @@ -47,12 +47,12 @@ void main() { }); } -VideoPlayerMediaControlRouter _router({ +MediaControlRouter _router({ required bool Function() canControl, required bool Function() canNavigate, required List calls, }) { - return VideoPlayerMediaControlRouter( + return MediaControlRouter( canControlPlayback: canControl, canNavigateMediaItems: canNavigate, onPlay: () => calls.add('play'), diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 55ae4f51..e85b14ef 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -123,27 +123,16 @@ void main() { expect(m.serverIds, isEmpty); expect(m.onlineServerIds, isEmpty); expect(m.offlineServerIds, isEmpty); - expect(m.plexServers, isEmpty); expect(m.onlineClients, isEmpty); }); - test('getClient/getPlexServer return null for unknown ids', () { + test('getClient returns null for unknown ids', () { final m = MultiServerManager(); addTearDown(m.dispose); expect(m.getClient(ServerId('nope')), isNull); - expect(m.getPlexServer(ServerId('nope')), isNull); expect(m.isServerOnline(ServerId('nope')), isFalse); }); - - test('plexServers map is unmodifiable', () { - final m = MultiServerManager(); - addTearDown(m.dispose); - - // Map.unmodifiable rejects every mutating operation — clear() is the - // simplest no-arg one to exercise the wrapper. - expect(() => m.plexServers.clear(), throwsUnsupportedError); - }); }); // ============================================================ diff --git a/test/services/music/music_playback_service_test.dart b/test/services/music/music_playback_service_test.dart index 26b563cd..e619a5f3 100644 --- a/test/services/music/music_playback_service_test.dart +++ b/test/services/music/music_playback_service_test.dart @@ -8,7 +8,6 @@ import 'package:plezy/media/media_display_criteria.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/mpv/models.dart'; import 'package:plezy/mpv/player/player.dart'; import 'package:plezy/mpv/player/player_state.dart'; @@ -20,6 +19,7 @@ import 'package:plezy/services/music/music_playback_service_impl.dart'; import 'package:plezy/services/music/music_source_resolver.dart'; import 'package:plezy/services/playback_coordinator.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/playback_report_fakes.dart'; const _trackDuration = Duration(minutes: 3); @@ -389,7 +389,7 @@ class RecordedReport { /// Records the progress-report surface; everything else is unimplemented /// (the engine and tracker never touch it in these tests). -class FakeMediaServerClient extends Fake implements MediaServerClient { +class FakeMediaServerClient extends Fake with PlaybackReportRecorder implements MediaServerClient { final List reports = []; final List markedWatched = []; Completer>? instantMixGate; @@ -419,47 +419,13 @@ class FakeMediaServerClient extends Fake implements MediaServerClient { } @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - reports.add(RecordedReport('started', itemId, position)); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - reports.add(RecordedReport(isPaused ? 'paused' : 'progress', itemId, position)); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - reports.add(RecordedReport('stopped', itemId, position)); + Future onPlaybackReport(PlaybackReportCall call) async { + final state = switch (call.kind) { + PlaybackReportKind.started => 'started', + PlaybackReportKind.progress => call.isPaused ? 'paused' : 'progress', + PlaybackReportKind.stopped => 'stopped', + }; + reports.add(RecordedReport(state, call.itemId, call.position)); } } diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 5e13f227..ee649b1b 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -22,6 +22,8 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/download_fixtures.dart'; +import '../test_helpers/playback_report_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; @@ -55,7 +57,7 @@ class _FakeOfflineModeSource extends ChangeNotifier implements OfflineModeSource bool get hasListeners => super.hasListeners; } -class _RecordingMediaClient implements MediaServerClient { +class _RecordingMediaClient with PlaybackReportRecorder implements MediaServerClient { _RecordingMediaClient({required this.serverId, required this.backend}); @override @@ -85,36 +87,24 @@ class _RecordingMediaClient implements MediaServerClient { testMediaItem(id: id, backend: backend, kind: MediaKind.movie, serverId: serverId); @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - started.add((itemId: itemId, positionMs: position.inMilliseconds, durationMs: duration?.inMilliseconds)); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - stopped.add(( - itemId: itemId, - positionMs: position.inMilliseconds, - durationMs: duration?.inMilliseconds, - report: report, - )); + Future onPlaybackReport(PlaybackReportCall call) async { + switch (call.kind) { + case PlaybackReportKind.started: + started.add(( + itemId: call.itemId, + positionMs: call.position.inMilliseconds, + durationMs: call.duration?.inMilliseconds, + )); + case PlaybackReportKind.progress: + throw UnimplementedError(); + case PlaybackReportKind.stopped: + stopped.add(( + itemId: call.itemId, + positionMs: call.position.inMilliseconds, + durationMs: call.duration?.inMilliseconds, + report: call.report, + )); + } } @override diff --git a/test/services/playback_initialization_offline_cache_test.dart b/test/services/playback_initialization_offline_cache_test.dart index 0af80d82..ba655aec 100644 --- a/test/services/playback_initialization_offline_cache_test.dart +++ b/test/services/playback_initialization_offline_cache_test.dart @@ -65,13 +65,15 @@ void main() { await PlexApiCache.instance.put(ServerId('srv-1'), '/library/metadata/movie-1', _plexMetadataEnvelope()); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -94,13 +96,15 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'track-1', - backend: MediaBackend.plex, - kind: MediaKind.track, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'track-1', + backend: MediaBackend.plex, + kind: MediaKind.track, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -121,13 +125,15 @@ void main() { final client = _FailingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -153,13 +159,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 1, ), - selectedMediaIndex: 1, preferOffline: true, ); @@ -186,13 +194,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, ); expect(result.isOffline, isTrue); @@ -213,14 +223,16 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', ), - selectedMediaIndex: 0, - selectedMediaSourceId: 'source-a', ); expect(result.isOffline, isTrue); @@ -243,14 +255,16 @@ void main() { final client = _StreamingPlaybackClient(serverId: ServerId('srv-1')); final result = await PlaybackInitializationService(client: client, database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, + selectedMediaSourceId: 'source-a', ), - selectedMediaIndex: 0, - selectedMediaSourceId: 'source-a', preferOffline: true, ); @@ -297,13 +311,15 @@ void main() { ); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'item-1', - backend: MediaBackend.jellyfin, - kind: MediaKind.movie, - serverId: ServerId('jf-machine'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'item-1', + backend: MediaBackend.jellyfin, + kind: MediaKind.movie, + serverId: ServerId('jf-machine'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); @@ -325,13 +341,15 @@ void main() { await subtitleFile.writeAsString('1\n00:00:00,000 --> 00:00:01,000\nHello'); final result = await PlaybackInitializationService(database: db).getPlaybackData( - metadata: testMediaItem( - id: 'movie-1', - backend: MediaBackend.plex, - kind: MediaKind.movie, - serverId: ServerId('srv-1'), + PlaybackInitializationOptions( + metadata: testMediaItem( + id: 'movie-1', + backend: MediaBackend.plex, + kind: MediaKind.movie, + serverId: ServerId('srv-1'), + ), + selectedMediaIndex: 0, ), - selectedMediaIndex: 0, preferOffline: true, ); diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 684dfd4e..565f8f9c 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/playback_report_fakes.dart'; // Periodic behavior is virtualized with fake_async and the tracker's existing // updateInterval seam. Routing, threshold, scrobble, cadence, coalescing, @@ -85,7 +86,7 @@ class _FakePlayer implements Player { /// Recording fake [PlexClient] that captures every progress / scrobble call /// without touching the network. -class _FakePlexClient implements PlexClient { +class _FakePlexClient with PlaybackReportRecorder implements PlexClient { _FakePlexClient({this.thresholdPercent = 90}); /// Watched-threshold percentage to report. Defaults to 90 (matches @@ -149,68 +150,25 @@ class _FakePlexClient implements PlexClient { // The interface report* methods delegate to updateProgress so existing // assertions on `updateProgressCalls` keep working. @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) { - playbackSessionIds.add(playSessionId); + Future onPlaybackReport(PlaybackReportCall call) { + playbackSessionIds.add(call.playSessionId); playbackStreamSelections.add(( - mediaSourceId: mediaSourceId, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, - )); - return updateProgress(itemId, time: position.inMilliseconds, state: 'playing', duration: duration?.inMilliseconds); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) { - playbackSessionIds.add(playSessionId); - playbackStreamSelections.add(( - mediaSourceId: mediaSourceId, - audioStreamIndex: audioStreamIndex, - subtitleStreamIndex: subtitleStreamIndex, + mediaSourceId: call.mediaSourceId, + audioStreamIndex: call.audioStreamIndex, + subtitleStreamIndex: call.subtitleStreamIndex, )); return updateProgress( - itemId, - time: position.inMilliseconds, - state: isPaused ? 'paused' : 'playing', - duration: duration.inMilliseconds, + call.itemId, + time: call.position.inMilliseconds, + state: switch (call.kind) { + PlaybackReportKind.started => 'playing', + PlaybackReportKind.progress => call.isPaused ? 'paused' : 'playing', + PlaybackReportKind.stopped => 'stopped', + }, + duration: call.duration?.inMilliseconds, ); } - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) { - playbackSessionIds.add(playSessionId); - playbackStreamSelections.add((mediaSourceId: mediaSourceId, audioStreamIndex: null, subtitleStreamIndex: null)); - return updateProgress(itemId, time: position.inMilliseconds, state: 'stopped', duration: duration?.inMilliseconds); - } - // Transport-only, like production: the single watch event for the stop // flow is emitted by markWatchedFromPlaybackStop after this returns. @override @@ -1431,7 +1389,7 @@ void main() { /// A more precise fake than [_FakePlexClient]: lets the test independently /// fail the scrobble (markWatched) without touching the progress signals. -class _ScrobblePreciseClient implements PlexClient { +class _ScrobblePreciseClient with PlaybackReportRecorder implements PlexClient { _ScrobblePreciseClient({this.thresholdPercent = 90, this.failScrobbleFirstTime = false}); final int thresholdPercent; @@ -1472,42 +1430,7 @@ class _ScrobblePreciseClient implements PlexClient { }) async {} @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async {} - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async {} - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async {} + Future onPlaybackReport(PlaybackReportCall call) async {} @override Future markWatched(MediaItem item) async { diff --git a/test/services/playback_report_session_test.dart b/test/services/playback_report_session_test.dart index 848fa435..f538a28d 100644 --- a/test/services/playback_report_session_test.dart +++ b/test/services/playback_report_session_test.dart @@ -2,66 +2,36 @@ import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_server_client.dart'; -import 'package:plezy/media/playback_report_metadata.dart'; import 'package:plezy/services/playback_report_session.dart'; -class _RecordingClient implements MediaServerClient { +import '../test_helpers/playback_report_fakes.dart'; + +class _RecordingClient with PlaybackReportRecorder implements MediaServerClient { final calls = []; Completer? startGate; Completer? stopGate; bool failNextStop = false; @override - Future reportPlaybackStarted({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final gate = startGate; - if (gate != null) await gate.future; - calls.add('started:${position.inMilliseconds}:$mediaSourceId:$audioStreamIndex:$subtitleStreamIndex'); - } - - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - calls.add('${isPaused ? 'paused' : 'playing'}:${position.inMilliseconds}'); - } - - @override - Future reportPlaybackStopped({ - required String itemId, - required Duration position, - Duration? duration, - String? playSessionId, - String? liveStreamId, - String? mediaSourceId, - PlaybackReportMetadata report = const PlaybackReportMetadata.live(), - }) async { - calls.add('stopped-attempt:${position.inMilliseconds}:$mediaSourceId'); - final gate = stopGate; - if (gate != null) await gate.future; - if (failNextStop) { - failNextStop = false; - throw StateError('stop failed'); + Future onPlaybackReport(PlaybackReportCall call) async { + final positionMs = call.position.inMilliseconds; + switch (call.kind) { + case PlaybackReportKind.started: + final start = startGate; + if (start != null) await start.future; + calls.add('started:$positionMs:${call.mediaSourceId}:${call.audioStreamIndex}:${call.subtitleStreamIndex}'); + case PlaybackReportKind.progress: + calls.add('${call.isPaused ? 'paused' : 'playing'}:$positionMs'); + case PlaybackReportKind.stopped: + calls.add('stopped-attempt:$positionMs:${call.mediaSourceId}'); + final stop = stopGate; + if (stop != null) await stop.future; + if (failNextStop) { + failNextStop = false; + throw StateError('stop failed'); + } + calls.add('stopped:$positionMs:${call.mediaSourceId}'); } - calls.add('stopped:${position.inMilliseconds}:$mediaSourceId'); } @override diff --git a/test/services/playback_source_resolver_test.dart b/test/services/playback_source_resolver_test.dart index c316ae00..5fa9fd99 100644 --- a/test/services/playback_source_resolver_test.dart +++ b/test/services/playback_source_resolver_test.dart @@ -57,10 +57,12 @@ void main() { manager.debugRegisterClientForTesting(client, online: false); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, ); expect(context.result.videoUrl, 'https://example.com/video.mp4'); @@ -80,11 +82,13 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.plex, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + sessionIdentifier: 'playback-session-id', + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, - sessionIdentifier: 'playback-session-id', ); expect(context.sourceKind, PlaybackSourceKind.remoteDirect); @@ -104,11 +108,13 @@ void main() { manager.debugRegisterClientForTesting(client, online: true); final context = await PlaybackSourceResolver(serverManager: manager, database: db).resolve( - metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), - selectedMediaIndex: 0, + PlaybackInitializationOptions( + metadata: testMediaItem(id: 'item-1', backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'), + selectedMediaIndex: 0, + qualityPreset: TranscodeQualityPreset.original, + sessionIdentifier: 'playback-session-id', + ), offlineLibraryMode: false, - qualityPreset: TranscodeQualityPreset.original, - sessionIdentifier: 'playback-session-id', ); expect(context.sourceKind, PlaybackSourceKind.remoteDirect); diff --git a/test/services/playback_subtitle_resolver_test.dart b/test/services/playback_subtitle_resolver_test.dart index f98eb4e6..72afe30e 100644 --- a/test/services/playback_subtitle_resolver_test.dart +++ b/test/services/playback_subtitle_resolver_test.dart @@ -113,15 +113,15 @@ void main() { final tracks = [_sourceSubtitle(0), _sourceSubtitle(2)]; expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off()), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.off(), 1), const PlaybackSourceSubtitleChoice.source(0), ); expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0)), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(0), 1), const PlaybackSourceSubtitleChoice.source(2), ); expect( - PlaybackSubtitleResolver.nextSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2)), + PlaybackSubtitleResolver.advanceSourceChoice(tracks, const PlaybackSourceSubtitleChoice.source(2), 1), const PlaybackSourceSubtitleChoice.off(), ); expect( diff --git a/test/services/plex_auth_service_test.dart b/test/services/plex_auth_service_test.dart index ea1dd78c..61de93eb 100644 --- a/test/services/plex_auth_service_test.dart +++ b/test/services/plex_auth_service_test.dart @@ -73,11 +73,9 @@ void main() { addTearDown(client.close); final auth = PlexAuthService.forTesting(http: client); - final response = await auth.switchToUser('uuid-1', 'account-token'); + final token = await auth.switchToUser('uuid-1', 'account-token'); - expect(response.authToken, 'minted-user-token'); - expect(response.profile.defaultAudioLanguages, ['en', 'sv']); - expect(response.profile.defaultSubtitleLanguages, ['en', 'sv']); + expect(token, 'minted-user-token'); }); test('fetchServers tolerates scalar drift in server and connection fields', () async { diff --git a/test/services/plex_live_tv_support_test.dart b/test/services/plex_live_tv_support_test.dart index 370d3078..cea40263 100644 --- a/test/services/plex_live_tv_support_test.dart +++ b/test/services/plex_live_tv_support_test.dart @@ -51,8 +51,8 @@ void main() { ); } - http.Response jsonResponse(Map body, {Map? headers}) { - return http.Response(jsonEncode(body), 200, headers: {'content-type': 'application/json', ...?headers}); + http.Response jsonResponse(Map body) { + return http.Response(jsonEncode(body), 200, headers: const {'content-type': 'application/json'}); } test('favorite source follows requested lineup provider', () async { @@ -155,37 +155,6 @@ void main() { expect(dvrs.single.channelMappings.single.enabled, isTrue); }); - test('createDvr sends repeated device and lineup query params and exposes activity id', () async { - late http.Request captured; - final client = makeClient((request) async { - captured = request; - return jsonResponse( - { - 'MediaContainer': { - 'Dvr': [ - {'key': '42', 'uuid': 'dvr-42'}, - ], - }, - }, - headers: {'x-plex-activity': 'activity-1'}, - ); - }); - addTearDown(client.close); - - final result = await client.liveTvDvr!.createDvr( - devices: const ['dev-a', 'dev-b'], - lineups: const ['lineup-a', 'lineup-b'], - language: 'eng', - ); - - expect(captured.url.path, '/livetv/dvrs'); - expect(captured.url.queryParametersAll['device'], ['dev-a', 'dev-b']); - expect(captured.url.queryParametersAll['lineup'], ['lineup-a', 'lineup-b']); - expect(captured.url.queryParameters['language'], 'eng'); - expect(result.activityUuid, 'activity-1'); - expect(result.value?.key, '42'); - }); - test('subscription template parses settings and URL-encoded enum labels', () async { final client = makeClient((request) async { expect(request.url.path, '/media/subscriptions/template'); diff --git a/test/services/shader_asset_loader_test.dart b/test/services/shader_asset_loader_test.dart index c7a66e21..e11b0208 100644 --- a/test/services/shader_asset_loader_test.dart +++ b/test/services/shader_asset_loader_test.dart @@ -136,6 +136,21 @@ void main() { } }); + test('repeats the restore pass in place for doubled Anime4K modes', () async { + final shaders = await ShaderAssetLoader.getAnime4KShaders( + const Anime4KConfig(quality: Anime4KQuality.fast, mode: Anime4KMode.modeBB), + ); + + expect(shaders.map(path.basename).toList(), [ + 'Anime4K_Clamp_Highlights.glsl', + 'Anime4K_Restore_CNN_M.glsl', + 'Anime4K_Restore_CNN_M.glsl', + 'Anime4K_Upscale_CNN_x2_M.glsl', + 'Anime4K_AutoDownscalePre_x2.glsl', + ]); + expect(shaders[1], shaders[2]); + }); + test('nested and non-GLSL names are rejected without touching matching files', () async { final customDirectory = Directory(path.join(supportDirectory.path, 'custom_shaders'))..createSync(recursive: true); final nested = File(path.join(customDirectory.path, 'subdir', 'name.glsl')) diff --git a/test/services/sync_rule_executor_test.dart b/test/services/sync_rule_executor_test.dart index 18e44edf..a2b98171 100644 --- a/test/services/sync_rule_executor_test.dart +++ b/test/services/sync_rule_executor_test.dart @@ -5,6 +5,7 @@ import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/episode_collection.dart'; import 'package:plezy/media/library_query.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; @@ -497,11 +498,7 @@ void main() { expect((await db.getSyncRule(ruleKey))!.downloadLinksInitialized, isTrue); }); - test('collectItemsForList accepts tracks and expands albums/artists', () async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - addTearDown(db.close); - final executor = SyncRuleExecutor(database: db); - + test('collectListLeaves accepts tracks and expands albums/artists', () async { final albumTracks = [_track('album-track-1'), _track('album-track-2', played: true)]; final client = _PlayableDescendantsClient(albumTracks); @@ -514,14 +511,14 @@ void main() { ]; final out = []; - await executor.collectItemsForList(client, items, unwatchedOnly: false, out: out); + await collectListLeaves(client, items, unwatchedOnly: false, out: out); expect(client.fetchPlayableDescendantsCalls, ['album-1', 'artist-1']); expect(out.map((i) => i.id), ['loose-track', 'album-track-1', 'album-track-2', 'album-track-1', 'album-track-2']); // unwatchedOnly applies the play-count filter to tracks too. final unwatched = []; - await executor.collectItemsForList( + await collectListLeaves( client, [_track('played-track', played: true), items[1]], unwatchedOnly: true, diff --git a/test/services/track_selection_container_ordinal_test.dart b/test/services/track_selection_container_ordinal_test.dart new file mode 100644 index 00000000..b639db2b --- /dev/null +++ b/test/services/track_selection_container_ordinal_test.dart @@ -0,0 +1,36 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_source_info.dart'; +import 'package:plezy/mpv/mpv.dart'; +import 'package:plezy/services/track_selection_service.dart'; + +// The container-ordinal guards in `findMpvTrackForPlexSubtitle` and +// `findPlexTrackForMpvSubtitle` look like mirrors but are not: when the probe +// has no ordinal in the container list, the Plex->MPV direction still scores by +// metadata while the MPV->Plex direction refuses to match at all. These tests +// pin that difference so the two guards are not "symmetrised". + +MediaSubtitleTrack _plexSub(int id, {int? index, String? languageCode}) => + MediaSubtitleTrack(id: id, index: index, languageCode: languageCode, selected: false, forced: false); + +SubtitleTrack _containerSub(String id, {String? lang}) => + SubtitleTrack(id: id, language: lang, isExternal: true, isContainer: true); + +void main() { + group('container-ordinal guard asymmetry', () { + test('Plex->MPV keeps metadata scoring when the Plex stream has no container ordinal', () { + final probe = _plexSub(40, index: 0, languageCode: 'eng'); + final otherPlexTracks = [_plexSub(41, index: 1, languageCode: 'eng')]; + final nativeTracks = [_containerSub('2_0', lang: 'eng')]; + + expect(findMpvTrackForPlexSubtitle(probe, nativeTracks, allPlexTracks: otherPlexTracks), nativeTracks.first); + }); + + test('MPV->Plex refuses to match when the container track has no ordinal', () { + final probe = _containerSub('2_0', lang: 'eng'); + final otherNativeTracks = [_containerSub('2_1', lang: 'eng')]; + final plexTracks = [_plexSub(40, index: 0, languageCode: 'eng')]; + + expect(findPlexTrackForMpvSubtitle(probe, plexTracks, allMpvTracks: otherNativeTracks), isNull); + }); + }); +} diff --git a/test/services/trackers/future_coalescer_test.dart b/test/services/trackers/future_coalescer_test.dart index b63b7c3f..f8db9996 100644 --- a/test/services/trackers/future_coalescer_test.dart +++ b/test/services/trackers/future_coalescer_test.dart @@ -34,4 +34,26 @@ void main() { expect(first, 1); expect(second, 2); }); + + test('KeyedFutureCache memoizes successes and evicts failures', () async { + final cache = KeyedFutureCache(); + final errors = []; + var calls = 0; + + Future create({required bool fail}) async { + calls++; + if (fail) throw StateError('boom'); + return calls; + } + + await expectLater(cache.run('a', () => create(fail: true), onError: errors.add), throwsStateError); + expect(errors, hasLength(1)); + + expect(await cache.run('a', () => create(fail: false)), 2); + expect(await cache.run('a', () => create(fail: false)), 2); + expect(calls, 2); + + cache.clear(); + expect(await cache.run('a', () => create(fail: false)), 3); + }); } diff --git a/test/services/trackers/tracker_status_ladder_test.dart b/test/services/trackers/tracker_status_ladder_test.dart new file mode 100644 index 00000000..28b81a9c --- /dev/null +++ b/test/services/trackers/tracker_status_ladder_test.dart @@ -0,0 +1,106 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:plezy/models/trakt/trakt_ids.dart'; +import 'package:plezy/models/trakt/trakt_scrobble_request.dart'; +import 'package:plezy/services/trackers/simkl/simkl_client.dart'; +import 'package:plezy/services/trackers/simkl/simkl_constants.dart'; +import 'package:plezy/services/trackers/tracker_exceptions.dart'; +import 'package:plezy/services/trackers/tracker_session.dart'; +import 'package:plezy/services/trakt/trakt_client.dart'; + +TrackerSession _session({String refreshToken = 'refresh-old'}) { + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return TrackerSession( + accessToken: 'access-old', + refreshToken: refreshToken, + expiresAt: now + 86400, + createdAt: now, + username: 'alice', + ); +} + +const _scrobble = TraktScrobbleRequest.movie(ids: TraktIds(trakt: 1)); + +void main() { + group('Trakt status ladder', () { + test('accepts 409 on scrobble but not on other requests', () async { + final client = TraktClient( + _session(), + onSessionInvalidated: () => fail('409 should not invalidate the session'), + httpClient: MockClient((_) async => http.Response('conflict', 409)), + ); + addTearDown(client.dispose); + + await client.scrobbleStart(_scrobble); + + await expectLater( + client.getUserSettings(), + throwsA(isA().having((e) => e.statusCode, 'statusCode', 409)), + ); + }); + + test('propagates the refresh TrackerAuthException after a 401', () async { + var invalidated = 0; + final client = TraktClient( + _session(refreshToken: 'refresh-ladder'), + onSessionInvalidated: () => invalidated++, + httpClient: MockClient((request) async { + if (request.url.path == '/oauth/token') { + return http.Response(json.encode({'error': 'invalid_grant'}), 400); + } + return http.Response('unauthorized', 401); + }), + ); + addTearDown(client.dispose); + + await expectLater( + client.getUserSettings(), + throwsA(isA().having((e) => e.isPermanent, 'isPermanent', isTrue)), + ); + expect(invalidated, 1); + }); + }); + + group('Simkl status ladder', () { + test('only the authenticated host invalidates on 401', () async { + var invalidated = 0; + final client = SimklClient( + _session(), + onSessionInvalidated: () => invalidated++, + httpClient: MockClient((_) async => http.Response('unauthorized', 401)), + ); + addTearDown(client.dispose); + + await expectLater(client.getTrending(SimklCatalogType.tv), throwsA(isA())); + expect(invalidated, 0); + + await expectLater( + client.getUserSettings(), + throwsA(isA().having((e) => e.isPermanent, 'isPermanent', isTrue)), + ); + expect(invalidated, 1); + }); + + test('surfaces 429 as a plain API failure', () async { + final client = SimklClient( + _session(), + onSessionInvalidated: () => fail('429 should not invalidate the session'), + httpClient: MockClient((_) async => http.Response('slow down', 429, headers: {'retry-after': '23'})), + ); + addTearDown(client.dispose); + + await expectLater( + client.getUserSettings(), + throwsA( + allOf( + isA().having((e) => e.statusCode, 'statusCode', 429), + isNot(isA()), + ), + ), + ); + }); + }); +} diff --git a/test/services/video_filter_manager_test.dart b/test/services/video_filter_manager_test.dart index bc4e5379..d861a52b 100644 --- a/test/services/video_filter_manager_test.dart +++ b/test/services/video_filter_manager_test.dart @@ -13,7 +13,7 @@ void main() { expect(manager.setZoomScale(1.234), 1.23); expect(manager.zoomScale, 1.23); - expect(manager.adjustZoom(VideoFilterManager.zoomStep), 1.24); + expect(manager.setZoomScale(manager.zoomScale + VideoFilterManager.zoomStep), 1.24); expect(manager.zoomScale, 1.24); }); @@ -26,7 +26,7 @@ void main() { expect(manager.setZoomScale(1.00008), 1.0); expect(manager.zoomScale, 1.0); - expect(manager.resetZoom(), 1.0); + expect(manager.setZoomScale(1.0), 1.0); }); test('video zoom property is exact zero at normalized default', () async { diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index c04c2d1d..a6f24d63 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -13,6 +13,7 @@ import 'package:plezy/media/ids.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/services/base_shared_preferences_service.dart'; +import 'test_helpers/download_fixtures.dart'; import 'test_helpers/prefs.dart'; final class _OpenTrackingInterceptor extends QueryInterceptor { diff --git a/test/test_helpers/download_fixtures.dart b/test/test_helpers/download_fixtures.dart new file mode 100644 index 00000000..f880ee19 --- /dev/null +++ b/test/test_helpers/download_fixtures.dart @@ -0,0 +1,70 @@ +import 'package:drift/drift.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/ids.dart'; + +/// Seeds `downloaded_media` rows at an arbitrary [status] so tests can start +/// from completed, downloading, or paused state. +/// +/// Production never writes rows this way — it goes through +/// `insertQueuedDownload`, which only ever admits `queued` rows and guards on +/// the existing status. This fixture deliberately keeps neither restriction, +/// which is why it lives in `test/` instead of `lib/`. +extension DownloadFixtures on AppDatabase { + Future insertDownload({ + required ServerId serverId, + String? clientScopeId, + required String ratingKey, + required String globalKey, + required String type, + String? parentRatingKey, + String? grandparentRatingKey, + required int status, + int mediaIndex = 0, + String? mediaSourceId, + }) async { + await customUpdate( + ''' + INSERT INTO downloaded_media ( + server_id, + client_scope_id, + rating_key, + global_key, + type, + parent_rating_key, + grandparent_rating_key, + status, + media_index, + media_source_id + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(global_key) DO UPDATE SET + server_id = excluded.server_id, + client_scope_id = excluded.client_scope_id, + rating_key = excluded.rating_key, + type = excluded.type, + parent_rating_key = excluded.parent_rating_key, + grandparent_rating_key = excluded.grandparent_rating_key, + status = excluded.status, + progress = 0, + total_bytes = NULL, + downloaded_bytes = 0, + error_message = NULL, + retry_count = 0, + media_index = excluded.media_index, + media_source_id = excluded.media_source_id + ''', + variables: [ + Variable(serverId), + Variable(clientScopeId), + Variable(ratingKey), + Variable(globalKey), + Variable(type), + Variable(parentRatingKey), + Variable(grandparentRatingKey), + Variable(status), + Variable(mediaIndex), + Variable(mediaSourceId), + ], + updates: {downloadedMedia}, + ); + } +} diff --git a/test/test_helpers/download_fixtures_test.dart b/test/test_helpers/download_fixtures_test.dart new file mode 100644 index 00000000..cc6bc344 --- /dev/null +++ b/test/test_helpers/download_fixtures_test.dart @@ -0,0 +1,126 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/models/download_models.dart'; + +import 'download_fixtures.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + group('insertDownload', () { + test('inserts a movie row with defaults', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + ); + + final rows = await db.select(db.downloadedMedia).get(); + expect(rows, hasLength(1)); + final r = rows.first; + expect(r.serverId, 'srv'); + expect(r.ratingKey, '100'); + expect(r.globalKey, 'srv:100'); + expect(r.type, 'movie'); + expect(r.status, DownloadStatus.queued.index); + expect(r.parentRatingKey, isNull); + expect(r.grandparentRatingKey, isNull); + expect(r.mediaIndex, 0); + }); + + test('inserts an episode with parent and grandparent keys', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'ep1', + globalKey: 'srv:ep1', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.queued.index, + mediaIndex: 7, + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.parentRatingKey, 'season1'); + expect(row.grandparentRatingKey, 'show1'); + expect(row.mediaIndex, 7); + }); + + test('atomically updates metadata and attempt state while preserving the row and physical fields', () async { + await db.insertDownload( + serverId: ServerId('srv'), + clientScopeId: 'scope-old', + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + mediaIndex: 1, + mediaSourceId: 'source-old', + ); + final original = (await db.getDownloadedMedia('srv:100'))!; + await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write( + const DownloadedMediaCompanion( + progress: Value(50), + downloadedBytes: Value(500), + totalBytes: Value(1000), + videoFilePath: Value('downloads/video.mkv'), + safRootUri: Value('content://downloads'), + thumbPath: Value('downloads/thumb.jpg'), + downloadedAt: Value(1234), + errorMessage: Value('old error'), + retryCount: Value(2), + bgTaskId: Value('current-task'), + ), + ); + + await db.insertDownload( + serverId: ServerId('srv-new'), + clientScopeId: 'scope-new', + ratingKey: '100-new', + globalKey: 'srv:100', + type: 'episode', + parentRatingKey: 'season-new', + grandparentRatingKey: 'show-new', + status: DownloadStatus.failed.index, + mediaIndex: 3, + mediaSourceId: 'source-new', + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.id, original.id); + expect(row.serverId, 'srv-new'); + expect(row.clientScopeId, 'scope-new'); + expect(row.ratingKey, '100-new'); + expect(row.type, 'episode'); + expect(row.parentRatingKey, 'season-new'); + expect(row.grandparentRatingKey, 'show-new'); + expect(row.status, DownloadStatus.failed.index); + expect(row.mediaIndex, 3); + expect(row.mediaSourceId, 'source-new'); + expect(row.progress, 0); + expect(row.downloadedBytes, 0); + expect(row.totalBytes, isNull); + expect(row.errorMessage, isNull); + expect(row.retryCount, 0); + expect(row.videoFilePath, 'downloads/video.mkv'); + expect(row.safRootUri, 'content://downloads'); + expect(row.thumbPath, 'downloads/thumb.jpg'); + expect(row.downloadedAt, 1234); + expect(row.bgTaskId, 'current-task'); + }); + }); +} diff --git a/test/test_helpers/http_fixtures.dart b/test/test_helpers/http_fixtures.dart new file mode 100644 index 00000000..a9d7d3ce --- /dev/null +++ b/test/test_helpers/http_fixtures.dart @@ -0,0 +1,7 @@ +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// JSON-encodes [body] into an [http.Response] carrying a JSON content type. +http.Response jsonResponse(Object body, {int status = 200}) => + http.Response(jsonEncode(body), status, headers: const {'content-type': 'application/json'}); diff --git a/test/test_helpers/library_tab_scaffold.dart b/test/test_helpers/library_tab_scaffold.dart new file mode 100644 index 00000000..9f58419a --- /dev/null +++ b/test/test_helpers/library_tab_scaffold.dart @@ -0,0 +1,62 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/input_mode_tracker.dart'; +import 'package:plezy/navigation/main_screen_scope.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/theme/mono_theme.dart'; +import 'package:provider/provider.dart'; + +/// Pumps [tab] under the ancestors every library tab requires: [provider], an +/// [InputModeTracker], a [MainScreenFocusScope] and a [NestedScrollView] whose +/// overlap absorber handle the tabs look up. Sizes the view to [size] and +/// restores it when the test ends. Settling is left to the caller. +Future pumpLibraryTab( + WidgetTester tester, { + required MultiServerProvider provider, + required Widget tab, + Size size = const Size(1280, 720), + VoidCallback? focusSidebar, +}) async { + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = size; + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: provider, + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: MainScreenFocusScope( + focusSidebar: focusSidebar ?? () {}, + focusContent: () {}, + isSidebarFocused: false, + sideNavigationWidth: 0, + child: Scaffold( + body: NestedScrollView( + headerSliverBuilder: (context, _) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: const SliverToBoxAdapter(child: SizedBox(height: 1)), + ), + ], + body: tab, + ), + ), + ), + ), + ), + ), + ); +} + +/// Frames a library tab needs to issue its debounced request and apply the +/// response, for tabs whose loading never quiesces enough for `pumpAndSettle`. +Future pumpRequestFrames(WidgetTester tester) async { + await tester.pump(); + await tester.pump(const Duration(milliseconds: 100)); + await tester.pump(const Duration(milliseconds: 500)); +} diff --git a/test/test_helpers/multi_server_fixtures.dart b/test/test_helpers/multi_server_fixtures.dart new file mode 100644 index 00000000..bfbf16bf --- /dev/null +++ b/test/test_helpers/multi_server_fixtures.dart @@ -0,0 +1,30 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_server_client.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/multi_server_manager.dart'; + +/// Wires [manager] into the provider widget tests read servers from. The caller +/// owns disposal of the returned provider. +MultiServerProvider testMultiServerProvider(MultiServerManager manager) { + return MultiServerProvider(manager, DataAggregationService(manager)); +} + +/// Registers [clients] on a fresh manager and returns it with its provider, +/// disposing both when the test ends. Clients also named in [offline] are +/// registered as unreachable; [clients] fixes the registration order. +({MultiServerManager manager, MultiServerProvider provider}) testMultiServer({ + List clients = const [], + List offline = const [], +}) { + final manager = MultiServerManager(); + for (final client in clients) { + manager.debugRegisterClientForTesting(client, online: !offline.contains(client)); + } + final provider = testMultiServerProvider(manager); + addTearDown(() { + provider.dispose(); + manager.dispose(); + }); + return (manager: manager, provider: provider); +} diff --git a/test/test_helpers/playback_report_fakes.dart b/test/test_helpers/playback_report_fakes.dart new file mode 100644 index 00000000..5c89d80d --- /dev/null +++ b/test/test_helpers/playback_report_fakes.dart @@ -0,0 +1,120 @@ +import 'package:plezy/media/playback_report_metadata.dart'; + +enum PlaybackReportKind { started, progress, stopped } + +/// One `reportPlayback*` invocation flattened into a single value. +class PlaybackReportCall { + const PlaybackReportCall({ + required this.kind, + required this.itemId, + required this.position, + this.duration, + this.isPaused = false, + this.playSessionId, + this.playMethod, + this.liveStreamId, + this.mediaSourceId, + this.audioStreamIndex, + this.subtitleStreamIndex, + this.report = const PlaybackReportMetadata.live(), + }); + + final PlaybackReportKind kind; + final String itemId; + final Duration position; + final Duration? duration; + final bool isPaused; + final String? playSessionId; + final String? playMethod; + final String? liveStreamId; + final String? mediaSourceId; + final int? audioStreamIndex; + final int? subtitleStreamIndex; + final PlaybackReportMetadata report; +} + +/// Carries the three playback-reporting signatures so fakes implement the +/// surface once in [onPlaybackReport]. Forwarding is synchronous, so +/// [onPlaybackReport] runs with the same timing the overridden method had. +mixin PlaybackReportRecorder { + Future onPlaybackReport(PlaybackReportCall call); + + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.started, + itemId: itemId, + position: position, + duration: duration, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ), + ); + } + + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.progress, + itemId: itemId, + position: position, + duration: duration, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ), + ); + } + + Future reportPlaybackStopped({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? liveStreamId, + String? mediaSourceId, + PlaybackReportMetadata report = const PlaybackReportMetadata.live(), + }) { + return onPlaybackReport( + PlaybackReportCall( + kind: PlaybackReportKind.stopped, + itemId: itemId, + position: position, + duration: duration, + playSessionId: playSessionId, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + report: report, + ), + ); + } +} diff --git a/test/test_helpers/profile_stack.dart b/test/test_helpers/profile_stack.dart new file mode 100644 index 00000000..b7d7ecca --- /dev/null +++ b/test/test_helpers/profile_stack.dart @@ -0,0 +1,74 @@ +import 'package:drift/native.dart'; +import 'package:plezy/connection/connection_registry.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/models/plex/plex_home_user.dart'; +import 'package:plezy/profiles/active_profile_provider.dart'; +import 'package:plezy/profiles/plex_home_service.dart'; +import 'package:plezy/profiles/profile_connection_registry.dart'; +import 'package:plezy/profiles/profile_registry.dart'; +import 'package:plezy/services/storage_service.dart'; + +/// The profile dependency graph wired the way production wires it: database → +/// registries → [PlexHomeService] → [ActiveProfileProvider]. +class ProfileStack { + ProfileStack._({ + required this.db, + required this.connections, + required this.profileConnections, + required this.profiles, + required this.plexHome, + required this.active, + required this._storage, + required this._ownsDatabase, + }); + + final AppDatabase db; + final ConnectionRegistry connections; + final ProfileConnectionRegistry profileConnections; + final ProfileRegistry profiles; + final PlexHomeService plexHome; + final ActiveProfileProvider active; + + final StorageService? _storage; + final bool _ownsDatabase; + + /// Only wired when the stack was created with `withStorage: true`. + StorageService get storage => _storage!; + + /// Pass [db] when the test also needs the database for caches or downloads; + /// the caller then owns closing it. + static Future create({ + AppDatabase? db, + List homeUsers = const [], + bool withStorage = true, + }) async { + final database = db ?? AppDatabase.forTesting(NativeDatabase.memory()); + final connections = ConnectionRegistry(database); + final profileConnections = ProfileConnectionRegistry(database); + final profiles = ProfileRegistry(database); + final storage = withStorage ? await StorageService.getInstance() : null; + final plexHome = PlexHomeService( + connections: connections, + profileConnections: profileConnections, + storage: storage, + plexHomeUserFetcher: (_) async => homeUsers, + ); + return ProfileStack._( + db: database, + connections: connections, + profileConnections: profileConnections, + profiles: profiles, + plexHome: plexHome, + active: ActiveProfileProvider(registry: profiles, plexHome: plexHome, connections: connections, storage: storage), + storage: storage, + ownsDatabase: db == null, + ); + } + + Future dispose() async { + await active.resetForTesting(); + active.dispose(); + await plexHome.dispose(); + if (_ownsDatabase) await db.close(); + } +} diff --git a/test/test_helpers/theme.dart b/test/test_helpers/theme.dart new file mode 100644 index 00000000..170671b3 --- /dev/null +++ b/test/test_helpers/theme.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:plezy/theme/mono_tokens.dart'; + +/// Default [MonoTokens] for widget tests: production-shaped metrics with +/// 1ms animations and no ink splashes, so a single `pump` settles the tree. +/// +/// Use as `ThemeData(extensions: const [testMonoTokens])`. +const testMonoTokens = MonoTokens( + radiusSm: 8, + radiusMd: 12, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, + space: 8, + fast: Duration(milliseconds: 1), + normal: Duration(milliseconds: 1), + slow: Duration(milliseconds: 1), + expressive: Duration(milliseconds: 1), + bg: Colors.black, + surface: Colors.black, + outline: Colors.white24, + text: Colors.white, + textMuted: Colors.white70, + splashFactory: NoSplash.splashFactory, +); + +/// [testMonoTokens] with realistic animation durations, for tests that step +/// through intermediate frames instead of settling straight to the end state. +const testMonoTokensAnimated = MonoTokens( + radiusSm: 4, + radiusMd: 8, + radiusLg: 20, + radiusXs: 5, + groupGap: 2, + space: 8, + fast: Duration(milliseconds: 100), + normal: Duration(milliseconds: 200), + slow: Duration(milliseconds: 300), + expressive: Duration(milliseconds: 300), + bg: Colors.black, + surface: Color(0xFF111111), + outline: Color(0xFF333333), + text: Colors.white, + textMuted: Color(0xFFAAAAAA), + splashFactory: NoSplash.splashFactory, +); diff --git a/test/utils/content_utils_test.dart b/test/utils/content_utils_test.dart index 49c9256c..f64d9592 100644 --- a/test/utils/content_utils_test.dart +++ b/test/utils/content_utils_test.dart @@ -95,10 +95,6 @@ void main() { expect(ContentTypeHelper.isVideoContent('artist'), isFalse); }); - test('isMusicLibrary returns false for null and non-matching types', () { - expect(ContentTypeHelper.isMusicLibrary(null), isFalse); - }); - test('getLibraryIcon normalizes type and falls back to folder', () { expect(ContentTypeHelper.getLibraryIcon('MOVIE'), Symbols.movie_rounded); expect(ContentTypeHelper.getLibraryIcon('show'), Symbols.tv_rounded); diff --git a/test/utils/layout_constants_test.dart b/test/utils/layout_constants_test.dart index 0bdfde51..4d4822ac 100644 --- a/test/utils/layout_constants_test.dart +++ b/test/utils/layout_constants_test.dart @@ -18,13 +18,6 @@ void main() { expect(ScreenBreakpoints.isTablet(1200), isFalse); }); - test('isWideTablet: 900 ≤ w < 1200', () { - expect(ScreenBreakpoints.isWideTablet(899.9), isFalse); - expect(ScreenBreakpoints.isWideTablet(900), isTrue); - expect(ScreenBreakpoints.isWideTablet(1199.9), isTrue); - expect(ScreenBreakpoints.isWideTablet(1200), isFalse); - }); - test('isDesktop: 1200 ≤ w < 1600', () { expect(ScreenBreakpoints.isDesktop(1199.9), isFalse); expect(ScreenBreakpoints.isDesktop(1200), isTrue); @@ -32,12 +25,6 @@ void main() { expect(ScreenBreakpoints.isDesktop(1600), isFalse); }); - test('isLargeDesktop: w ≥ 1600', () { - expect(ScreenBreakpoints.isLargeDesktop(1599.9), isFalse); - expect(ScreenBreakpoints.isLargeDesktop(1600), isTrue); - expect(ScreenBreakpoints.isLargeDesktop(10000), isTrue); - }); - test('isDesktopOrLarger: w ≥ 1200', () { expect(ScreenBreakpoints.isDesktopOrLarger(1199.9), isFalse); expect(ScreenBreakpoints.isDesktopOrLarger(1200), isTrue); diff --git a/test/utils/live_tv_player_navigation_test.dart b/test/utils/live_tv_player_navigation_test.dart index 130d6233..5ccd4f11 100644 --- a/test/utils/live_tv_player_navigation_test.dart +++ b/test/utils/live_tv_player_navigation_test.dart @@ -8,10 +8,11 @@ import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/screens/video_player_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/utils/live_tv_player_navigation.dart'; +import '../test_helpers/multi_server_fixtures.dart'; + void main() { late MultiServerManager manager; late MultiServerProvider multiServer; @@ -19,7 +20,7 @@ void main() { setUp(() async { await LocaleSettings.setLocale(AppLocale.bg); manager = MultiServerManager(); - multiServer = MultiServerProvider(manager, DataAggregationService(manager)); + multiServer = testMultiServerProvider(manager); }); tearDown(() { diff --git a/test/utils/media_server_http_client_shutdown_test.dart b/test/utils/media_server_http_client_shutdown_test.dart new file mode 100644 index 00000000..77387f04 --- /dev/null +++ b/test/utils/media_server_http_client_shutdown_test.dart @@ -0,0 +1,83 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:plezy/exceptions/media_server_exceptions.dart'; +import 'package:plezy/utils/managed_http_client.dart'; +import 'package:plezy/utils/media_server_http_client.dart'; + +void main() { + group('MediaServerHttpClient shutdown', () { + test('rejects new requests as a cancellation, not a transient failure', () async { + final client = MediaServerHttpClient( + client: ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'), + baseUrl: 'https://example.test/', + ); + + client.close(); + + await expectLater( + client.get('library/sections'), + throwsA( + isA() + .having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled) + .having((e) => e.isTransient, 'isTransient', isFalse), + ), + ); + }); + + test('the layer beneath reports the same shutdown as a transient connection error', () async { + final managed = ManagedHttpClient(_AbortAwareClient(), debugLabel: 'test'); + await managed.closeGracefully(drainTimeout: Duration.zero); + + await expectLater( + managed.send(http.Request('GET', Uri.parse('https://example.test/library/sections'))), + throwsA( + isA() + .having( + (e) => MediaServerHttpException.from(e).type, + 'mapped type', + MediaServerHttpErrorType.connectionError, + ) + .having((e) => MediaServerHttpException.from(e).isTransient, 'mapped isTransient', isTrue), + ), + ); + }); + + test('aborts requests already in flight at the transport', () async { + final transport = _AbortAwareClient(); + final client = MediaServerHttpClient(client: transport, baseUrl: 'https://example.test/'); + + final pending = client.get('library/sections'); + await Future.delayed(Duration.zero); + + client.close(); + + await expectLater(transport.abortTrigger, completes); + await expectLater( + pending, + throwsA(isA().having((e) => e.type, 'type', MediaServerHttpErrorType.cancelled)), + ); + }); + }); +} + +class _AbortAwareClient extends http.BaseClient { + final _response = Completer(); + late final Future abortTrigger; + + @override + Future send(http.BaseRequest request) { + final trigger = (request as http.Abortable).abortTrigger!; + abortTrigger = trigger; + unawaited( + trigger.whenComplete(() { + if (!_response.isCompleted) _response.completeError(http.RequestAbortedException(request.url)); + }), + ); + return _response.future; + } + + @override + void close() {} +} diff --git a/test/utils/plex_cache_parser_test.dart b/test/utils/plex_cache_parser_test.dart index 0fbc4a24..118af79d 100644 --- a/test/utils/plex_cache_parser_test.dart +++ b/test/utils/plex_cache_parser_test.dart @@ -61,47 +61,4 @@ void main() { expect(result, equals(first)); }); }); - - group('PlexCacheParser.extractChapters', () { - test('returns null for null input', () { - expect(PlexCacheParser.extractChapters(null), isNull); - }); - - test('returns null when no metadata', () { - expect( - PlexCacheParser.extractChapters({ - 'MediaContainer': {'Metadata': []}, - }), - isNull, - ); - }); - - test('returns null when first metadata has no Chapter key', () { - expect( - PlexCacheParser.extractChapters({ - 'MediaContainer': { - 'Metadata': [ - {'ratingKey': '1'}, - ], - }, - }), - isNull, - ); - }); - - test('returns chapter list when present', () { - final chapters = [ - {'tag': 'Chapter 1'}, - {'tag': 'Chapter 2'}, - ]; - final result = PlexCacheParser.extractChapters({ - 'MediaContainer': { - 'Metadata': [ - {'ratingKey': '1', 'Chapter': chapters}, - ], - }, - }); - expect(result, equals(chapters)); - }); - }); } diff --git a/test/utils/provider_extensions_test.dart b/test/utils/provider_extensions_test.dart index 19b7ac20..afaab4a8 100644 --- a/test/utils/provider_extensions_test.dart +++ b/test/utils/provider_extensions_test.dart @@ -8,13 +8,12 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_library.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/provider_extensions.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; const _missingOwnerLibrary = MediaLibrary( id: '1', @@ -59,13 +58,7 @@ void main() { tester, ) async { final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext(tester, testMultiServer(clients: [replacement]).provider); expect(() => context.getPlexClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); expect(() => context.getMediaClientForLibrary(_missingOwnerLibrary), _throwsNoClientAvailable); @@ -75,13 +68,7 @@ void main() { tester, ) async { final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager()..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext(tester, testMultiServer(clients: [replacement]).provider); for (final serverId in [null, ' ']) { final library = MediaLibrary( @@ -103,15 +90,10 @@ void main() { testWidgets('library-qualified helpers return their registered owner even when it is marked offline', (tester) async { final owner = testPlexClient(serverId: ServerId('server-a')); final replacement = testPlexClient(serverId: ServerId('server-b')); - final manager = MultiServerManager() - ..debugRegisterClientForTesting(owner, online: false) - ..debugRegisterClientForTesting(replacement); - final provider = MultiServerProvider(manager, DataAggregationService(manager)); - addTearDown(() { - provider.dispose(); - manager.dispose(); - }); - final context = await _pumpContext(tester, provider); + final context = await _pumpContext( + tester, + testMultiServer(clients: [owner, replacement], offline: [owner]).provider, + ); expect(context.getPlexClientForLibrary(_missingOwnerLibrary), same(owner)); expect(context.getMediaClientForLibrary(_missingOwnerLibrary), same(owner)); diff --git a/test/widgets/chapter_sheet_test.dart b/test/widgets/chapter_sheet_test.dart index 76bc0768..9540d902 100644 --- a/test/widgets/chapter_sheet_test.dart +++ b/test/widgets/chapter_sheet_test.dart @@ -4,28 +4,10 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/sheets/chapter_sheet.dart'; -const _tokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { setUp(() => LocaleSettings.setLocaleSync(AppLocale.en)); @@ -64,7 +46,7 @@ Future _pumpSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_tokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Scaffold( body: Builder( diff --git a/test/widgets/library_management_sheet_test.dart b/test/widgets/library_management_sheet_test.dart index 2bcba7da..532ca2e2 100644 --- a/test/widgets/library_management_sheet_test.dart +++ b/test/widgets/library_management_sheet_test.dart @@ -15,7 +15,6 @@ import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/theme/mono_theme.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/platform_detector.dart'; @@ -24,6 +23,7 @@ import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; @@ -49,8 +49,7 @@ Future<({int Function() selects, int Function() backs})> _pumpLibraryManagementL addTearDown(hiddenLibrariesProvider.dispose); final fallbackManager = multiServerProvider == null ? MultiServerManager() : null; - final effectiveMultiServerProvider = - multiServerProvider ?? MultiServerProvider(fallbackManager!, DataAggregationService(fallbackManager)); + final effectiveMultiServerProvider = multiServerProvider ?? testMultiServerProvider(fallbackManager!); if (fallbackManager != null) { addTearDown(() { effectiveMultiServerProvider.dispose(); @@ -268,7 +267,7 @@ class _LibraryActionHarness { ); manager.debugRegisterClientForTesting(owner); } - provider = MultiServerProvider(manager, DataAggregationService(manager)); + provider = testMultiServerProvider(manager); } void dispose() { diff --git a/test/widgets/media_context_menu_test.dart b/test/widgets/media_context_menu_test.dart index af1c3db5..3d6b4a53 100644 --- a/test/widgets/media_context_menu_test.dart +++ b/test/widgets/media_context_menu_test.dart @@ -9,7 +9,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:http/http.dart' as http; import 'package:http/testing.dart'; import 'package:plezy/connection/connection.dart'; -import 'package:plezy/connection/connection_registry.dart'; import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/navigation/profile_navigation_scope.dart'; @@ -26,15 +25,11 @@ import 'package:plezy/models/plex/plex_home_user.dart'; import 'package:plezy/models/plex/plex_config.dart'; import 'package:plezy/profiles/profile.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/screens/music/album_detail_screen.dart'; import 'package:plezy/screens/music/artist_detail_screen.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/download_manager_service.dart'; import 'package:plezy/services/download_storage_service.dart'; import 'package:plezy/services/jellyfin_client.dart'; @@ -50,7 +45,9 @@ import 'package:plezy/widgets/media_context_menu.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; +import '../test_helpers/profile_stack.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -126,28 +123,14 @@ void main() { ]; final client = _AudioPlaylistClient(tracks); final music = _RecordingMusicPlaybackService(); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final multiServerProvider = testMultiServerProvider(manager); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); music.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -164,7 +147,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: music), ], child: MaterialApp( @@ -244,28 +227,14 @@ void main() { ), ])..blockWithAbort = true; final playback = PlaybackStateProvider(); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final multiServerProvider = testMultiServerProvider(manager); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { playback.dispose(); - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -281,7 +250,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: playback), ], child: MaterialApp( @@ -323,27 +292,13 @@ void main() { TvDetectionService.debugSetAppleTVOverride(true); addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); - final db = AppDatabase.forTesting(NativeDatabase.memory()); final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final multiServerProvider = testMultiServerProvider(manager); + final stack = await ProfileStack.create(withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); - await db.close(); }); final menuKey = GlobalKey(); @@ -360,7 +315,7 @@ void main() { child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ], child: MaterialApp( theme: monoTheme(dark: true), @@ -614,22 +569,10 @@ Future> _pumpPlexMovieMenu( }), ); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final multiServerProvider = testMultiServerProvider(manager); + final stack = await ProfileStack.create(db: db, withStorage: false); addTearDown(() async { - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); multiServerProvider.dispose(); manager.dispose(); await db.close(); @@ -648,7 +591,7 @@ Future> _pumpPlexMovieMenu( child: MultiProvider( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ], child: MaterialApp( theme: monoTheme(dark: true), @@ -727,9 +670,6 @@ class _RecordingMusicPlaybackService extends StubMusicPlaybackService { bool? shuffle; int callCount = 0; - @override - bool get isAvailable => true; - @override Future playFromList({ required List tracks, @@ -828,19 +768,8 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( await downloadProvider.ensureInitialized(); final client = _RelatedMusicClient(relatedItems); final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final multiServerProvider = testMultiServerProvider(manager); + final stack = await ProfileStack.create(db: db, withStorage: false); final music = _RecordingMusicPlaybackService(); final rootNavigatorKey = GlobalKey(); final profileNavigatorKey = GlobalKey(); @@ -849,8 +778,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( addTearDown(() async { downloadProvider.dispose(); downloadManager.dispose(); - activeProfileProvider.dispose(); - await plexHome.dispose(); + await stack.dispose(); music.dispose(); multiServerProvider.dispose(); manager.dispose(); @@ -866,7 +794,7 @@ Future<_SiblingMusicMenuHarness> _pumpSiblingMusicMenu( providers: [ ChangeNotifierProvider.value(value: multiServerProvider), ChangeNotifierProvider.value(value: downloadProvider), - ChangeNotifierProvider.value(value: activeProfileProvider), + ChangeNotifierProvider.value(value: stack.active), ChangeNotifierProvider.value(value: music), ], child: ProfileNavigationScope( diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index d269e31d..9f2f791d 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -1,12 +1,9 @@ import 'dart:async'; -import 'package:drift/native.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:material_symbols_icons/symbols.dart'; -import 'package:plezy/connection/connection_registry.dart'; -import 'package:plezy/database/app_database.dart'; import 'package:plezy/focus/focusable_action_bar.dart'; import 'package:plezy/focus/focusable_wrapper.dart'; import 'package:plezy/i18n/strings.g.dart'; @@ -15,13 +12,8 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/models/download_models.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; -import 'package:plezy/profiles/plex_home_service.dart'; -import 'package:plezy/profiles/profile_connection_registry.dart'; -import 'package:plezy/profiles/profile_registry.dart'; import 'package:plezy/providers/download_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/music/music_playback_service.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -32,7 +24,9 @@ import 'package:plezy/widgets/music/mini_player.dart'; import 'package:provider/provider.dart'; import '../../test_helpers/media_items.dart'; +import '../../test_helpers/multi_server_fixtures.dart'; import '../../test_helpers/prefs.dart'; +import '../../test_helpers/profile_stack.dart'; final _track = testMediaItem( id: 'track_1', @@ -80,9 +74,6 @@ class _FakeMusicService extends StubMusicPlaybackService { _FakeMusicService({this.track}); - @override - bool get isAvailable => true; - @override MediaItem? get currentTrack => track; @@ -164,14 +155,9 @@ void main() { ActiveProfileProvider? activeProfileProvider, DownloadProvider? downloadProvider, }) { - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); addTearDown(service.dispose); addTearDown(observer.suppress.dispose); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); + final multiServerProvider = testMultiServer().provider; return TranslationProvider( child: MultiProvider( @@ -337,25 +323,11 @@ void main() { testWidgets('keyboard long-press anchors the context menu to the focused card instead of a stale pointer', ( tester, ) async { - final db = AppDatabase.forTesting(NativeDatabase.memory()); - final connections = ConnectionRegistry(db); - final profileConnections = ProfileConnectionRegistry(db); - final plexHome = PlexHomeService( - connections: connections, - profileConnections: profileConnections, - plexHomeUserFetcher: (_) async => const [], - ); - final activeProfileProvider = ActiveProfileProvider( - registry: ProfileRegistry(db), - plexHome: plexHome, - connections: connections, - ); + final stack = await ProfileStack.create(withStorage: false); final downloadProvider = _FakeDownloadProvider(); addTearDown(() async { - activeProfileProvider.dispose(); + await stack.dispose(); downloadProvider.dispose(); - await plexHome.dispose(); - await db.close(); }); final service = _FakeMusicService(track: _track); final observer = MusicUiRouteObserver(); @@ -364,7 +336,7 @@ void main() { wrap( service: service, observer: observer, - activeProfileProvider: activeProfileProvider, + activeProfileProvider: stack.active, downloadProvider: downloadProvider, ), ); diff --git a/test/widgets/player_queue_spoilers_test.dart b/test/widgets/player_queue_spoilers_test.dart index 21a0ac27..eb45d6c6 100644 --- a/test/widgets/player_queue_spoilers_test.dart +++ b/test/widgets/player_queue_spoilers_test.dart @@ -12,7 +12,6 @@ import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/sheets/queue_sheet.dart'; import 'package:plezy/widgets/video_controls/widgets/content_strip.dart'; import 'package:plezy/widgets/video_controls/widgets/media_selector_thumbnail.dart'; @@ -20,25 +19,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -334,7 +315,7 @@ Widget _queueHarness({required PlaybackStateProvider playback, required Widget c value: playback, child: InputModeTracker( child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold(body: SizedBox(width: 600, height: 400, child: child)), ), ), diff --git a/test/widgets/server_activities_button_test.dart b/test/widgets/server_activities_button_test.dart index dfe410a1..fff44583 100644 --- a/test/widgets/server_activities_button_test.dart +++ b/test/widgets/server_activities_button_test.dart @@ -9,14 +9,13 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/i18n/strings.g.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; -import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/theme/mono_theme.dart'; import 'package:plezy/widgets/server_activities_button.dart'; import 'package:provider/provider.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -31,15 +30,9 @@ void main() { tearDown(() => database.close()); testWidgets('togglePanel opens and closes the server activities overlay', (tester) async { - final manager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServer().provider; final buttonKey = GlobalKey(); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); - await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( @@ -178,15 +171,9 @@ class _ActivitiesHarness { Future<_ActivitiesHarness> _pumpActivitiesHarness(WidgetTester tester, _ControlledActivitiesClient transport) async { final serverId = ServerId('plex-server'); final client = testPlexClient(serverId: serverId, serverName: 'Test server', httpClient: transport); - final manager = MultiServerManager()..debugRegisterClientForTesting(client); - final multiServerProvider = MultiServerProvider(manager, DataAggregationService(manager)); + final multiServerProvider = testMultiServer(clients: [client]).provider; final buttonKey = GlobalKey(); - addTearDown(() { - multiServerProvider.dispose(); - manager.dispose(); - }); - await tester.pumpWidget( TranslationProvider( child: ChangeNotifierProvider.value( diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 4fe8602a..158f3429 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -14,35 +14,16 @@ import 'package:plezy/navigation/navigation_tabs.dart'; import 'package:plezy/providers/hidden_libraries_provider.dart'; import 'package:plezy/providers/libraries_provider.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/utils/platform_detector.dart'; import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:provider/provider.dart'; +import '../test_helpers/multi_server_fixtures.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; MediaLibrary _library({ required String id, @@ -101,8 +82,7 @@ Future _pumpBasicRail( addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final rail = SideNavigationRail( @@ -124,7 +104,7 @@ Future _pumpBasicRail( ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: height == null ? rail : SizedBox(height: height, child: rail), ), @@ -159,8 +139,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); await tester.pumpWidget( @@ -172,7 +151,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -224,8 +203,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); await tester.pumpWidget( @@ -237,7 +215,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -263,7 +241,7 @@ void main() { await _pumpBasicRail(tester, alwaysExpanded: true); final selectedItem = find.byType(NavigationRailItem).first; - expect(_railItemDecoration(tester, selectedItem)?.color, _testTokens.text.withValues(alpha: 0.1)); + expect(_railItemDecoration(tester, selectedItem)?.color, testMonoTokens.text.withValues(alpha: 0.1)); }); testWidgets('D-pad sidebar focus hides selected item background after focus moves', (tester) async { @@ -328,8 +306,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final reports = []; @@ -343,7 +320,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( selectedTab: NavigationTabId.discover, @@ -392,8 +369,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final sideNavKey = GlobalKey(); @@ -408,7 +384,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( key: sideNavKey, @@ -469,8 +445,7 @@ void main() { addTearDown(hiddenLibrariesProvider.dispose); final manager = MultiServerManager(); - final aggregation = DataAggregationService(manager); - final multiServerProvider = MultiServerProvider(manager, aggregation); + final multiServerProvider = testMultiServerProvider(manager); addTearDown(multiServerProvider.dispose); final sideNavKey = GlobalKey(); @@ -485,7 +460,7 @@ void main() { ChangeNotifierProvider.value(value: multiServerProvider), ], child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SideNavigationRail( key: sideNavKey, @@ -527,7 +502,7 @@ void main() { await tester.pumpWidget( InputModeTracker( child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: Builder( builder: (context) { diff --git a/test/widgets/track_sheet_test.dart b/test/widgets/track_sheet_test.dart index 00106a40..8fc7bdad 100644 --- a/test/widgets/track_sheet_test.dart +++ b/test/widgets/track_sheet_test.dart @@ -9,29 +9,11 @@ import 'package:plezy/media/media_source_info.dart'; import 'package:plezy/mpv/mpv.dart'; import 'package:plezy/services/playback_subtitle_resolver.dart'; import 'package:plezy/services/playback_initialization_types.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/track_sheet.dart'; -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -262,6 +244,7 @@ void main() { expect(find.text('Downloaded subtitle'), findsOneWidget); expect(find.text('Remote sidecar'), findsOneWidget); }); + testWidgets('keeps a source sheet open until the async selection commits', (tester) async { final player = _FakeTrackSheetPlayer( tracks: const Tracks( @@ -276,7 +259,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Builder( builder: (context) { @@ -538,7 +521,7 @@ Future _pumpTrackSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: OverlaySheetHost( child: Scaffold( body: SizedBox( diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index b4408505..ba43a116 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -10,7 +10,6 @@ import 'package:plezy/media/media_hub.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; -import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/device_performance.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; @@ -25,6 +24,7 @@ import 'package:provider/provider.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; +import '../test_helpers/multi_server_fixtures.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -346,7 +346,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -418,7 +418,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -473,7 +473,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: theme, home: Scaffold( @@ -513,7 +513,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -557,7 +557,7 @@ void main() { Widget rail(MediaHub hub) { return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -599,7 +599,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -689,7 +689,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -762,7 +762,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -822,7 +822,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: InputModeTracker( child: MaterialApp( theme: monoTheme(dark: true), @@ -884,7 +884,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -939,7 +939,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1001,7 +1001,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1053,7 +1053,7 @@ void main() { Widget buildRail(List hubs, {String? initialHubId, String? initialItemId, bool autofocus = false}) { final serverManager = MultiServerManager(); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1095,7 +1095,7 @@ void main() { Widget buildRail(List hubs, {String? initialHubId}) { final serverManager = MultiServerManager(); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1139,7 +1139,7 @@ void main() { addTearDown(tester.view.resetPhysicalSize); final serverManager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final multiServerProvider = testMultiServerProvider(serverManager); addTearDown(multiServerProvider.dispose); final recentItems = [ @@ -1215,7 +1215,7 @@ void main() { Widget buildRail(List hubs, {String? initialItemId}) { final serverManager = MultiServerManager(); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1295,7 +1295,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1409,7 +1409,7 @@ void main() { Widget buildRail(List hubs) { return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1492,7 +1492,7 @@ void main() { Widget buildRail({required bool backgroundLoaded}) { return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1568,7 +1568,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1670,7 +1670,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1746,7 +1746,7 @@ void main() { final serverManager = MultiServerManager(); await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1835,7 +1835,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1926,7 +1926,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -1998,7 +1998,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2063,7 +2063,7 @@ void main() { final item = testMediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); return ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2098,7 +2098,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2143,7 +2143,7 @@ void main() { await tester.pumpWidget( ChangeNotifierProvider( - create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + create: (_) => testMultiServerProvider(serverManager), child: MaterialApp( theme: monoTheme(dark: true), home: Scaffold( @@ -2174,7 +2174,7 @@ void main() { testWidgets('background bleed updates do not renotify rail focus', (tester) async { final serverManager = MultiServerManager(); - final multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + final multiServerProvider = testMultiServerProvider(serverManager); addTearDown(multiServerProvider.dispose); final focusedItemIds = []; diff --git a/test/widgets/video_controls_test.dart b/test/widgets/video_controls_test.dart index e83672ee..eddf247f 100644 --- a/test/widgets/video_controls_test.dart +++ b/test/widgets/video_controls_test.dart @@ -17,7 +17,6 @@ import 'package:plezy/services/playback_subtitle_resolver.dart'; import 'package:plezy/providers/playback_state_provider.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/services/video_volume_controller.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/video_controls/widgets/player_toast_indicator.dart'; import 'package:plezy/widgets/video_controls/desktop_video_controls.dart'; import 'package:plezy/widgets/video_controls/mobile_video_controls.dart'; @@ -36,25 +35,7 @@ import 'package:plezy/widgets/video_controls/widgets/video_timeline_bar.dart'; import '../test_helpers/watch_together_fakes.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 8, - radiusMd: 12, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 1), - normal: Duration(milliseconds: 1), - slow: Duration(milliseconds: 1), - expressive: Duration(milliseconds: 1), - bg: Colors.black, - surface: Colors.black, - outline: Colors.white24, - text: Colors.white, - textMuted: Colors.white70, - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -968,7 +949,7 @@ void main() { ChangeNotifierProvider.value( value: watchTogether, child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 1000, @@ -1023,7 +1004,7 @@ void main() { ChangeNotifierProvider.value( value: watchTogether, child: MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 500, @@ -1563,7 +1544,7 @@ void main() { ChangeNotifierProvider.value(value: watchTogether), ], child: MaterialApp( - theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [_testTokens]), + theme: ThemeData(platform: TargetPlatform.macOS, extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 1200, @@ -1617,7 +1598,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1653,7 +1634,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1699,7 +1680,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1752,7 +1733,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1804,7 +1785,7 @@ void main() { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: SizedBox( width: 700, @@ -1914,7 +1895,7 @@ Future _pumpSkipMarkerButton( }) { return tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokens]), home: Scaffold( body: Center( child: SkipMarkerButton( diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index 5be86b00..7358f700 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -10,30 +10,12 @@ import 'package:plezy/mpv/player/player_streams.dart'; import 'package:plezy/screens/settings/subtitle_styling_screen.dart'; import 'package:plezy/services/sleep_timer_service.dart'; import 'package:plezy/services/settings_service.dart'; -import 'package:plezy/theme/mono_tokens.dart'; import 'package:plezy/widgets/overlay_sheet.dart'; +import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart'; import '../test_helpers/prefs.dart'; - -const _testTokens = MonoTokens( - radiusSm: 4, - radiusMd: 8, - radiusLg: 20, - radiusXs: 5, - groupGap: 2, - space: 8, - fast: Duration(milliseconds: 100), - normal: Duration(milliseconds: 200), - slow: Duration(milliseconds: 300), - expressive: Duration(milliseconds: 300), - bg: Colors.black, - surface: Color(0xFF111111), - outline: Color(0xFF333333), - text: Colors.white, - textMuted: Color(0xFFAAAAAA), - splashFactory: NoSplash.splashFactory, -); +import '../test_helpers/theme.dart'; void main() { setUpAll(() async { @@ -108,7 +90,7 @@ void main() { LocaleSettings.setLocaleSync(AppLocale.ru); await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokensAnimated]), home: const SubtitleStylingScreen(), ), ); @@ -185,17 +167,15 @@ Future _pumpSheet( }) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokensAnimated]), home: Scaffold( body: SizedBox( width: 900, height: 700, child: VideoSettingsSheet( player: player ?? _FakeSettingsPlayer(), - audioSyncOffset: 0, - subtitleSyncOffset: 0, - canControl: canControl, supportsHdrControl: supportsHdrControl, + trackControlsState: TrackControlsState(canControl: canControl), ), ), ), @@ -207,15 +187,17 @@ Future _pumpSheet( Future _pumpHostedSheet(WidgetTester tester, Player player) async { await tester.pumpWidget( MaterialApp( - theme: ThemeData(extensions: const [_testTokens]), + theme: ThemeData(extensions: const [testMonoTokensAnimated]), home: OverlaySheetHost( child: Scaffold( body: Builder( builder: (context) => TextButton( onPressed: () => unawaited( OverlaySheetController.of(context).show( - builder: (_) => - VideoSettingsSheet(player: player, audioSyncOffset: 0, subtitleSyncOffset: 0, canControl: true), + builder: (_) => VideoSettingsSheet( + player: player, + trackControlsState: const TrackControlsState(canControl: true), + ), ), ), child: const Text('Open settings'), diff --git a/website/src/lib/components/DownloadButtons.svelte b/website/src/lib/components/DownloadButtons.svelte index 89cd821d..e554b30a 100644 --- a/website/src/lib/components/DownloadButtons.svelte +++ b/website/src/lib/components/DownloadButtons.svelte @@ -5,7 +5,7 @@ import AmazonIcon from "~icons/cib/amazon"; import ChevronDownIcon from "~icons/heroicons/chevron-down-solid"; import WindowsIcon from "./WindowsIcon.svelte"; - import { linuxArchitectures } from "$lib/content/downloads"; + import { AMAZON_URL, linuxArchitectures, releaseAsset, storeOptions } from "$lib/content/downloads"; const componentId = $props.id(); const linuxPanelId = `${componentId}-linux-downloads`; @@ -34,7 +34,7 @@
@@ -75,7 +75,7 @@ diff --git a/website/src/lib/components/FAQ.svelte b/website/src/lib/components/FAQ.svelte index dae6a720..f764d476 100644 --- a/website/src/lib/components/FAQ.svelte +++ b/website/src/lib/components/FAQ.svelte @@ -4,6 +4,7 @@ import MinusIcon from '~icons/heroicons/minus'; import PlusIcon from '~icons/heroicons/plus'; import ScrollReveal from "./ScrollReveal.svelte"; + import SectionHeader from "./SectionHeader.svelte"; const hash = $derived(page.url.hash.slice(1)); const hashIndex = $derived(faqs.findIndex((f) => f.id === hash)); @@ -24,12 +25,12 @@ } -
- - -

Common questions

-

Everything you need to know about Plezy.

-
+
+
{#each faqs as faq, i} @@ -71,43 +72,6 @@
diff --git a/website/src/lib/content/downloads.ts b/website/src/lib/content/downloads.ts index 6a79e63b..f7f4e901 100644 --- a/website/src/lib/content/downloads.ts +++ b/website/src/lib/content/downloads.ts @@ -12,16 +12,23 @@ export type StoreOption = { url: string; }; +export const APP_STORE_ID = '6754315964'; +export const ANDROID_PACKAGE = 'com.edde746.plezy'; +export const AMAZON_URL = 'https://www.amazon.com/gp/product/B0GK65CVS1'; + +export const releaseAsset = (name: string) => + `https://github.com/edde746/plezy/releases/latest/download/${name}`; + export const storeOptions = { ios: { id: 'app-store', label: 'App Store', - url: 'https://apps.apple.com/us/app/id6754315964', + url: `https://apps.apple.com/us/app/id${APP_STORE_ID}`, }, android: { id: 'play-store', label: 'Google Play', - url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', + url: `https://play.google.com/store/apps/details?id=${ANDROID_PACKAGE}`, }, } as const satisfies Record<'ios' | 'android', StoreOption>; @@ -45,23 +52,21 @@ export function storeOptionsForPlatform(platform: MobileStorePlatform): readonly return [storeOptions.ios, storeOptions.android]; } +// Every architecture ships the same formats, so the menu is their cross product. +const LINUX_FORMATS = [ + { label: '.deb (Debian/Ubuntu)', extension: 'deb' }, + { label: '.rpm (Fedora/RHEL)', extension: 'rpm' }, + { label: '.pkg.tar.zst (Arch)', extension: 'pkg.tar.zst' }, + { label: '.tar.gz (Portable)', extension: 'tar.gz' }, +]; + export const linuxArchitectures = [ - { - label: 'x64 (Intel/AMD)', - formats: [ - { label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb' }, - { label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm' }, - { label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst' }, - { label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz' }, - ], - }, - { - label: 'ARM64', - formats: [ - { label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb' }, - { label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm' }, - { label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst' }, - { label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz' }, - ], - }, -] as const; + { slug: 'x64', label: 'x64 (Intel/AMD)' }, + { slug: 'arm64', label: 'ARM64' }, +].map(({ slug, label }) => ({ + label, + formats: LINUX_FORMATS.map((format) => ({ + label: format.label, + url: releaseAsset(`plezy-linux-${slug}.${format.extension}`), + })), +})); diff --git a/website/src/lib/content/software_app_offers.ts b/website/src/lib/content/software_app_offers.ts index 24415648..b2234bc7 100644 --- a/website/src/lib/content/software_app_offers.ts +++ b/website/src/lib/content/software_app_offers.ts @@ -1,3 +1,5 @@ +import { AMAZON_URL, storeOptions } from './downloads'; + export type StorePrices = { appStorePrice: string | null; playStorePrice: string | null; @@ -23,7 +25,7 @@ export function buildSoftwareApplicationOffers({ return [ { '@type': 'Offer', - url: 'https://apps.apple.com/us/app/id6754315964', + url: storeOptions.ios.url, category: 'App Store', ...(appStorePrice === null ? {} @@ -34,7 +36,7 @@ export function buildSoftwareApplicationOffers({ }, { '@type': 'Offer', - url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', + url: storeOptions.android.url, category: 'Google Play', ...(playStorePrice === null ? {} @@ -45,7 +47,7 @@ export function buildSoftwareApplicationOffers({ }, { '@type': 'Offer', - url: 'https://www.amazon.com/gp/product/B0GK65CVS1', + url: AMAZON_URL, category: 'Amazon Appstore', }, { diff --git a/website/src/lib/server/homepage_store_metadata.ts b/website/src/lib/server/homepage_store_metadata.ts index 286fcd0f..2e08abfb 100644 --- a/website/src/lib/server/homepage_store_metadata.ts +++ b/website/src/lib/server/homepage_store_metadata.ts @@ -1,3 +1,4 @@ +import { APP_STORE_ID } from '../content/downloads'; import { normalizeUsdStorePrice } from '../content/software_app_offers'; export type HomepageAggregateRating = { @@ -42,7 +43,7 @@ type AppStoreLookupResponse = { results?: AppStoreLookupResult[]; }; -const APP_STORE_LOOKUP_URL = 'https://itunes.apple.com/lookup?id=6754315964'; +const APP_STORE_LOOKUP_URL = `https://itunes.apple.com/lookup?id=${APP_STORE_ID}`; export async function loadHomepageStoreMetadata({ fetch, diff --git a/website/src/routes/+error.svelte b/website/src/routes/+error.svelte index 2869835d..c5e690c1 100644 --- a/website/src/routes/+error.svelte +++ b/website/src/routes/+error.svelte @@ -8,16 +8,16 @@ -
-
- +
+ diff --git a/website/src/routes/+page.server.ts b/website/src/routes/+page.server.ts index d488440e..34d0aab2 100644 --- a/website/src/routes/+page.server.ts +++ b/website/src/routes/+page.server.ts @@ -1,4 +1,5 @@ import type { PageServerLoad } from './$types'; +import { ANDROID_PACKAGE } from '$lib/content/downloads'; import { loadHomepageStoreMetadata } from '$lib/server/homepage_store_metadata'; export const load: PageServerLoad = async ({ fetch }) => @@ -9,7 +10,7 @@ export const load: PageServerLoad = async ({ fetch }) => // helper's Play Store failure boundary. const { default: gplay } = await import('google-play-scraper'); return gplay.app({ - appId: 'com.edde746.plezy', + appId: ANDROID_PACKAGE, country: 'us', lang: 'en' }); diff --git a/website/src/routes/layout.css b/website/src/routes/layout.css index e6a2b413..62b4f500 100644 --- a/website/src/routes/layout.css +++ b/website/src/routes/layout.css @@ -149,6 +149,86 @@ ul { background: var(--color-surface); } +/* Standalone page holding a single centered card (/scan, +error). */ +.centered-page { + display: flex; + min-height: 100dvh; + align-items: center; + justify-content: center; + padding: var(--page-gutter); +} + +.centered-card { + display: flex; + width: min(100%, 32rem); + flex-direction: column; + align-items: center; + border-radius: var(--radius-xl); + padding: clamp(2rem, 8vw, 4rem); + background: var(--color-surface); + text-align: center; +} + +.card-logo { + display: flex; + width: 5rem; + height: 5rem; + align-items: center; + justify-content: center; + margin-bottom: 2rem; + border-radius: var(--radius-lg); + background: var(--color-surface-highest); +} + +.card-logo img { + width: 2.5rem; + height: 2.5rem; +} + +/* Filled pill action; add .btn-pill--icon when it leads with an icon. */ +.btn-pill { + display: inline-flex; + min-height: 3rem; + align-items: center; + border-radius: var(--radius-pill); + padding-inline: 1.25rem; + color: var(--color-on-primary); + background: var(--color-text); + font-size: 0.8125rem; + font-weight: 700; + transition: + border-radius var(--motion-expressive) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard); +} + +.btn-pill:hover, +.btn-pill:focus-visible { + border-radius: var(--radius-md); + background: #fff; +} + +.btn-pill--icon { + gap: 0.625rem; +} + +.btn-pill--icon svg { + width: 1.125rem; + height: 1.125rem; +} + +/* Landing section that stays inside the page grid. */ +.page-section { + width: min(100%, var(--page-width)); + margin-inline: auto; + padding: clamp(4rem, 9vw, 8rem) var(--page-gutter); +} + +/* Landing section whose content bleeds past the page grid. */ +.bleed-section { + overflow: hidden; + padding-block: clamp(4rem, 9vw, 8rem); +} + @keyframes fade-in-up { from { opacity: 0; diff --git a/website/src/routes/scan/+page.svelte b/website/src/routes/scan/+page.svelte index 929d551a..c4737a5e 100644 --- a/website/src/routes/scan/+page.svelte +++ b/website/src/routes/scan/+page.svelte @@ -40,9 +40,9 @@ -
-
- +
+
+

Scan in Plezy

To use this feature, scan this QR code with the Plezy app.

@@ -53,7 +53,7 @@ href={store.url} target="_blank" rel="noopener noreferrer" - class="store-button" + class="btn-pill btn-pill--icon" > {#if store.id === "app-store"} @@ -68,41 +68,6 @@
diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 4ceacfb1..bbcb464b 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -26,44 +26,32 @@ struct InnerWindowSubclassState { namespace { -flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { - if (!node) return flutter::EncodableValue(); +// Adapts the shared, bounded mpv_node walk onto Flutter's encodable values. +struct EncodableNodeBuilder { + using Value = flutter::EncodableValue; + using ListBuilder = flutter::EncodableList; + using MapBuilder = flutter::EncodableMap; - switch (node->format) { - case MPV_FORMAT_STRING: - return flutter::EncodableValue(SanitizeUtf8(node->u.string)); - case MPV_FORMAT_FLAG: - return flutter::EncodableValue(node->u.flag != 0); - case MPV_FORMAT_INT64: - return flutter::EncodableValue(node->u.int64); - case MPV_FORMAT_DOUBLE: - return flutter::EncodableValue(node->u.double_); - case MPV_FORMAT_NODE_ARRAY: { - const mpv_node_list* node_list = node->u.list; - if (!node_list || node_list->num < 0 || (node_list->num > 0 && !node_list->values)) { - return flutter::EncodableValue(); - } - flutter::EncodableList list; - list.reserve(static_cast(node_list->num)); - for (int i = 0; i < node_list->num; ++i) { - list.push_back(NodeToEncodableValue(&node_list->values[i])); - } - return flutter::EncodableValue(list); - } - case MPV_FORMAT_NODE_MAP: { - const mpv_node_list* node_list = node->u.list; - if (!node_list || node_list->num < 0 || (node_list->num > 0 && (!node_list->values || !node_list->keys))) { - return flutter::EncodableValue(); - } - flutter::EncodableMap map; - for (int i = 0; i < node_list->num; ++i) { - map[flutter::EncodableValue(SanitizeUtf8(node_list->keys[i]))] = NodeToEncodableValue(&node_list->values[i]); - } - return flutter::EncodableValue(map); - } - default: - return flutter::EncodableValue(); + static Value Null() { return flutter::EncodableValue(); } + static Value Bool(bool value) { return flutter::EncodableValue(value); } + static Value Int(int64_t value) { return flutter::EncodableValue(value); } + static Value Double(double value) { return flutter::EncodableValue(value); } + static Value String(const char* value, size_t length) { return flutter::EncodableValue(SanitizeUtf8(value, length)); } + + static ListBuilder NewList() { return flutter::EncodableList(); } + static void Append(ListBuilder& list, Value value) { list.push_back(std::move(value)); } + static Value FinishList(ListBuilder list) { return flutter::EncodableValue(std::move(list)); } + + static MapBuilder NewMap() { return flutter::EncodableMap(); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + map[flutter::EncodableValue(SanitizeUtf8(key, key_length))] = std::move(value); } + static Value FinishMap(MapBuilder map) { return flutter::EncodableValue(std::move(map)); } + static void AbandonMap(MapBuilder&) {} +}; + +flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { + return plezy::mpv_common::ConvertNode(node); } // Input ownership for the DComp video child. @@ -668,21 +656,7 @@ void MpvPlayer::CommandAsync(const std::vector& args, CommandCallba return; } - std::vector c_args; - c_args.reserve(args.size() + 1); - for (const auto& arg : args) { - c_args.push_back(arg.c_str()); - } - c_args.push_back(nullptr); - - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - // mpv_command_async returns immediately - int result = mpv_command_async(mpv_, request_id, c_args.data()); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback)); } void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { @@ -701,14 +675,7 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val return; } - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - char* property_value = const_cast(value.c_str()); - int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback)); } void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) { @@ -717,13 +684,7 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca return; } - uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback)); - - int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING); - if (result < 0) { - auto cb = pending_requests_.TakeProperty(request_id); - if (cb) cb(result, ""); - } + plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback)); } void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { @@ -839,32 +800,12 @@ void MpvPlayer::EventLoop() { } void MpvPlayer::HandleMpvEvent(mpv_event* event) { + if (plezy::mpv_common::DispatchReplyEvent( + pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) { + return; + } + switch (event->event_id) { - case MPV_EVENT_COMMAND_REPLY: - case MPV_EVENT_SET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - StatusCallback callback = pending_requests_.TakeStatus(request_id); - if (callback) { - callback(event->error); - } - break; - } - case MPV_EVENT_GET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - GetPropertyCallback callback = pending_requests_.TakeProperty(request_id); - if (callback) { - std::string value; - if (event->error >= 0) { - auto* prop = static_cast(event->data); - if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { - auto c_value = *static_cast(prop->data); - if (c_value) value = SanitizeUtf8(c_value); - } - } - callback(event->error, value); - } - break; - } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); char log_msg[512]; @@ -880,50 +821,11 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { } case MPV_EVENT_PROPERTY_CHANGE: { auto* prop = static_cast(event->data); - mpv_node node{}; - node.format = prop->format; + mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop); - switch (prop->format) { - case MPV_FORMAT_STRING: - node.u.string = prop->data ? *static_cast(prop->data) : nullptr; - break; - case MPV_FORMAT_FLAG: - node.u.flag = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_INT64: - node.u.int64 = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_DOUBLE: - node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; - break; - case MPV_FORMAT_NODE: - if (prop->data) { - node = *static_cast(prop->data); - } - break; - default: - node.format = MPV_FORMAT_NONE; - break; - } - - if (strcmp(prop->name, "current-ao") == 0) { - const char* current_ao = nullptr; - if (prop->format == MPV_FORMAT_STRING && prop->data) { - current_ao = *static_cast(prop->data); - } - const bool is_null = current_ao && strcmp(current_ao, "null") == 0; - const auto transition = - audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now()); - if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) { - LogRecovery("current-ao fell back to null; starting recovery"); - } else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) { - LogRecovery("audio recovered (current-ao no longer null)"); - } - } - if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && - audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) { - LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload"); - } + // The 100ms mpv_wait_event tick already polls for scheduled reloads. + const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop); + if (notice.message) LogRecovery(notice.message); SendPropertyChange(prop->name, &node); break;