Merge the deduplication and dead-code removal pass

Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.

Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
This commit is contained in:
edde746
2026-07-26 19:41:23 +02:00
425 changed files with 12045 additions and 17543 deletions
@@ -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"
}
+17 -48
View File
@@ -25,10 +25,10 @@ on:
type: boolean type: boolean
env: env:
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} # Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }} FLUTTER_VERSION: "3.44.0"
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github # Shared by every release build command; SENTRY_DIST stays per-platform.
DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true 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 TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
LINUX_APT_PACKAGES: > LINUX_APT_PACKAGES: >
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev 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 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -110,7 +110,7 @@ jobs:
EOF EOF
- name: Build APKs - 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 - name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy' if: github.repository == 'edde746/plezy'
@@ -163,7 +163,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -188,7 +188,7 @@ jobs:
run: flutter pub get --enforce-lockfile --no-example run: flutter pub get --enforce-lockfile --no-example
- name: Build iOS (no codesign) - 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 - name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy' if: github.repository == 'edde746/plezy'
@@ -231,7 +231,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -256,7 +256,7 @@ jobs:
run: flutter pub get --enforce-lockfile --no-example run: flutter pub get --enforce-lockfile --no-example
- name: Build macOS - 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 - name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy' if: github.repository == 'edde746/plezy'
@@ -436,45 +436,14 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false 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' if: matrix.flutter_setup == 'git'
# Flutter publishes no windows-arm64 SDK archive, so fetch the release tag for uses: ./.github/actions/setup-flutter-git
# 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"
}
- name: Cache Pub dependencies - name: Cache Pub dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
@@ -495,7 +464,7 @@ jobs:
- name: Build Windows ${{ matrix.arch }} - name: Build Windows ${{ matrix.arch }}
shell: pwsh 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 - name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy' if: github.repository == 'edde746/plezy'
@@ -528,7 +497,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -641,7 +610,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: ${{ matrix.flutter_channel }} channel: ${{ matrix.flutter_channel }}
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -698,7 +667,7 @@ jobs:
- name: Build Linux ${{ matrix.arch }} - name: Build Linux ${{ matrix.arch }}
shell: bash 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: env:
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig
+14 -62
View File
@@ -10,6 +10,10 @@ on:
- main - main
workflow_dispatch: 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: jobs:
analyze: analyze:
name: Code Analysis name: Code Analysis
@@ -26,7 +30,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false pub-cache: false
@@ -53,31 +57,7 @@ jobs:
run: python3 scripts/clean_translations.py --check --strict run: python3 scripts/clean_translations.py --check --strict
- name: Verify workflow and script guards - name: Verify workflow and script guards
run: | run: bash scripts/ci_guard_checks.sh
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
- name: Verify formatting - name: Verify formatting
run: | run: |
@@ -131,7 +111,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false pub-cache: false
@@ -188,7 +168,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false pub-cache: false
@@ -282,7 +262,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false pub-cache: false
@@ -356,7 +336,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false pub-cache: false
@@ -479,41 +459,13 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false 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' if: matrix.flutter_setup == 'git'
shell: pwsh uses: ./.github/actions/setup-flutter-git
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"
}
- name: Install locked Dart dependencies - name: Install locked Dart dependencies
shell: pwsh shell: pwsh
@@ -567,7 +519,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false pub-cache: false
+5 -5
View File
@@ -243,7 +243,7 @@ jobs:
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with: with:
path: ~/.pub-cache 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 - name: Save Gradle cache
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
@@ -252,7 +252,7 @@ jobs:
path: | path: |
~/.gradle/caches ~/.gradle/caches
~/.gradle/wrapper ~/.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 - name: Save Maestro CLI cache
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
@@ -261,7 +261,7 @@ jobs:
path: | path: |
~/.maestro/bin ~/.maestro/bin
~/.maestro/lib ~/.maestro/lib
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }} key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
- name: Save Android 15 AVD cache - name: Save Android 15 AVD cache
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
@@ -270,7 +270,7 @@ jobs:
path: | path: |
~/.android/avd/maestro-api35.avd ~/.android/avd/maestro-api35.avd
~/.android/avd/maestro-api35.ini ~/.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 - name: Save Android 9 AVD cache
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
@@ -279,7 +279,7 @@ jobs:
path: | path: |
~/.android/avd/maestro-api28.avd ~/.android/avd/maestro-api28.avd
~/.android/avd/maestro-api28.ini ~/.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 - name: Upload Maestro diagnostics
if: always() if: always()
@@ -77,6 +77,7 @@ import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.MediaCodecQuery import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.PlayerSurfaceHost import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import org.chromium.net.CronetEngine 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 internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class ExoPlayerCore(private val activity: Activity) : Player.Listener { class ExoPlayerCore(private val activity: Activity) : Player.Listener, SurfacePlayerCore {
companion object { companion object {
private const val TAG = "ExoPlayerCore" private const val TAG = "ExoPlayerCore"
@@ -3501,7 +3502,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
else -> null else -> null
} }
fun setVisible(visible: Boolean) { override fun setVisible(visible: Boolean) {
if (disposing) return if (disposing) return
currentVisible = visible currentVisible = visible
activity.runOnUiThread { 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 if (disposing) return
activity.runOnUiThread { activity.runOnUiThread {
if (disposing) return@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 if (disposing) return
activity.runOnUiThread { activity.runOnUiThread {
if (disposing) return@runOnUiThread if (disposing) return@runOnUiThread
@@ -3625,15 +3626,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
// Audio Focus // Audio Focus
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false override fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
fun abandonAudioFocus() { override fun abandonAudioFocus() {
audioFocusManager?.abandonAudioFocus() audioFocusManager?.abandonAudioFocus()
} }
// Frame Rate Matching // Frame Rate Matching
fun setVideoFrameRate( override fun setVideoFrameRate(
fps: Float, fps: Float,
videoDurationMs: Long, videoDurationMs: Long,
extraDelayMs: Long, extraDelayMs: Long,
@@ -3649,7 +3650,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete) mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
} }
fun clearVideoFrameRate() { override fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate() frameRateManager?.clearVideoFrameRate()
} }
@@ -12,6 +12,7 @@ import com.edde746.plezy.shared.MpvContentUriResolver
import com.edde746.plezy.shared.PlayerChannelBinding import com.edde746.plezy.shared.PlayerChannelBinding
import com.edde746.plezy.shared.PlayerDelegate import com.edde746.plezy.shared.PlayerDelegate
import com.edde746.plezy.shared.ResolvedMpvUri 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.FlutterPlugin
import io.flutter.embedding.engine.plugins.activity.ActivityAware import io.flutter.embedding.engine.plugins.activity.ActivityAware
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
@@ -46,6 +47,10 @@ class ExoPlayerPlugin :
private var activity: Activity? = null private var activity: Activity? = null
private var activityBinding: ActivityPluginBinding? = 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 // Every Dart observeProperty registration, kept so an ExoPlayer→MPV
// fallback can re-observe exactly what Dart asked for instead of // fallback can re-observe exactly what Dart asked for instead of
// maintaining a parallel hard-coded list. // maintaining a parallel hard-coded list.
@@ -951,20 +956,12 @@ class ExoPlayerPlugin :
return return
} }
if (usingMpvFallback) { activeSurfaceCore?.setVisible(visible)
mpvCore?.setVisible(visible)
} else {
playerCore?.setVisible(visible)
}
result.success(null) result.success(null)
} }
private fun handleUpdateFrame(result: MethodChannel.Result) { private fun handleUpdateFrame(result: MethodChannel.Result) {
if (usingMpvFallback) { activeSurfaceCore?.updateFrame()
mpvCore?.updateFrame()
} else {
playerCore?.updateFrame()
}
result.success(null) result.success(null)
} }
@@ -976,51 +973,31 @@ class ExoPlayerPlugin :
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0 val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight") Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) } val core = activeSurfaceCore
if (usingMpvFallback) {
val core = mpvCore
if (core == null) { if (core == null) {
result.success(false) result.success(false)
} else { return
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)
} }
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
result.success(switched)
} }
} }
private fun handleClearVideoFrameRate(result: MethodChannel.Result) { private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate") Log.d(TAG, "clearVideoFrameRate")
if (usingMpvFallback) { activeSurfaceCore?.clearVideoFrameRate()
mpvCore?.clearVideoFrameRate()
} else {
playerCore?.clearVideoFrameRate()
}
result.success(null) result.success(null)
} }
private fun handleRequestAudioFocus(result: MethodChannel.Result) { private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus") Log.d(TAG, "requestAudioFocus")
val granted = if (usingMpvFallback) { val granted = activeSurfaceCore?.requestAudioFocus() ?: false
mpvCore?.requestAudioFocus() ?: false
} else {
playerCore?.requestAudioFocus() ?: false
}
result.success(granted) result.success(granted)
} }
private fun handleAbandonAudioFocus(result: MethodChannel.Result) { private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus") Log.d(TAG, "abandonAudioFocus")
if (usingMpvFallback) { activeSurfaceCore?.abandonAudioFocus()
mpvCore?.abandonAudioFocus()
} else {
playerCore?.abandonAudioFocus()
}
result.success(null) result.success(null)
} }
@@ -1230,11 +1207,7 @@ class ExoPlayerPlugin :
fun onPipModeChanged(isInPipMode: Boolean) { fun onPipModeChanged(isInPipMode: Boolean) {
activity?.runOnUiThread { activity?.runOnUiThread {
if (usingMpvFallback) { activeSurfaceCore?.onPipModeChanged(isInPipMode)
mpvCore?.onPipModeChanged(isInPipMode)
} else {
playerCore?.onPipModeChanged(isInPipMode)
}
} }
} }
@@ -19,6 +19,7 @@ import com.edde746.plezy.shared.AudioFocusManager
import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.PlayerDelegate import com.edde746.plezy.shared.PlayerDelegate
import com.edde746.plezy.shared.PlayerSurfaceHost import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import dev.jdtech.mpv.* import dev.jdtech.mpv.*
import kotlinx.coroutines.* import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
@@ -40,7 +41,7 @@ class MpvPlayerCore private constructor(
private val audioOnly: Boolean, private val audioOnly: Boolean,
private val propertyWriterOverride: (suspend (String, String) -> Unit)?, private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
initializedForTesting: Boolean initializedForTesting: Boolean
) : SurfaceHolder.Callback { ) : SurfaceHolder.Callback, SurfacePlayerCore {
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false) constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
internal constructor( internal constructor(
@@ -405,7 +406,7 @@ class MpvPlayerCore private constructor(
// Audio Focus // Audio Focus
fun requestAudioFocus(): Boolean { override fun requestAudioFocus(): Boolean {
val granted = audioFocusManager?.requestAudioFocus() ?: false val granted = audioFocusManager?.requestAudioFocus() ?: false
if (granted && pausedForAudioFocusLoss) { if (granted && pausedForAudioFocusLoss) {
resumeAfterAudioFocusGain("audio focus request granted") resumeAfterAudioFocusGain("audio focus request granted")
@@ -413,7 +414,7 @@ class MpvPlayerCore private constructor(
return granted return granted
} }
fun abandonAudioFocus() { override fun abandonAudioFocus() {
audioFocusManager?.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. // Audio-only: no render layer to show or hide — tolerated no-op.
if (audioOnly || disposing) return if (audioOnly || disposing) return
runOnMain { 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 // MPV handles aspect ratio internally via its own surface management
} }
fun updateFrame() { override fun updateFrame() {
// Audio-only: no surface to refresh — tolerated no-op. // Audio-only: no surface to refresh — tolerated no-op.
if (audioOnly || disposing) return if (audioOnly || disposing) return
runOnMain { runOnMain {
@@ -1160,7 +1161,7 @@ class MpvPlayerCore private constructor(
// Frame Rate Matching // Frame Rate Matching
fun setVideoFrameRate( override fun setVideoFrameRate(
fps: Float, fps: Float,
videoDurationMs: Long, videoDurationMs: Long,
extraDelayMs: Long, extraDelayMs: Long,
@@ -1182,7 +1183,7 @@ class MpvPlayerCore private constructor(
} }
} }
fun clearVideoFrameRate() { override fun clearVideoFrameRate() {
frameRateManager?.clearVideoFrameRate() frameRateManager?.clearVideoFrameRate()
} }
@@ -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
)
}
@@ -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<bool> 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<Connection> refresh(Connection connection);
/// Revoke the token server-side and forget local credentials. The caller
/// is responsible for removing the row from [ConnectionRegistry].
Future<void> signOut(Connection connection);
}
+1 -11
View File
@@ -29,7 +29,7 @@ class ConnectionBootstrap {
required this.profileRegistry, required this.profileRegistry,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher, Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher, Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers, }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo; _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
final StorageService storage; final StorageService storage;
@@ -283,16 +283,6 @@ class ConnectionBootstrap {
} }
} }
Future<List<PlexHomeUser>> _fetchPlexHomeUsers(String accountToken) async {
final auth = await PlexAuthService.create();
try {
final home = await auth.getHomeUsers(accountToken);
return home.users;
} finally {
auth.dispose();
}
}
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async { Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
final auth = await PlexAuthService.create(); final auth = await PlexAuthService.create();
try { try {
+140 -299
View File
@@ -246,197 +246,56 @@ class AppDatabase extends _$AppDatabase {
final joinRows = await (select( final joinRows = await (select(
profileConnections, profileConnections,
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get(); )..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 { return {
'connections': [ 'connections': [for (final row in connectionRows) row.toJson()],
for (final row in connectionRows) 'profiles': [for (final row in profileRows) row.toJson()],
{ 'profileConnections': [for (final row in joinRows) row.toJson()],
'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,
},
],
}; };
} }
Future<Map<String, Object?>> _readPendingRecoveryRows() async { Future<Map<String, Object?>> _readPendingRecoveryRows() async {
final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get(); final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
return { return {
'offlineWatchProgress': [ 'offlineWatchProgress': [for (final row in rows) row.toJson()],
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,
},
],
}; };
} }
Future<void> _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async { Future<void> _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async {
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', const { final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', ConnectionRow.fromJson);
'id', final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', ProfileRow.fromJson);
'kind', final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', ProfileConnectionRow.fromJson);
'displayName', final pendingRows = _decodeRecoveryRows(
'configJson', snapshot.pending,
'isDefault', 'offlineWatchProgress',
'createdAt', OfflineWatchProgressItem.fromJson,
'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',
});
// Recovery images from releases before the credential vault may contain // Recovery images from releases before the credential vault may contain
// plaintext secrets. Protect them before they cross into Drift; already // plaintext secrets. Protect them before they cross into Drift; already
// protected values remain byte-identical because vault protection is // protected values remain byte-identical because vault protection is
// idempotent. // idempotent.
for (final row in connectionRows) { for (var index = 0; index < connectionRows.length; index++) {
final kind = _requiredRecoveryValue<String>(row, 'kind'); final row = connectionRows[index];
final configJson = _requiredRecoveryValue<String>(row, 'configJson'); final decoded = jsonDecode(row.configJson);
final decoded = jsonDecode(configJson);
if (decoded is! Map<String, dynamic>) { if (decoded is! Map<String, dynamic>) {
throw const FormatException('Invalid connection configuration'); throw const FormatException('Invalid connection configuration');
} }
if (_containsPlaintextConnectionCredential(kind, decoded)) { if (_containsPlaintextConnectionCredential(row.kind, decoded)) {
row['configJson'] = jsonEncode(await CredentialVault.protectConnectionConfig(kind, decoded)); connectionRows[index] = row.copyWith(
configJson: jsonEncode(await CredentialVault.protectConnectionConfig(row.kind, decoded)),
);
} }
} }
for (final row in joinRows) { for (var index = 0; index < joinRows.length; index++) {
final token = _requiredRecoveryValue<String>(row, 'userToken'); final row = joinRows[index];
if (token.isNotEmpty && !CredentialVault.isProtected(token)) { if (row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken)) {
row['userToken'] = await CredentialVault.protect(token); joinRows[index] = row.copyWith(userToken: await CredentialVault.protect(row.userToken));
} }
} }
final connectionCompanions = [
for (final row in connectionRows)
ConnectionsCompanion(
id: Value(_requiredRecoveryValue<String>(row, 'id')),
kind: Value(_requiredRecoveryValue<String>(row, 'kind')),
displayName: Value(_requiredRecoveryValue<String>(row, 'displayName')),
configJson: Value(_requiredRecoveryValue<String>(row, 'configJson')),
isDefault: Value(_requiredRecoveryValue<bool>(row, 'isDefault')),
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
lastAuthenticatedAt: Value(_nullableRecoveryValue<int>(row, 'lastAuthenticatedAt')),
),
];
final profileCompanions = [
for (final row in profileRows)
ProfilesCompanion(
id: Value(_requiredRecoveryValue<String>(row, 'id')),
kind: Value(_requiredRecoveryValue<String>(row, 'kind')),
displayName: Value(_requiredRecoveryValue<String>(row, 'displayName')),
avatarThumbUrl: Value(_nullableRecoveryValue<String>(row, 'avatarThumbUrl')),
configJson: Value(_requiredRecoveryValue<String>(row, 'configJson')),
sortOrder: Value(_requiredRecoveryValue<int>(row, 'sortOrder')),
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
lastUsedAt: Value(_nullableRecoveryValue<int>(row, 'lastUsedAt')),
),
];
final joinCompanions = [
for (final row in joinRows)
ProfileConnectionsCompanion(
profileId: Value(_requiredRecoveryValue<String>(row, 'profileId')),
connectionId: Value(_requiredRecoveryValue<String>(row, 'connectionId')),
userToken: Value(_requiredRecoveryValue<String>(row, 'userToken')),
userIdentifier: Value(_requiredRecoveryValue<String>(row, 'userIdentifier')),
isDefault: Value(_requiredRecoveryValue<bool>(row, 'isDefault')),
tokenAcquiredAt: Value(_nullableRecoveryValue<int>(row, 'tokenAcquiredAt')),
lastUsedAt: Value(_nullableRecoveryValue<int>(row, 'lastUsedAt')),
),
];
final pendingCompanions = [
for (final row in pendingRows)
OfflineWatchProgressCompanion(
id: Value(_requiredRecoveryValue<int>(row, 'id')),
profileId: Value(_nullableRecoveryValue<String>(row, 'profileId')),
serverId: Value(_requiredRecoveryValue<String>(row, 'serverId')),
clientScopeId: Value(_nullableRecoveryValue<String>(row, 'clientScopeId')),
ratingKey: Value(_requiredRecoveryValue<String>(row, 'ratingKey')),
globalKey: Value(_requiredRecoveryValue<String>(row, 'globalKey')),
actionType: Value(_requiredRecoveryValue<String>(row, 'actionType')),
viewOffset: Value(_nullableRecoveryValue<int>(row, 'viewOffset')),
duration: Value(_nullableRecoveryValue<int>(row, 'duration')),
shouldMarkWatched: Value(_requiredRecoveryValue<bool>(row, 'shouldMarkWatched')),
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
updatedAt: Value(_requiredRecoveryValue<int>(row, 'updatedAt')),
syncAttempts: Value(_requiredRecoveryValue<int>(row, 'syncAttempts')),
lastError: Value(_nullableRecoveryValue<String>(row, 'lastError')),
),
];
await transaction(() async { await transaction(() async {
// Recovery completion (the durable marker removal) is deliberately // Recovery completion (the durable marker removal) is deliberately
// separate from this transaction. Replace the snapshot-owned rows so a // 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(profiles).go();
await delete(connections).go(); await delete(connections).go();
await delete(offlineWatchProgress).go(); await delete(offlineWatchProgress).go();
for (final row in connectionCompanions) { // `toCompanion(false)` writes every column explicitly, including the
await into(connections).insert(row); // 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) { for (final row in profileRows) {
await into(profiles).insert(row); await into(profiles).insert(row.toCompanion(false));
} }
for (final row in joinCompanions) { for (final row in joinRows) {
await into(profileConnections).insert(row); await into(profileConnections).insert(row.toCompanion(false));
} }
for (final row in pendingCompanions) { for (final row in pendingRows) {
await into(offlineWatchProgress).insert(row); await into(offlineWatchProgress).insert(row.toCompanion(false));
} }
}); });
} }
static List<Map<String, Object?>> _decodeRecoveryRows( static List<T> _decodeRecoveryRows<T extends DataClass>(
Map<String, Object?> group, Map<String, Object?> group,
String key, String key,
Set<String> expectedKeys, T Function(Map<String, dynamic> json) fromJson,
) { ) {
final value = group[key]; final value = group[key];
if (value is! List) throw const FormatException('Invalid tvOS database recovery image'); if (value is! List) throw _invalidRecoveryImage;
return [ return [
for (final value in value) for (final row in value)
if (value is Map<String, Object?> && if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
value.keys.toSet().containsAll(expectedKeys) &&
value.length == expectedKeys.length)
value
else
throw const FormatException('Invalid tvOS database recovery image'),
]; ];
} }
static T _requiredRecoveryValue<T>(Map<String, Object?> row, String key) { /// Reads one row through drift's generated deserializer and rejects anything
final value = row[key]; /// that does not round-trip back to the exact same map. Drift already throws
if (!row.containsKey(key) || value is! T) { /// on a missing or mistyped required column; the round-trip additionally
throw const FormatException('Invalid tvOS database recovery image'); /// rejects unknown and missing-but-nullable columns, which the serializer
/// would otherwise accept silently.
static T _decodeRecoveryRow<T extends DataClass>(
Map<String, dynamic> row,
T Function(Map<String, dynamic> 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<T>(Map<String, Object?> row, String key) { static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
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;
}
@override @override
int get schemaVersion => 20; int get schemaVersion => 20;
@@ -658,89 +520,27 @@ class AppDatabase extends _$AppDatabase {
} }
if (from < 17) { if (from < 17) {
appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)'); appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)');
await customStatement(''' await customStatement(
WITH download_metadata_ids AS ( _rescopePinnedPlexMetadataStatement(
SELECT global_key, server_id, rating_key AS metadata_id namespaceExpression: "'/~plex-profile/' || owner.profile_id || ':'",
FROM downloaded_media ownerJoin: '''JOIN download_owners AS owner
UNION ON owner.global_key = metadata.global_key''',
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
''');
// A direct pre-v14 upgrade has no owners yet: profiles and owner // A direct pre-v14 upgrade has no owners yet: profiles and owner
// adoption are bootstrapped only after the database opens. Preserve // adoption are bootstrapped only after the database opens. Preserve
// those downloads in the neutral Plex transfer namespace so the // those downloads in the neutral Plex transfer namespace so the
// first profile can adopt them without inheriting legacy watch data. // first profile can adopt them without inheriting legacy watch data.
await customStatement(''' await customStatement(
WITH download_metadata_ids AS ( _rescopePinnedPlexMetadataStatement(
SELECT global_key, server_id, rating_key AS metadata_id namespaceExpression: "'/~plex-transfer:'",
FROM downloaded_media ownerFilter: '''AND NOT EXISTS (
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 SELECT 1
FROM download_owners AS owner FROM download_owners AS owner
WHERE owner.global_key = metadata.global_key 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
''');
final transferRows = await customSelect(''' final transferRows = await customSelect('''
SELECT cache_key, data SELECT cache_key, data
@@ -941,10 +741,6 @@ class AppDatabase extends _$AppDatabase {
} }
} }
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
}
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) { Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
return value == null ? column.isNull() : column.equals(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 /// Get pending watch actions for a specific server
@visibleForTesting
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) { Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) {
return (select(offlineWatchProgress) return (select(offlineWatchProgress)
..where( ..where(
@@ -994,12 +791,13 @@ class AppDatabase extends _$AppDatabase {
(t) => (t) =>
matchesKey(t) & matchesKey(t) &
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) & (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)]); ..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]);
} }
/// Get the latest action for a specific item /// Get the latest action for a specific item
@visibleForTesting
Future<OfflineWatchProgressItem?> getLatestWatchAction( Future<OfflineWatchProgressItem?> getLatestWatchAction(
String globalKey, { String globalKey, {
String? profileId, String? profileId,
@@ -1102,7 +900,7 @@ class AppDatabase extends _$AppDatabase {
(t) => (t) =>
t.globalKey.equals(globalKey) & t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) & _nullableTextPredicate(t.profileId, profileId) &
_clientScopePredicate(t.clientScopeId, clientScopeId) & _nullableTextPredicate(t.clientScopeId, clientScopeId) &
t.actionType.equals(OfflineActionType.progress.id), t.actionType.equals(OfflineActionType.progress.id),
) )
..orderBy([(t) => OrderingTerm.asc(t.id)])) ..orderBy([(t) => OrderingTerm.asc(t.id)]))
@@ -1164,7 +962,7 @@ class AppDatabase extends _$AppDatabase {
(t) => (t) =>
t.globalKey.equals(globalKey) & t.globalKey.equals(globalKey) &
_nullableTextPredicate(t.profileId, profileId) & _nullableTextPredicate(t.profileId, profileId) &
_clientScopePredicate(t.clientScopeId, clientScopeId), _nullableTextPredicate(t.clientScopeId, clientScopeId),
)) ))
.go(); .go();
@@ -1384,29 +1182,21 @@ class AppDatabase extends _$AppDatabase {
} }
} }
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async { Future<void> _writeSyncRule(String globalKey, SyncRulesCompanion values) async {
await (update( await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values);
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
} }
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async { Future<void> updateSyncRuleCount(String globalKey, int episodeCount) =>
await (update( _writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount)));
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
}
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async { Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) =>
await (update( _writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
syncRules,
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
}
Future<void> updateSyncRuleLastExecuted(String globalKey) async { Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( _writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
); Future<void> updateSyncRuleLastExecuted(String globalKey) =>
} _writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
Future<void> completeSyncRuleExecution(String globalKey) { Future<void> completeSyncRuleExecution(String globalKey) {
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( 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<File> _resolveProductionDatabaseFile() async { Future<File> _resolveProductionDatabaseFile() async {
final dbFolder = (Platform.isAndroid || Platform.isIOS) final dbFolder = (Platform.isAndroid || Platform.isIOS)
? await getApplicationDocumentsDirectory() ? await getApplicationDocumentsDirectory()
+3 -58
View File
@@ -1,6 +1,7 @@
import 'dart:convert'; import 'dart:convert';
import 'package:drift/drift.dart'; import 'package:drift/drift.dart';
import 'package:flutter/foundation.dart';
import '../media/ids.dart'; import '../media/ids.dart';
import 'app_database.dart'; import 'app_database.dart';
@@ -147,6 +148,7 @@ extension DownloadDatabaseOperations on AppDatabase {
return (await _validDownloadOwnerRows(globalKey)).length; return (await _validDownloadOwnerRows(globalKey)).length;
} }
@visibleForTesting
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async { Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId); final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
return rows.isNotEmpty; return rows.isNotEmpty;
@@ -278,64 +280,6 @@ extension DownloadDatabaseOperations on AppDatabase {
} }
} }
Future<void> 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<String>(serverId),
Variable<String>(clientScopeId),
Variable<String>(ratingKey),
Variable<String>(globalKey),
Variable<String>(type),
Variable<String>(parentRatingKey),
Variable<String>(grandparentRatingKey),
Variable<int>(status),
Variable<int>(mediaIndex),
Variable<String>(mediaSourceId),
],
updates: {downloadedMedia},
);
}
Future<void> addToQueue({ Future<void> addToQueue({
required String mediaGlobalKey, required String mediaGlobalKey,
int priority = 0, int priority = 0,
@@ -582,6 +526,7 @@ extension DownloadDatabaseOperations on AppDatabase {
return (await query.map((row) => row.read(count) ?? 0).getSingle()); return (await query.map((row) => row.read(count) ?? 0).getSingle());
} }
@visibleForTesting
Future<Set<String>> getReferencedDownloadSafRoots() async { Future<Set<String>> getReferencedDownloadSafRoots() async {
final rows = final rows =
await (selectOnly(downloadedMedia) await (selectOnly(downloadedMedia)
+1 -1
View File
@@ -218,7 +218,7 @@ class ProfileConnections extends Table {
// Profile.virtualPlexHome from PlexHomeService's live cache, never // Profile.virtualPlexHome from PlexHomeService's live cache, never
// persisted in `profiles`), so an FK here would reject every join row // persisted in `profiles`), so an FK here would reject every join row
// they need. Profile deletion instead cleans up join rows explicitly // they need. Profile deletion instead cleans up join rows explicitly
// (removeAllProfileConnectionsAndCleanup in profile_connection_cleanup) // (ProfileConnectionCleanup.removeAllProfileConnections)
// before calling ProfileRegistry.remove. // before calling ProfileRegistry.remove.
TextColumn get profileId => text()(); TextColumn get profileId => text()();
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)(); TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
+2 -2
View File
@@ -5,8 +5,8 @@ import 'focus_theme.dart';
/// Exposes the focus state of an enclosing focus wrapper to a descendant /// Exposes the focus state of an enclosing focus wrapper to a descendant
/// [CardFocusBorder] that draws the focus border itself. /// [CardFocusBorder] that draws the focus border itself.
/// ///
/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this /// The shared focus chrome ([buildFocusChrome]) inserts this instead of painting
/// instead of painting a border when `delegateFocusBorder` is set, so cards can /// a border when `delegateFocusBorder` is set, so cards can
/// put the border on the exact rect the design highlights (the poster image, /// put the border on the exact rect the design highlights (the poster image,
/// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder] /// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder]
/// element registers a dependency, so a focus flip rebuilds just that border /// element registers a dependency, so a focus flip rebuilds just that border
+207
View File
@@ -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<E, W extends StatefulWidget> on State<W> {
/// 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<E>? _originalOrder;
bool _backKeyDownSeen = false;
/// The list being reordered. Mutated in place while moving and replaced
/// wholesale when a move is cancelled.
List<E> get reorderItems;
set reorderItems(List<E> 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<E>.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<E>.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();
}
}
+59
View File
@@ -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;
}
+26 -16
View File
@@ -32,22 +32,18 @@ class ChipKeyCallbacks {
/// This mixin handles: /// This mixin handles:
/// - Internal/external FocusNode pattern /// - Internal/external FocusNode pattern
/// - `_isFocused` state tracking /// - `_isFocused` state tracking
/// - Listener setup in `initState` /// - Listener setup, handoff and cleanup across the State lifecycle
/// - Listener handoff in `didUpdateWidget`
/// - Cleanup in `dispose`
/// ///
/// To use this mixin: /// To use this mixin:
/// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class /// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class
/// 2. Implement [widgetFocusNode] to return the widget's optional focusNode /// 2. Implement [widgetFocusNode] to return the widget's optional focusNode
/// 3. Implement [debugLabel] to return a debug label for the internal node /// 3. Implement [debugLabel] to return a debug label for the internal node
/// 4. Call [initFocusNode] in your `initState` /// 4. Use [focusNode] and [isFocused] in your build method
/// 5. Call [updateFocusNode] in your `didUpdateWidget`
/// 6. Call [disposeFocusNode] in your `dispose`
/// 7. Use [focusNode] and [isFocused] in your build method
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> { mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final _focusNodeBinding = OwnedFocusNodeBinding(); final _focusNodeBinding = OwnedFocusNodeBinding();
bool _isFocused = false; bool _isFocused = false;
final _selectLongPress = DpadSelectLongPressController(); final _selectLongPress = DpadSelectLongPressController();
FocusNode? _boundExternalNode;
/// Override to return the widget's optional external focus node. /// Override to return the widget's optional external focus node.
FocusNode? get widgetFocusNode; FocusNode? get widgetFocusNode;
@@ -61,22 +57,30 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
/// Whether this widget is currently focused. /// Whether this widget is currently focused.
bool get isFocused => _isFocused; bool get isFocused => _isFocused;
/// Call this in your `initState` to set up the focus listener. @override
void initFocusNode() { void initState() {
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); super.initState();
_bindFocusNode();
} }
/// Call this in your `didUpdateWidget` with the old widget's focusNode. @override
void updateFocusNode(FocusNode? oldFocusNode) { void didUpdateWidget(T oldWidget) {
if (oldFocusNode != widgetFocusNode) { super.didUpdateWidget(oldWidget);
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); if (_boundExternalNode != widgetFocusNode) {
_bindFocusNode();
} }
} }
/// Call this in your `dispose` to clean up the focus listener. @override
void disposeFocusNode() { void dispose() {
_focusNodeBinding.dispose(); _focusNodeBinding.dispose();
_selectLongPress.dispose(); _selectLongPress.dispose();
super.dispose();
}
void _bindFocusNode() {
_boundExternalNode = widgetFocusNode;
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
} }
void _onFocusChange() { void _onFocusChange() {
@@ -102,6 +106,12 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
/// ///
/// Returns [KeyEventResult.handled] if the event was consumed, /// Returns [KeyEventResult.handled] if the event was consumed,
/// [KeyEventResult.ignored] otherwise. /// [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) { KeyEventResult handleChipKeyEvent(FocusNode _, KeyEvent event, ChipKeyCallbacks callbacks) {
final key = event.logicalKey; final key = event.logicalKey;
+18 -6
View File
@@ -7,23 +7,35 @@ import 'owned_focus_node_binding.dart';
/// auto-scrolls the tile into view when it gains focus. /// auto-scrolls the tile into view when it gains focus.
mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> { mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> {
final _focusNodeBinding = OwnedFocusNodeBinding(); final _focusNodeBinding = OwnedFocusNodeBinding();
FocusNode? _boundExternalNode;
FocusNode? get widgetFocusNode; FocusNode? get widgetFocusNode;
FocusNode get effectiveFocusNode => _focusNodeBinding.node; FocusNode get effectiveFocusNode => _focusNodeBinding.node;
void initFocusNode() { @override
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); void initState() {
super.initState();
_bindFocusNode();
} }
void updateFocusNode(FocusNode? oldFocusNode) { @override
if (oldFocusNode != widgetFocusNode) { void didUpdateWidget(T oldWidget) {
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange); super.didUpdateWidget(oldWidget);
if (_boundExternalNode != widgetFocusNode) {
_bindFocusNode();
} }
} }
void disposeFocusNode() { @override
void dispose() {
_focusNodeBinding.dispose(); _focusNodeBinding.dispose();
super.dispose();
}
void _bindFocusNode() {
_boundExternalNode = widgetFocusNode;
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange);
} }
void _onFocusChange() { void _onFocusChange() {
+18 -35
View File
@@ -4,10 +4,9 @@ import 'package:flutter/rendering.dart';
import '../widgets/clickable_cursor.dart'; import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart'; import '../utils/text_input_diagnostics.dart';
import 'card_focus_scope.dart';
import 'dpad_navigator.dart'; import 'dpad_navigator.dart';
import 'dpad_select_long_press_controller.dart'; import 'dpad_select_long_press_controller.dart';
import 'focus_glow_overlay.dart'; import 'focus_chrome.dart';
import 'focus_theme.dart'; import 'focus_theme.dart';
import 'input_mode_tracker.dart'; import 'input_mode_tracker.dart';
import 'owned_focus_node_binding.dart'; import 'owned_focus_node_binding.dart';
@@ -426,6 +425,11 @@ class _FocusableWrapperState extends State<FocusableWrapper> 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) { KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) {
final key = event.logicalKey; final key = event.logicalKey;
final diagnosticsEnabled = TextInputDiagnostics.enabled; final diagnosticsEnabled = TextInputDiagnostics.enabled;
@@ -552,41 +556,20 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
// Keep the card subtree outside the scale builder. Rebuilding media-card // Keep the card subtree outside the scale builder. Rebuilding media-card
// semantics on every animation tick is substantially more expensive than // semantics on every animation tick is substantially more expensive than
// changing the paint transform alone on dense TV grids. // 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( inner = AnimatedBuilder(
animation: _scaleAnimation!, 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!), builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!),
); );
} }
+20 -41
View File
@@ -24,6 +24,7 @@ import 'profiles/profile.dart';
import 'profiles/profile_connection_cleanup.dart'; import 'profiles/profile_connection_cleanup.dart';
import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_connection_registry.dart';
import 'profiles/profile_registry.dart'; import 'profiles/profile_registry.dart';
import 'profiles/profile_selection_policy.dart';
import 'mixins/mounted_set_state_mixin.dart'; import 'mixins/mounted_set_state_mixin.dart';
import 'theme/mono_theme.dart'; import 'theme/mono_theme.dart';
import 'profiles/plex_home_service.dart'; import 'profiles/plex_home_service.dart';
@@ -1043,7 +1044,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return provider; return provider;
}, },
update: (_, multiServerProvider, previous) { update: (_, multiServerProvider, previous) {
final provider = previous ?? OfflineModeProvider(_serverManager, multiServerProvider: multiServerProvider); final provider = previous!;
provider.updateMultiServerProvider(multiServerProvider); provider.updateMultiServerProvider(multiServerProvider);
provider.initialize(); // Idempotent - safe to call again provider.initialize(); // Idempotent - safe to call again
return provider; return provider;
@@ -1069,8 +1070,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
pinPrompt: _rootPinPrompt, pinPrompt: _rootPinPrompt,
shouldDeferInitialBind: (_) async { shouldDeferInitialBind: (_) async {
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
return settings.read(SettingsService.requireProfileSelectionOnOpen) && return activeProfile.requiresSelectionOnOpen(settings);
activeProfile.hasMultipleProfiles;
}, },
); );
}, },
@@ -1081,7 +1081,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>( ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>(
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase), create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
update: (context, activeProfile, previous) { update: (context, activeProfile, previous) {
final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase); final provider = previous!;
provider.setActiveProfileId(activeProfile.activeId); provider.setActiveProfileId(activeProfile.activeId);
return provider; return provider;
}, },
@@ -1134,7 +1134,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
return _offlineWatchSyncService; return _offlineWatchSyncService;
}, },
update: (_, activeProfile, previous) { update: (_, activeProfile, previous) {
final provider = previous ?? _offlineWatchSyncService; final provider = previous!;
provider.setActiveProfileId( provider.setActiveProfileId(
activeProfile.activeId, activeProfile.activeId,
availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null, availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null,
@@ -1147,14 +1147,12 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
syncService: context.read<OfflineWatchSyncService>(), syncService: context.read<OfflineWatchSyncService>(),
downloadProvider: context.read<DownloadProvider>(), downloadProvider: context.read<DownloadProvider>(),
), ),
update: (_, syncService, downloadProvider, previous) { update: (_, syncService, downloadProvider, previous) => previous!,
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
},
), ),
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>( ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
create: (context) => UserProfileProvider(storageService: context.read<StorageService>()), create: (context) => UserProfileProvider(storageService: context.read<StorageService>()),
update: (context, activeProfile, connections, previous) { update: (context, activeProfile, connections, previous) {
final provider = previous ?? UserProfileProvider(storageService: context.read<StorageService>()); final provider = previous!;
provider.attach( provider.attach(
connections: connections, connections: connections,
activeProfile: activeProfile, activeProfile: activeProfile,
@@ -1213,7 +1211,7 @@ class _AppShell extends StatelessWidget {
themeMode: themeProvider.materialThemeMode, themeMode: themeProvider.materialThemeMode,
navigatorKey: rootNavigatorKey, navigatorKey: rootNavigatorKey,
navigatorObservers: [BackKeySuppressorObserver()], navigatorObservers: [BackKeySuppressorObserver()],
home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome), home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome),
// Siri Remote select + gamepad A report as // Siri Remote select + gamepad A report as
// LogicalKeyboardKey.{select,gameButtonA} which aren't // LogicalKeyboardKey.{select,gameButtonA} which aren't
// in Flutter's default shortcut set — Material-level // in Flutter's default shortcut set — Material-level
@@ -1300,32 +1298,6 @@ bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired; return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
} }
class OrientationAwareSetup extends StatefulWidget {
const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome});
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
@override
State<OrientationAwareSetup> createState() => _OrientationAwareSetupState();
}
class _OrientationAwareSetupState extends State<OrientationAwareSetup> {
@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 { class SetupScreen extends StatefulWidget {
const SetupScreen({ const SetupScreen({
super.key, super.key,
@@ -1356,6 +1328,15 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
_loadSavedCredentials(); _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) { void _setStatus(String message) {
setStateIfMounted(() => _statusMessage = message); setStateIfMounted(() => _statusMessage = message);
} }
@@ -1418,12 +1399,12 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
profileRegistry: profileRegistry, profileRegistry: profileRegistry,
); );
await bootstrap.run(); await bootstrap.run();
final pruned = await pruneUnreferencedJellyfinConnections( final pruned = await ProfileConnectionCleanup(
profileConnections: profileConnections, profileConnections: profileConnections,
connections: connRegistry, connections: connRegistry,
storage: storage, storage: storage,
serverManager: serverManager, serverManager: serverManager,
); ).pruneUnreferencedJellyfinConnections();
if (pruned > 0) { if (pruned > 0) {
appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}'); appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}');
} }
@@ -1561,9 +1542,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
final settings = await SettingsService.getInstance(); final settings = await SettingsService.getInstance();
if (!mounted) return; if (!mounted) return;
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
final requireOnOpen = final shouldPrompt = hasNoActive || activeProfile.requiresSelectionOnOpen(settings);
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
final shouldPrompt = hasNoActive || requireOnOpen;
var bindingSucceeded = activeProfile.lastBindingSucceeded; var bindingSucceeded = activeProfile.lastBindingSucceeded;
if (shouldPrompt) { if (shouldPrompt) {
+40
View File
@@ -29,6 +29,46 @@ Future<void> 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<void> collectListLeaves(
MediaServerClient client,
List<MediaItem> items, {
required bool unwatchedOnly,
required List<MediaItem> 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. /// Fetch just the first episode of a season without walking the entire season.
/// Use this for representative lookups and immediate "play first" actions. /// Use this for representative lookups and immediate "play first" actions.
Future<MediaItem?> fetchFirstEpisodeForSeason( Future<MediaItem?> fetchFirstEpisodeForSeason(
+31
View File
@@ -1,6 +1,7 @@
// ignore_for_file: invalid_annotation_target // ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../utils/media_server_http_client.dart' show AbortController;
import 'media_kind.dart'; import 'media_kind.dart';
part 'library_query.freezed.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; final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
return offset + itemCount + (fullPage ? 1 : 0); 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<List<T>> drainPages<T>(
Future<LibraryPage<T>> Function(int start, int size) fetchPage, {
required int pageSize,
AbortController? abort,
bool stopOnShortPage = false,
}) async {
final all = <T>[];
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;
}
-68
View File
@@ -1,13 +1,8 @@
import '../models/livetv_capture_buffer.dart'; import '../models/livetv_capture_buffer.dart';
import '../models/livetv_channel.dart'; import '../models/livetv_channel.dart';
import '../models/livetv_dvr.dart'; import '../models/livetv_dvr.dart';
import '../models/livetv_lineup.dart';
import '../models/livetv_program.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_grab_operation.dart';
import '../models/media_grabber_device.dart';
import '../models/media_provider_info.dart';
import '../models/media_subscription.dart'; import '../models/media_subscription.dart';
class LiveTvActivityResult<T> { class LiveTvActivityResult<T> {
@@ -202,67 +197,13 @@ abstract class LiveTvSupport {
/// recording APIs. /// recording APIs.
abstract class LiveTvDvrSupport { abstract class LiveTvDvrSupport {
Future<List<LiveTvDvr>> fetchDvrs(); Future<List<LiveTvDvr>> fetchDvrs();
Future<LiveTvServerStatus> fetchLiveTvServerStatus();
Future<LiveTvDvr?> fetchDvr(String dvrId);
Future<LiveTvActivityResult<LiveTvDvr?>> createDvr({
required List<String> devices,
required List<String> lineups,
String? language,
String? country,
String? postalCode,
});
Future<void> deleteDvr(String dvrId);
Future<void> updateDvrPrefs(String dvrId, Map<String, Object?> prefs);
Future<void> attachDeviceToDvr(String dvrId, String deviceId);
Future<void> detachDeviceFromDvr(String dvrId, String deviceId);
Future<void> addLineupToDvr(String dvrId, String lineupUri);
Future<void> removeLineupFromDvr(String dvrId, String lineupUri);
Future<LiveTvActivityResult<void>> reloadGuide(String dvrId); Future<LiveTvActivityResult<void>> reloadGuide(String dvrId);
Future<void> cancelGuideReload(String dvrId);
Future<List<MediaGrabber>> fetchGrabbers({String? protocol});
Future<List<MediaGrabberDevice>> fetchGrabberDevices();
Future<LiveTvActivityResult<List<MediaGrabberDevice>>> discoverGrabberDevices();
Future<MediaGrabberDevice?> fetchGrabberDevice(String deviceId);
Future<MediaGrabberDevice?> addGrabberDevice(String uri, {String? grabberId});
Future<void> updateGrabberDevice(String deviceId, {bool? enabled, String? title});
Future<void> deleteGrabberDevice(String deviceId);
Future<List<MediaGrabberDeviceChannel>> fetchGrabberDeviceChannels(String deviceId);
Future<LiveTvActivityResult<MediaGrabberDevice?>> scanGrabberDevice(
String deviceId, {
String? source,
Map<String, Object?> prefs = const {},
String? network,
String? country,
});
Future<MediaGrabberDevice?> cancelGrabberDeviceScan(String deviceId);
Future<MediaGrabberDevice?> saveGrabberDeviceChannelMap(String deviceId, MediaGrabberChannelMapRequest request);
Future<void> updateGrabberDevicePrefs(String deviceId, Map<String, Object?> prefs);
String buildGrabberDeviceThumbUrl(String deviceId, int version);
Future<List<LiveTvCountry>> fetchEpgCountries();
Future<List<LiveTvLanguage>> fetchEpgLanguages();
Future<List<LiveTvRegion>> fetchEpgRegions(String country, String epgId);
Future<LiveTvLineupResult> fetchEpgLineups(String country, String epgId, {String? postalCode, String? region});
Future<List<LiveTvChannel>> fetchEpgChannelsForLineup(String lineupUri);
Future<List<LiveTvLineup>> fetchEpgChannelsForLineups(List<String> lineupUris);
Future<List<ChannelMapping>> computeEpgChannelMap({required String deviceUri, required String lineupUri});
Future<LiveTvActivityResult<Map<String, dynamic>?>> findBestLineup({
required String deviceUri,
required String lineupGroupUri,
});
Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid); Future<List<SubscriptionTemplate>> getSubscriptionTemplate(String guid);
Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true}); Future<List<MediaSubscription>> fetchRecordingRules({bool includeGrabs = true, bool includeStorage = true});
Future<MediaSubscription?> fetchRecordingRule(
String subscriptionId, {
bool includeGrabs = true,
bool includeStorage = true,
});
Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request); Future<MediaSubscription?> createRecordingRule(MediaSubscriptionCreateRequest request);
Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs); Future<MediaSubscription?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs);
Future<void> deleteRecordingRule(String subscriptionId); Future<void> deleteRecordingRule(String subscriptionId);
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId});
Future<void> processRecordingRules(); Future<void> processRecordingRules();
Future<List<MediaGrabOperation>> fetchScheduledRecordings(); Future<List<MediaGrabOperation>> fetchScheduledRecordings();
Future<void> cancelGrab(String operationId); Future<void> cancelGrab(String operationId);
@@ -271,13 +212,4 @@ abstract class LiveTvDvrSupport {
required List<String> ratingKeys, required List<String> ratingKeys,
bool includeStorage = true, bool includeStorage = true,
}); });
Future<List<MediaProviderInfo>> fetchMediaProviders();
Future<void> registerMediaProvider(String url);
Future<void> refreshMediaProviders();
Future<void> unregisterMediaProvider(String providerId);
Future<List<LiveTvSession>> fetchLiveTvSessionsDetailed();
Future<LiveTvSession?> fetchLiveTvSession(String sessionId);
Uri buildNotificationWebSocketUri({List<String>? filters});
Uri buildNotificationEventSourceUri({List<String>? filters});
} }
-7
View File
@@ -676,13 +676,6 @@ sealed class MediaItem with _$MediaItem {
return resolvedBackdropPaths; 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. /// Returns hero art candidates in display-preference order.
List<String> heroArtCandidates({required double containerAspectRatio}) { List<String> heroArtCandidates({required double containerAspectRatio}) {
final own = resolvedBackdropPaths; final own = resolvedBackdropPaths;
-2
View File
@@ -26,8 +26,6 @@ enum MediaKind {
bool get isVideo => this == movie || this == episode || this == clip; 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 isMusic => this == artist || this == album || this == track;
bool get isPlayable => isVideo || this == track; bool get isPlayable => isVideo || this == track;
-5
View File
@@ -61,11 +61,6 @@ class MediaPlaylist {
/// Display-friendly title (alias of [title] for parity with [MediaItem]). /// Display-friendly title (alias of [title] for parity with [MediaItem]).
String get displayTitle => title; 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; String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id;
MediaPlaylist copyWith({ MediaPlaylist copyWith({
+1 -4
View File
@@ -265,7 +265,7 @@ abstract class MediaServerClient {
/// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a /// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a
/// sidecar-lyrics track stream (`streamType 4`) fetched from /// sidecar-lyrics track stream (`streamType 4`) fetched from
/// `/library/streams/{id}` and parsed from LRC. Synced-ness is per /// `/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<Lyrics?> fetchLyrics(MediaItem track); Future<Lyrics?> fetchLyrics(MediaItem track);
/// Free-text search across the user's libraries. [limit] is a per-request /// 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. /// backend request owned by this search pass.
Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort}); Future<List<MediaItem>> searchItems(String query, {int limit = 100, AbortController? abort});
/// Recently-added items across all libraries.
Future<List<MediaItem>> fetchRecentlyAdded({int limit = 50});
/// Items the user has started but not finished. Plex calls this "On Deck" /// Items the user has started but not finished. Plex calls this "On Deck"
/// internally; the neutral name matches the Continue Watching UI surface. /// internally; the neutral name matches the Continue Watching UI surface.
Future<List<MediaItem>> fetchContinueWatching({int? count = 20}); Future<List<MediaItem>> fetchContinueWatching({int? count = 20});
-1
View File
@@ -275,7 +275,6 @@ class MediaMarker {
Duration get startTime => Duration(milliseconds: startTimeOffset); Duration get startTime => Duration(milliseconds: startTimeOffset);
Duration get endTime => Duration(milliseconds: endTimeOffset); Duration get endTime => Duration(milliseconds: endTimeOffset);
bool get isIntro => type == 'intro';
bool get isCredits => type == 'credits'; bool get isCredits => type == 'credits';
bool containsPosition(Duration position) { bool containsPosition(Duration position) {
+16 -144
View File
@@ -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 /// Static capability flags advertised by a [MediaServerClient]. UI consults
/// these to gate feature affordances per server (e.g. hide Live TV when no /// these to gate feature affordances per server (e.g. hide Live TV when no
/// connected server supports it). /// connected server supports it).
@@ -21,14 +7,6 @@ enum AlphaBarMode {
/// Jellyfin features are wired in over time, the corresponding flags flip /// Jellyfin features are wired in over time, the corresponding flags flip
/// without changing call sites. /// without changing call sites.
class ServerCapabilities { 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 /// 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 — /// a *specific* server has Live TV configured is a runtime concern —
/// [MultiServerProvider.checkLiveTvAvailability] probes each server and /// [MultiServerProvider.checkLiveTvAvailability] probes each server and
@@ -41,17 +19,9 @@ class ServerCapabilities {
/// when [liveTv] is true. /// when [liveTv] is true.
final bool liveTvDvr; final bool liveTvDvr;
/// Server proxies subtitle search (e.g. OpenSubtitles).
final bool subtitleSearch;
/// Server can transcode video. /// Server can transcode video.
final bool videoTranscoding; 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 /// Server provides curated recommendation hubs (Plex Discover). Jellyfin
/// returns synthesized hubs but with sparser categorisation. /// returns synthesized hubs but with sparser categorisation.
final bool richHubs; final bool richHubs;
@@ -72,32 +42,9 @@ class ServerCapabilities {
/// Hides the "Search subtitles" affordance when false. /// Hides the "Search subtitles" affordance when false.
final bool externalSubtitleSearch; 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. /// Server exposes metadata edit endpoints. Hides edit affordances when false.
final bool richMetadataEdit; 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. /// Server can supply thumbnails for the player's seek-bar scrub preview.
/// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite /// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite
/// sheets. Both backends are wired through [ScrubPreviewSource]; the flag /// sheets. Both backends are wired through [ScrubPreviewSource]; the flag
@@ -109,73 +56,40 @@ class ServerCapabilities {
/// `/Items?ParentId=...&Recursive=false` queries. /// `/Items?ParentId=...&Recursive=false` queries.
final bool folderGrouping; 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. /// Server can build an "instant mix" / radio track list from a seed item.
/// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues /// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues
/// (`POST /playQueues?type=audio&uri=...station...`). /// (`POST /playQueues?type=audio&uri=...station...`).
final bool instantMix; 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({ const ServerCapabilities({
this.serverSidePlayQueue = false,
this.serverSidePlaylists = false,
this.liveTv = false, this.liveTv = false,
this.liveTvDvr = false, this.liveTvDvr = false,
this.subtitleSearch = false,
this.videoTranscoding = true, this.videoTranscoding = true,
this.serverSideSync = false,
this.richHubs = false, this.richHubs = false,
this.numericUserRating = false, this.numericUserRating = false,
this.userFavorites = false, this.userFavorites = false,
this.continueWatchingRemoval = false, this.continueWatchingRemoval = false,
this.externalSubtitleSearch = false, this.externalSubtitleSearch = false,
this.trackPreferencePersistence = false,
this.endpointFailover = false,
this.offlineWatchQueue = false,
this.discordRpc = false,
this.richMetadataEdit = false, this.richMetadataEdit = false,
this.alphaBar = AlphaBarMode.none,
this.scrubThumbnails = false, this.scrubThumbnails = false,
this.folderGrouping = false, this.folderGrouping = false,
this.lyrics = false,
this.instantMix = false, this.instantMix = false,
this.audioTranscoding = false,
}); });
/// Defaults for a fully-featured Plex server. /// Defaults for a fully-featured Plex server.
static const ServerCapabilities plex = ServerCapabilities( static const ServerCapabilities plex = ServerCapabilities(
serverSidePlayQueue: true,
serverSidePlaylists: true,
liveTv: true, liveTv: true,
liveTvDvr: true, liveTvDvr: true,
subtitleSearch: true,
videoTranscoding: true, videoTranscoding: true,
serverSideSync: true,
richHubs: true, richHubs: true,
numericUserRating: true, numericUserRating: true,
userFavorites: false, userFavorites: false,
continueWatchingRemoval: true, continueWatchingRemoval: true,
externalSubtitleSearch: true, externalSubtitleSearch: true,
trackPreferencePersistence: true,
endpointFailover: true,
offlineWatchQueue: true,
discordRpc: true,
richMetadataEdit: true, richMetadataEdit: true,
alphaBar: AlphaBarMode.scrollSnap,
scrubThumbnails: true, scrubThumbnails: true,
folderGrouping: true, folderGrouping: true,
lyrics: true,
instantMix: true, instantMix: true,
audioTranscoding: true,
); );
/// Defaults for a Jellyfin server. /// Defaults for a Jellyfin server.
@@ -188,79 +102,37 @@ class ServerCapabilities {
/// `/LiveTv/Programs`. Detection + channel listing are wired today; /// `/LiveTv/Programs`. Detection + channel listing are wired today;
/// EPG and tuning are follow-ups. /// EPG and tuning are follow-ups.
static const ServerCapabilities jellyfin = ServerCapabilities( static const ServerCapabilities jellyfin = ServerCapabilities(
serverSidePlayQueue: false,
serverSidePlaylists: true,
liveTv: true, liveTv: true,
liveTvDvr: false, liveTvDvr: false,
subtitleSearch: false,
videoTranscoding: true, videoTranscoding: true,
serverSideSync: false,
richHubs: false, richHubs: false,
numericUserRating: false, numericUserRating: false,
userFavorites: true, userFavorites: true,
externalSubtitleSearch: false, externalSubtitleSearch: false,
trackPreferencePersistence: true,
endpointFailover: true,
offlineWatchQueue: false,
discordRpc: false,
richMetadataEdit: true, richMetadataEdit: true,
alphaBar: AlphaBarMode.nameStartsWithFilter,
scrubThumbnails: true, scrubThumbnails: true,
folderGrouping: true, folderGrouping: true,
lyrics: true,
instantMix: true, instantMix: true,
audioTranscoding: true,
); );
ServerCapabilities copyWith({ /// Every flag here is fixed per backend *kind* except [videoTranscoding],
bool? serverSidePlayQueue, /// which Plex probes per server (`PlexClient.capabilities`) — so that is the
bool? serverSidePlaylists, /// only override this type needs. Widen the parameter list if another flag
bool? liveTv, /// ever becomes a runtime probe.
bool? liveTvDvr, ServerCapabilities copyWith({bool? videoTranscoding}) {
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,
}) {
return ServerCapabilities( return ServerCapabilities(
serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue, liveTv: liveTv,
serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists, liveTvDvr: liveTvDvr,
liveTv: liveTv ?? this.liveTv,
liveTvDvr: liveTvDvr ?? this.liveTvDvr,
subtitleSearch: subtitleSearch ?? this.subtitleSearch,
videoTranscoding: videoTranscoding ?? this.videoTranscoding, videoTranscoding: videoTranscoding ?? this.videoTranscoding,
serverSideSync: serverSideSync ?? this.serverSideSync, richHubs: richHubs,
richHubs: richHubs ?? this.richHubs, numericUserRating: numericUserRating,
numericUserRating: numericUserRating ?? this.numericUserRating, userFavorites: userFavorites,
userFavorites: userFavorites ?? this.userFavorites, continueWatchingRemoval: continueWatchingRemoval,
continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval, externalSubtitleSearch: externalSubtitleSearch,
externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch, richMetadataEdit: richMetadataEdit,
trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence, scrubThumbnails: scrubThumbnails,
endpointFailover: endpointFailover ?? this.endpointFailover, folderGrouping: folderGrouping,
offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue, instantMix: instantMix,
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,
); );
} }
} }
@@ -5,7 +5,6 @@ import '../media/media_kind.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../services/jellyfin_client.dart'; import '../services/jellyfin_client.dart';
import '../utils/jellyfin_time.dart'; import '../utils/jellyfin_time.dart';
import '../utils/media_image_helper.dart';
import 'metadata_edit_models.dart'; import 'metadata_edit_models.dart';
class JellyfinMetadataEditAdapter extends MetadataEditAdapter { class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
@@ -39,7 +38,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) { List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
final kind = draft.sourceItem.kind; final kind = draft.sourceItem.kind;
return [ 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) if (_tagFields(kind).isNotEmpty)
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
@@ -53,11 +56,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
final dto = Map<String, dynamic>.from(raw); final dto = Map<String, dynamic>.from(raw);
dto['ProviderIds'] = _stringMap(dto['ProviderIds']); dto['ProviderIds'] = _stringMap(dto['ProviderIds']);
dto['Tags'] = _stringList(dto['Tags']); dto['Tags'] = metadataStringList(dto['Tags']);
dto['Genres'] = _stringList(dto['Genres']); dto['Genres'] = metadataStringList(dto['Genres']);
dto['People'] = _mapList(dto['People']); dto['People'] = _mapList(dto['People']);
dto['Studios'] = _mapList(dto['Studios']); dto['Studios'] = _mapList(dto['Studios']);
dto['LockedFields'] = _stringList(dto['LockedFields']); dto['LockedFields'] = metadataStringList(dto['LockedFields']);
dto['LockData'] = dto['LockData'] == true; dto['LockData'] = dto['LockData'] == true;
dto.remove('Trickplay'); dto.remove('Trickplay');
@@ -71,29 +74,29 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
final value = draft.value<String>('originallyAvailableAt') ?? ''; final value = draft.value<String>('originallyAvailableAt') ?? '';
dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']); dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']);
} }
if (_fieldChanged(draft, 'studio')) { if (_listFieldChanged(draft, 'studio')) {
dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio'])); dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio']));
} }
if (draft.fieldChanged('tagline')) { if (draft.fieldChanged('tagline')) {
final tagline = metadataEmptyToNull(draft.value<String>('tagline')); final tagline = metadataEmptyToNull(draft.value<String>('tagline'));
final existing = _stringList(dto['Taglines']); final existing = metadataStringList(dto['Taglines']);
dto['Taglines'] = tagline == null ? <String>[] : <String>[tagline, ...existing.skip(1)]; dto['Taglines'] = tagline == null ? <String>[] : <String>[tagline, ...existing.skip(1)];
} }
if (_fieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']); if (_listFieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']);
if (_fieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']); if (_listFieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']);
if (_fieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']); if (_listFieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']);
var peopleChanged = false; var peopleChanged = false;
var people = _mapList(dto['People']); var people = _mapList(dto['People']);
if (_fieldChanged(draft, 'director')) { if (_listFieldChanged(draft, 'director')) {
people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director'])); people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director']));
peopleChanged = true; peopleChanged = true;
} }
if (_fieldChanged(draft, 'writer')) { if (_listFieldChanged(draft, 'writer')) {
people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer'])); people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer']));
peopleChanged = true; peopleChanged = true;
} }
if (_fieldChanged(draft, 'producer')) { if (_listFieldChanged(draft, 'producer')) {
people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer'])); people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer']));
peopleChanged = true; peopleChanged = true;
} }
@@ -132,11 +135,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
.toList(); .toList();
} }
@override
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
return applyArtworkFromUrl(draft, field, option.sourceUrl);
}
@override @override
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
final imageType = field.artwork?.key; final imageType = field.artwork?.key;
@@ -180,12 +178,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
? metadataFirstString(raw['Taglines']) ? metadataFirstString(raw['Taglines'])
: item.tagline ?? ''; : item.tagline ?? '';
values['summary'] = raw['Overview'] as String? ?? item.summary ?? ''; 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['director'] = _peopleByType(raw['People'], 'Director');
values['writer'] = _peopleByType(raw['People'], 'Writer'); values['writer'] = _peopleByType(raw['People'], 'Writer');
values['producer'] = _peopleByType(raw['People'], 'Producer'); values['producer'] = _peopleByType(raw['People'], 'Producer');
values['country'] = _stringList(raw['ProductionLocations']); values['country'] = metadataStringList(raw['ProductionLocations']);
values['label'] = _stringList(raw['Tags']); values['label'] = metadataStringList(raw['Tags']);
} }
void _writeArtworkValues(Map<String, Object?> values, MediaItem item) { void _writeArtworkValues(Map<String, Object?> values, MediaItem item) {
@@ -194,29 +192,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
values['artwork:Logo'] = item.clearLogoPath; values['artwork:Logo'] = item.clearLogoPath;
} }
List<MetadataEditField> _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<MetadataEditField> _tagFields(MediaKind kind) { List<MetadataEditField> _tagFields(MediaKind kind) {
MetadataEditField tag(String id, String label) => MetadataEditField tag(String id, String label) =>
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
@@ -234,100 +209,20 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
}; };
} }
List<MetadataEditField> _artworkFields(MediaKind kind) { List<MetadataEditField> _artworkFields(MediaKind kind) =>
final fields = <MetadataEditField>[ metadataArtworkFields(kind, posterKey: 'Primary', backdropKey: 'Backdrop', logoKey: 'Logo');
// 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,
),
);
}
void _setChangedString(Map<String, dynamic> dto, MetadataEditDraft draft, String fieldId, String dtoKey) { void _setChangedString(Map<String, dynamic> dto, MetadataEditDraft draft, String fieldId, String dtoKey) {
if (!draft.fieldChanged(fieldId)) return; if (!draft.fieldChanged(fieldId)) return;
dto[dtoKey] = metadataEmptyToNull(draft.value<String>(fieldId)); dto[dtoKey] = metadataEmptyToNull(draft.value<String>(fieldId));
} }
bool _fieldChanged(MetadataEditDraft draft, String fieldId) { /// Every id passed here names a `stringList` field, so the comparison is
for (final section in schemaFor(draft)) { /// order-insensitive regardless of which kind's schema is in play.
for (final field in section.fields) { bool _listFieldChanged(MetadataEditDraft draft, String fieldId) =>
if (field.id == fieldId) return metadataEditFieldChanged(draft, field); !metadataEditStringListEquals(draft.values[fieldId], draft.originalValues[fieldId]);
}
}
return draft.fieldChanged(fieldId);
}
} }
List<String> _stringList(Object? value) => metadataStringList(value);
Map<String, String> _stringMap(Object? value) { Map<String, String> _stringMap(Object? value) {
if (value is! Map) return <String, String>{}; if (value is! Map) return <String, String>{};
return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? '')); return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
+131 -1
View File
@@ -1,3 +1,4 @@
import '../i18n/strings.g.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_kind.dart'; import '../media/media_kind.dart';
@@ -147,7 +148,9 @@ abstract class MetadataEditAdapter {
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field); Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field);
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option); Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
return applyArtworkFromUrl(draft, field, option.sourceUrl);
}
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url); Future<bool> 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<MetadataEditField> 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<MetadataEditField> metadataArtworkFields(
MediaKind kind, {
required String posterKey,
required String backdropKey,
required String logoKey,
String? squareKey,
Set<MediaKind> logoKinds = const {MediaKind.movie, MediaKind.show},
}) {
final fields = <MetadataEditField>[
// 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) { bool metadataEditValueEquals(Object? a, Object? b) {
if (identical(a, b)) return true; if (identical(a, b)) return true;
if (a is List && b is List) { if (a is List && b is List) {
@@ -7,7 +7,6 @@ import '../media/media_server_client.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/language_codes.dart'; import '../utils/language_codes.dart';
import '../utils/media_image_helper.dart';
import 'metadata_edit_models.dart'; import 'metadata_edit_models.dart';
class PlexMetadataEditAdapter extends MetadataEditAdapter { class PlexMetadataEditAdapter extends MetadataEditAdapter {
@@ -60,7 +59,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) { List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
final kind = draft.sourceItem.kind; final kind = draft.sourceItem.kind;
return [ 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) if (_tagFields(kind).isNotEmpty)
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
@@ -133,11 +132,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
.toList(); .toList();
} }
@override
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
return applyArtworkFromUrl(draft, field, option.sourceUrl);
}
@override @override
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
final element = field.artwork?.key; final element = field.artwork?.key;
@@ -213,29 +207,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
} }
} }
List<MetadataEditField> _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<MetadataEditField> _tagFields(MediaKind kind) { List<MetadataEditField> _tagFields(MediaKind kind) {
MetadataEditField tag(String id, String label) => MetadataEditField tag(String id, String label) =>
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
@@ -267,94 +238,14 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
}; };
} }
List<MetadataEditField> _artworkFields(MediaKind kind) { List<MetadataEditField> _artworkFields(MediaKind kind) => metadataArtworkFields(
final fields = <MetadataEditField>[ kind,
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art. posterKey: 'posters',
kind == MediaKind.episode backdropKey: 'arts',
? _artworkField( logoKey: 'clearLogos',
'posters', squareKey: 'squareArts',
t.metadataEdit.poster, logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection},
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<MetadataEditField> _advancedFields(MediaKind kind) { List<MetadataEditField> _advancedFields(MediaKind kind) {
final fields = <MetadataEditField>[]; final fields = <MetadataEditField>[];
+16
View File
@@ -171,6 +171,22 @@ mixin DebouncedMediaSearch<T extends StatefulWidget> on State<T> {
} }
} }
/// 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 /// OSK "Search" / hardware Enter on TV: jump to results, or force the
/// pending search to run now. /// pending search to run now.
void handleSearchSubmit() { void handleSearchSubmit() {
+19
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/deletion_notifier.dart'; import '../utils/deletion_notifier.dart';
import 'event_aware.dart'; import 'event_aware.dart';
import 'watch_state_aware.dart';
/// Mixin for screens that need to react to deletion events. /// Mixin for screens that need to react to deletion events.
/// ///
@@ -74,3 +75,21 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
super.dispose(); 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<T extends StatefulWidget> on WatchStateAware<T>, DeletionAware<T> {
@override
String? get deletionServerId => watchStateServerId;
@override
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
@override
Set<String>? get deletionIds => watchedIds;
}
-37
View File
@@ -104,43 +104,6 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
return (page: result, applied: true); 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<bool> loadInitialPaginatedItems({
required int pageSize,
required VoidCallback resetViewState,
required void Function(List<T> 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) /// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
/// with [buffer] extra indices on each side. Serialized — only one /// with [buffer] extra indices on each side. Serialized — only one
/// range-fetch runs at a time — and re-checks after each success so a /// range-fetch runs at a time — and re-checks after each success so a
+74
View File
@@ -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<T, W extends StatefulWidget> on PaginatedItemLoader<T, W> {
set items(List<T> 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<void> 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<W extends StatefulWidget> on PaginatedItemLoader<MediaItem, W>, ItemUpdatable<W> {
@override
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
for (final entry in loadedItems.entries) {
if (entry.value.globalKey == sourceGlobalKey) {
loadedItems[entry.key] = updatedItem;
return;
}
}
}
}
-2
View File
@@ -34,8 +34,6 @@ sealed class DownloadProgress with _$DownloadProgress {
double get progressPercent => progress / 100.0; double get progressPercent => progress / 100.0;
String get speedFormatted => ByteFormatter.formatSpeed(speed); String get speedFormatted => ByteFormatter.formatSpeed(speed);
String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes);
String get totalFormatted => ByteFormatter.formatBytes(totalBytes);
bool get hasArtworkPaths => thumbPath != null; bool get hasArtworkPaths => thumbPath != null;
} }
+1 -4
View File
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../utils/json_utils.dart'; import '../utils/json_utils.dart';
import 'mixins/multi_server_fields.dart';
part 'livetv_channel.g.dart'; part 'livetv_channel.g.dart';
@@ -49,7 +48,7 @@ List<LiveTvChannel> filterLiveTvChannelsForFavorites({
} }
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class LiveTvChannel with MultiServerFields { class LiveTvChannel {
@JsonKey(readValue: _readChannelKey) @JsonKey(readValue: _readChannelKey)
final String key; final String key;
@JsonKey(readValue: _readChannelIdentifier) @JsonKey(readValue: _readChannelIdentifier)
@@ -68,10 +67,8 @@ class LiveTvChannel with MultiServerFields {
@JsonKey(fromJson: flexibleBool) @JsonKey(fromJson: flexibleBool)
final bool? drm; final bool? drm;
@override
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId; final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName; final String? serverName;
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
-92
View File
@@ -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<LiveTvChannel> _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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<LiveTvChannel> channels;
const LiveTvLineup({
required this.uuid,
this.type,
this.title,
this.lineupType,
this.location,
this.channels = const [],
});
factory LiveTvLineup.fromJson(Map<String, dynamic> json) => _$LiveTvLineupFromJson(json);
}
class LiveTvLineupResult {
final String? lineupGroupUuid;
final List<LiveTvLineup> lineups;
const LiveTvLineupResult({this.lineupGroupUuid, required this.lineups});
}
-42
View File
@@ -1,42 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_lineup.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvCountry _$LiveTvCountryFromJson(Map<String, dynamic> 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<String, dynamic> json) =>
LiveTvLanguage(
code: json['code'] as String? ?? '',
title: json['title'] as String? ?? '',
);
LiveTvRegion _$LiveTvRegionFromJson(Map<String, dynamic> json) => LiveTvRegion(
key: json['key'] as String? ?? '',
type: json['type'] as String?,
title: json['title'] as String? ?? '',
);
LiveTvLineup _$LiveTvLineupFromJson(Map<String, dynamic> 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']),
);
-26
View File
@@ -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<String, dynamic> json) => _$LiveTvServerStatusFromJson(json);
Set<String> 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');
}
-14
View File
@@ -1,14 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_server_status.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvServerStatus _$LiveTvServerStatusFromJson(Map<String, dynamic> json) =>
LiveTvServerStatus(
liveTvCount: flexibleInt(json['livetv']),
allowTuners: flexibleBoolNullable(json['allowTuners']),
ownerFeatures: json['ownerFeatures'] as String?,
);
-66
View File
@@ -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<String, dynamic>? 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<String, dynamic> json) => _$LiveTvSessionFromJson(json);
}
-25
View File
@@ -1,25 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'livetv_session.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
LiveTvSession _$LiveTvSessionFromJson(Map<String, dynamic> 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']),
);
-103
View File
@@ -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<ChannelMapping> _parseChannelMappings(Object? raw) => parseFlexibleJsonList(raw, ChannelMapping.fromJson);
List<SubscriptionSetting> _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<String, dynamic> 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<ChannelMapping> channelMappings;
@JsonKey(name: 'Setting', fromJson: _parseSettings)
final List<SubscriptionSetting> 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<String, dynamic> 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<String, dynamic> json) => _$MediaGrabberDeviceChannelFromJson(json);
}
class MediaGrabberChannelMapRequest {
final List<String> channelsEnabled;
final Map<String, String> channelMapping;
final Map<String, String> channelMappingByKey;
const MediaGrabberChannelMapRequest({
this.channelsEnabled = const [],
this.channelMapping = const {},
this.channelMappingByKey = const {},
});
}
-47
View File
@@ -1,47 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_grabber_device.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaGrabber _$MediaGrabberFromJson(Map<String, dynamic> json) => MediaGrabber(
identifier: json['identifier'] as String? ?? '',
protocol: json['protocol'] as String?,
title: json['title'] as String?,
);
MediaGrabberDevice _$MediaGrabberDeviceFromJson(Map<String, dynamic> 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<String, dynamic> 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']),
);
-71
View File
@@ -1,71 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
part 'media_provider_info.g.dart';
List<MediaProviderFeature> _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson);
List<Map<String, dynamic>> _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<MediaProviderFeature> 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<String, dynamic> 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<Map<String, dynamic>> directories;
@JsonKey(name: 'Action', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> actions;
@JsonKey(name: 'Pivot', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> 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<String, dynamic> json) => _$MediaProviderFeatureFromJson(json);
}
-38
View File
@@ -1,38 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_provider_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaProviderInfo _$MediaProviderInfoFromJson(Map<String, dynamic> 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<String, dynamic> 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']),
);
@@ -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;
}
-14
View File
@@ -37,18 +37,4 @@ class PlexHome {
Map<String, dynamic> toJson() => _$PlexHomeToJson(this); Map<String, dynamic> toJson() => _$PlexHomeToJson(this);
PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull; PlexHomeUser? get adminUser => users.where((user) => user.admin).firstOrNull;
List<PlexHomeUser> get managedUsers => users.where((user) => !user.admin).toList();
List<PlexHomeUser> 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;
} }
+13
View File
@@ -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<String, dynamic> json) {
final authToken = json['authToken'];
if (authToken is! String || authToken.isEmpty) {
throw const FormatException('Plex /switch response has no usable authToken');
}
return authToken;
}
+1 -15
View File
@@ -10,8 +10,7 @@ part 'plex_user_profile.g.dart';
/// ///
/// Every field parses tolerantly: the account API drifts (~July 2026 the /// Every field parses tolerantly: the account API drifts (~July 2026 the
/// language-list fields switched from arrays to CSV strings, #1488), and a /// language-list fields switched from arrays to CSV strings, #1488), and a
/// profile blob must never fail to parse — token minting embeds it (see /// single drifted field must never sink the whole profile.
/// UserSwitchResponse.fromJson).
@JsonSerializable() @JsonSerializable()
class PlexUserProfile implements MediaServerUserProfile { class PlexUserProfile implements MediaServerUserProfile {
@JsonKey(fromJson: _boolOrTrue) @JsonKey(fromJson: _boolOrTrue)
@@ -62,19 +61,6 @@ class PlexUserProfile implements MediaServerUserProfile {
this.mediaReviewsLanguages, 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<String, dynamic> json) { factory PlexUserProfile.fromJson(Map<String, dynamic> json) {
final envelope = json['profile']; final envelope = json['profile'];
final profile = envelope is Map<String, dynamic> ? envelope : json; final profile = envelope is Map<String, dynamic> ? envelope : json;
@@ -26,6 +26,4 @@ class PlexVideoPlaybackData {
}); });
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty; bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
bool get hasMediaInfo => mediaInfo != null;
} }
+6 -109
View File
@@ -4,111 +4,18 @@ import 'seerr_media.dart';
part 'seerr_details.g.dart'; part 'seerr_details.g.dart';
/// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog /// Full detail from `GET /movie/{tmdbId}` and `GET /tv/{tmdbId}` — the subset
/// surfaces need (credits, external ids, availability, air status). /// the catalog surfaces need (credits, availability, seasons). `seasons` is
/// absent on movies.
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class SeerrMovieDetails { class SeerrDetails {
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<SeerrGenre>? 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<String, dynamic> 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<int>? episodeRunTime;
/// `Returning Series` / `Ended` / `Canceled` / `In Production` /
/// `Planned` / `Pilot`.
final String? status;
final double? voteAverage;
final int? voteCount;
final List<SeerrGenre>? genres;
final List<SeerrNetwork>? networks;
final int? numberOfEpisodes;
final int? numberOfSeasons;
final List<SeerrSeason>? seasons; final List<SeerrSeason>? seasons;
final SeerrCredits? credits; final SeerrCredits? credits;
final SeerrExternalIds? externalIds;
final SeerrMediaInfo? mediaInfo; final SeerrMediaInfo? mediaInfo;
const SeerrTvDetails({ const SeerrDetails({this.seasons, this.credits, this.mediaInfo});
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,
});
factory SeerrTvDetails.fromJson(Map<String, dynamic> json) => _$SeerrTvDetailsFromJson(json); factory SeerrDetails.fromJson(Map<String, dynamic> json) => _$SeerrDetailsFromJson(json);
}
@JsonSerializable(createToJson: false)
class SeerrGenre {
final String? name;
const SeerrGenre({this.name});
factory SeerrGenre.fromJson(Map<String, dynamic> json) => _$SeerrGenreFromJson(json);
}
@JsonSerializable(createToJson: false)
class SeerrNetwork {
final String? name;
const SeerrNetwork({this.name});
factory SeerrNetwork.fromJson(Map<String, dynamic> json) => _$SeerrNetworkFromJson(json);
} }
/// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials. /// One TMDB season entry (`TvDetails.seasons[]`). Season 0 is specials.
@@ -141,13 +48,3 @@ class SeerrCastMember {
factory SeerrCastMember.fromJson(Map<String, dynamic> json) => _$SeerrCastMemberFromJson(json); factory SeerrCastMember.fromJson(Map<String, dynamic> json) => _$SeerrCastMemberFromJson(json);
} }
@JsonSerializable(createToJson: false)
class SeerrExternalIds {
final String? imdbId;
final int? tvdbId;
const SeerrExternalIds({this.imdbId, this.tvdbId});
factory SeerrExternalIds.fromJson(Map<String, dynamic> json) => _$SeerrExternalIdsFromJson(json);
}
+2 -68
View File
@@ -6,77 +6,17 @@ part of 'seerr_details.dart';
// JsonSerializableGenerator // JsonSerializableGenerator
// ************************************************************************** // **************************************************************************
SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> json) => SeerrDetails _$SeerrDetailsFromJson(Map<String, dynamic> json) => SeerrDetails(
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<dynamic>?)
?.map((e) => SeerrGenre.fromJson(e as Map<String, dynamic>))
.toList(),
credits: json['credits'] == null
? null
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
externalIds: json['externalIds'] == null
? null
: SeerrExternalIds.fromJson(
json['externalIds'] as Map<String, dynamic>,
),
mediaInfo: json['mediaInfo'] == null
? null
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
);
SeerrTvDetails _$SeerrTvDetailsFromJson(Map<String, dynamic> 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<dynamic>?)
?.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<dynamic>?)
?.map((e) => SeerrGenre.fromJson(e as Map<String, dynamic>))
.toList(),
networks: (json['networks'] as List<dynamic>?)
?.map((e) => SeerrNetwork.fromJson(e as Map<String, dynamic>))
.toList(),
numberOfEpisodes: (json['numberOfEpisodes'] as num?)?.toInt(),
numberOfSeasons: (json['numberOfSeasons'] as num?)?.toInt(),
seasons: (json['seasons'] as List<dynamic>?) seasons: (json['seasons'] as List<dynamic>?)
?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>)) ?.map((e) => SeerrSeason.fromJson(e as Map<String, dynamic>))
.toList(), .toList(),
credits: json['credits'] == null credits: json['credits'] == null
? null ? null
: SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>), : SeerrCredits.fromJson(json['credits'] as Map<String, dynamic>),
externalIds: json['externalIds'] == null
? null
: SeerrExternalIds.fromJson(
json['externalIds'] as Map<String, dynamic>,
),
mediaInfo: json['mediaInfo'] == null mediaInfo: json['mediaInfo'] == null
? null ? null
: SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>), : SeerrMediaInfo.fromJson(json['mediaInfo'] as Map<String, dynamic>),
); );
SeerrGenre _$SeerrGenreFromJson(Map<String, dynamic> json) =>
SeerrGenre(name: json['name'] as String?);
SeerrNetwork _$SeerrNetworkFromJson(Map<String, dynamic> json) =>
SeerrNetwork(name: json['name'] as String?);
SeerrSeason _$SeerrSeasonFromJson(Map<String, dynamic> json) => SeerrSeason( SeerrSeason _$SeerrSeasonFromJson(Map<String, dynamic> json) => SeerrSeason(
seasonNumber: (json['seasonNumber'] as num).toInt(), seasonNumber: (json['seasonNumber'] as num).toInt(),
@@ -97,9 +37,3 @@ SeerrCastMember _$SeerrCastMemberFromJson(Map<String, dynamic> json) =>
character: json['character'] as String?, character: json['character'] as String?,
profilePath: json['profilePath'] as String?, profilePath: json['profilePath'] as String?,
); );
SeerrExternalIds _$SeerrExternalIdsFromJson(Map<String, dynamic> json) =>
SeerrExternalIds(
imdbId: json['imdbId'] as String?,
tvdbId: (json['tvdbId'] as num?)?.toInt(),
);
+35 -82
View File
@@ -9,22 +9,35 @@ enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom }
/// ArtCNN real-time model sizes. /// ArtCNN real-time model sizes.
enum ArtCNNModel { enum ArtCNNModel {
/// Lightweight real-time model /// Lightweight real-time model
c4f16, c4f16('C4F16'),
/// Higher-quality real-time model /// Higher-quality real-time model
c4f32, c4f32('C4F32');
const ArtCNNModel(this.label);
/// Display label for the model.
final String label;
} }
/// ArtCNN luma doubler variants. /// ArtCNN luma doubler variants.
enum ArtCNNVariant { enum ArtCNNVariant {
/// Neutral luma doubler /// Neutral luma doubler
neutral, neutral('Neutral', 'neutral'),
/// Denoise and soften /// Denoise and soften
denoise, denoise('Denoise', 'dn'),
/// Denoise and sharpen /// 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 /// Quality tiers for Anime4K presets
@@ -39,22 +52,27 @@ enum Anime4KQuality {
/// Anime4K modes that define shader combinations /// Anime4K modes that define shader combinations
enum Anime4KMode { enum Anime4KMode {
/// Mode A: Clamp + Restore /// Mode A: Clamp + Restore
modeA, modeA('A'),
/// Mode B: Clamp + Restore + Upscale + Downscale /// Mode B: Clamp + Restore + Upscale + Downscale
modeB, modeB('B'),
/// Mode C: Clamp + Upscale + Downscale /// Mode C: Clamp + Upscale + Downscale
modeC, modeC('C'),
/// Mode A+A: Clamp + Restore + Restore /// Mode A+A: Clamp + Restore + Restore
modeAA, modeAA('A+A'),
/// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale /// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale
modeBB, modeBB('B+B'),
/// Mode C+A: Clamp + Upscale + Restore + Downscale /// Mode C+A: Clamp + Upscale + Restore + Downscale
modeCA, modeCA('C+A');
const Anime4KMode(this.label);
/// Display label for the mode.
final String label;
} }
@freezed @freezed
@@ -120,93 +138,28 @@ class ShaderPreset {
); );
/// Create an ArtCNN preset with the specified model and variant /// Create an ArtCNN preset with the specified model and variant
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) { static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) => ShaderPreset(
final modelName = _getArtCNNModelName(model); id: 'artcnn_${model.name}_${variant.slug}',
final variantName = _getArtCNNVariantName(variant); name: variant == ArtCNNVariant.neutral ? 'ArtCNN ${model.label}' : 'ArtCNN ${model.label} ${variant.label}',
final variantId = _getArtCNNVariantId(variant);
return ShaderPreset(
id: 'artcnn_${model.name}_$variantId',
name: variant == ArtCNNVariant.neutral ? 'ArtCNN $modelName' : 'ArtCNN $modelName $variantName',
type: ShaderPresetType.artcnn, type: ShaderPresetType.artcnn,
artcnnConfig: ArtCNNConfig(model: model, variant: variant), artcnnConfig: ArtCNNConfig(model: model, variant: variant),
); );
}
/// Create an Anime4K preset with the specified quality and mode /// Create an Anime4K preset with the specified quality and mode
static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) { static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) {
final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ'; final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ';
final modeName = _getModeName(mode);
return ShaderPreset( return ShaderPreset(
id: 'anime4k_${quality.name}_${mode.name}', id: 'anime4k_${quality.name}_${mode.name}',
name: 'Anime4K $qualityName $modeName', name: 'Anime4K $qualityName ${mode.label}',
type: ShaderPresetType.anime4k, type: ShaderPresetType.anime4k,
anime4kConfig: Anime4KConfig(quality: quality, mode: mode), anime4kConfig: Anime4KConfig(quality: quality, mode: mode),
); );
} }
static String _getModeName(Anime4KMode mode) { String get modeDisplayName => anime4kConfig?.mode.label ?? '';
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';
}
}
static String _getArtCNNModelName(ArtCNNModel model) { String get artcnnModelDisplayName => artcnnConfig?.model.label ?? '';
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 '';
}
static final List<ShaderPreset> _builtInPresets = List.unmodifiable([ static final List<ShaderPreset> _builtInPresets = List.unmodifiable([
none, none,
+3 -26
View File
@@ -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 /// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
/// to a specific point in time; defaults to "now" on Trakt's side. /// to a specific point in time; defaults to "now" on Trakt's side.
Map<String, dynamic> toHistoryAddBody({String? watchedAt}) => switch (this) { Map<String, dynamic> toHistoryBody({String? watchedAt}) => switch (this) {
TraktScrobbleMovieRequest(:final ids) => { TraktScrobbleMovieRequest(:final ids) => {
'movies': [ 'movies': [
{'watched_at': ?watchedAt, 'ids': ids.toJson()}, {'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<String, dynamic> 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},
],
},
],
},
],
},
};
} }
-160
View File
@@ -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<String, dynamic> 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<String, dynamic> 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;
}
+31 -42
View File
@@ -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<void> _applyWhenInitialized(Future<void> 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 @override
Future<void> open( Future<void> open(
Media media, { Media media, {
@@ -279,15 +294,10 @@ class PlayerAndroid extends PlayerBase {
break; break;
case 'dv-conversion-mode': case 'dv-conversion-mode':
_dvConversionMode = value; _dvConversionMode = value;
final initFuture = _initFuture; await _applyWhenInitialized(
if (initialized) { () => invoke('setDvConversionMode', {'mode': value}),
await invoke('setDvConversionMode', {'mode': value}); () => _dvConversionMode == value,
} else if (initFuture != null) { );
await initFuture;
if (!disposed && initialized && _dvConversionMode == value) {
await invoke('setDvConversionMode', {'mode': value});
}
}
break; break;
case 'sub-visibility': case 'sub-visibility':
if (value == 'no') { if (value == 'no') {
@@ -316,15 +326,10 @@ class PlayerAndroid extends PlayerBase {
Future<void> setAudioNormalization(bool enabled) async { Future<void> setAudioNormalization(bool enabled) async {
if (disposed) return; if (disposed) return;
_audioNormalizationEnabled = enabled; _audioNormalizationEnabled = enabled;
final initFuture = _initFuture; await _applyWhenInitialized(
if (initialized) { () => invoke('setAudioNormalization', {'enabled': enabled}),
await invoke('setAudioNormalization', {'enabled': enabled}); () => _audioNormalizationEnabled == enabled,
} else if (initFuture != null) { );
await initFuture;
if (!disposed && initialized && _audioNormalizationEnabled == enabled) {
await invoke('setAudioNormalization', {'enabled': enabled});
}
}
// Keep the mpv af property flowing through setMpvProperty so the plugin's // Keep the mpv af property flowing through setMpvProperty so the plugin's
// pendingMpvProperties replay applies loudnorm if exo falls back to mpv. // pendingMpvProperties replay applies loudnorm if exo falls back to mpv.
await super.setAudioNormalization(enabled); await super.setAudioNormalization(enabled);
@@ -336,21 +341,10 @@ class PlayerAndroid extends PlayerBase {
_downmixEnabled = enabled; _downmixEnabled = enabled;
_downmixCenterBoostDb = centerBoostDb; _downmixCenterBoostDb = centerBoostDb;
_downmixNormalize = normalize; _downmixNormalize = normalize;
Future<void> invokeNative() => await _applyWhenInitialized(
invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}); () => invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}),
final initFuture = _initFuture; () => _downmixEnabled == enabled && _downmixCenterBoostDb == centerBoostDb && _downmixNormalize == normalize,
if (initialized) { );
await invokeNative();
} else if (initFuture != null) {
await initFuture;
if (!disposed &&
initialized &&
_downmixEnabled == enabled &&
_downmixCenterBoostDb == centerBoostDb &&
_downmixNormalize == normalize) {
await invokeNative();
}
}
// Keep the mpv properties flowing through setMpvProperty so the plugin's // Keep the mpv properties flowing through setMpvProperty so the plugin's
// pendingMpvProperties replay applies downmix if exo falls back to mpv. // pendingMpvProperties replay applies downmix if exo falls back to mpv.
await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize); await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize);
@@ -360,15 +354,10 @@ class PlayerAndroid extends PlayerBase {
Future<void> setAudioPassthrough(bool enabled) async { Future<void> setAudioPassthrough(bool enabled) async {
if (disposed) return; if (disposed) return;
_audioPassthroughEnabled = enabled; _audioPassthroughEnabled = enabled;
final initFuture = _initFuture; await _applyWhenInitialized(
if (initialized) { () => invoke('setAudioPassthrough', {'enabled': enabled}),
await invoke('setAudioPassthrough', {'enabled': enabled}); () => _audioPassthroughEnabled == enabled,
} else if (initFuture != null) { );
await initFuture;
if (!disposed && initialized && _audioPassthroughEnabled == enabled) {
await invoke('setAudioPassthrough', {'enabled': enabled});
}
}
await setProperty('audio-spdif', enabled ? _passthroughCodecs : ''); await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
} }
-6
View File
@@ -21,12 +21,6 @@ class NavigationTab {
return NavigationDestination(icon: AppIcon(icon, fill: 1), selectedIcon: AppIcon(icon, fill: 1), label: getLabel()); 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 /// Get tabs filtered by offline mode and feature availability
static List<NavigationTab> getVisibleTabs({ static List<NavigationTab> getVisibleTabs({
required bool isOffline, required bool isOffline,
+5 -2
View File
@@ -113,8 +113,6 @@ class ActiveProfileBinder {
final Set<String> _plexHomePreVerified = {}; final Set<String> _plexHomePreVerified = {};
final Set<String> _userInitiatedActivations = {}; final Set<String> _userInitiatedActivations = {};
bool get isSwitching => _isSwitching;
@visibleForTesting @visibleForTesting
String? get debugLastBoundProfileId => _lastBoundProfileId; String? get debugLastBoundProfileId => _lastBoundProfileId;
@@ -374,6 +372,11 @@ class ActiveProfileBinder {
return success; 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<String> _expectedServerIdsForProfile( Set<String> _expectedServerIdsForProfile(
Profile profile, { Profile profile, {
required List<ProfileConnection> joinRows, required List<ProfileConnection> joinRows,
+1 -11
View File
@@ -26,7 +26,7 @@ class PlexHomeService {
this._storage, this._storage,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher, Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
this._refreshInterval = const Duration(hours: 1), this._refreshInterval = const Duration(hours: 1),
}) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher; }) : _fetchHomeUsers = plexHomeUserFetcher ?? fetchPlexHomeUsers;
final ConnectionRegistry _connections; final ConnectionRegistry _connections;
final ProfileConnectionRegistry _profileConnections; final ProfileConnectionRegistry _profileConnections;
@@ -498,13 +498,3 @@ class PlexHomeService {
_started = false; _started = false;
} }
} }
Future<List<PlexHomeUser>> _defaultHomeUserFetcher(String accountToken) async {
final auth = await PlexAuthService.create();
try {
final home = await auth.getHomeUsers(accountToken);
return home.users;
} finally {
auth.dispose();
}
}
+2 -2
View File
@@ -51,8 +51,8 @@ Future<PlexHomeSwitchResult> switchPlexHomeUserWithPin({
if (pin == null) return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.cancelled, null); if (pin == null) return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.cancelled, null);
} }
try { try {
final response = await auth.switchToUser(homeUserUuid, accountToken, pin: pin); final userToken = await auth.switchToUser(homeUserUuid, accountToken, pin: pin);
return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken); return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, userToken);
} on MediaServerHttpException catch (e) { } on MediaServerHttpException catch (e) {
if (e.statusCode == 403 && _isInvalidPin(e)) { if (e.statusCode == 403 && _isInvalidPin(e)) {
error = t.profiles.incorrectPinTryAgain; error = t.profiles.incorrectPinTryAgain;
+3 -4
View File
@@ -234,6 +234,8 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
final connections = context.read<ConnectionRegistry>(); final connections = context.read<ConnectionRegistry>();
final pcRegistry = context.read<ProfileConnectionRegistry>(); final pcRegistry = context.read<ProfileConnectionRegistry>();
final binder = context.read<ActiveProfileBinder>(); final binder = context.read<ActiveProfileBinder>();
// Built before the await: capturing the prompt needs a live context.
final promptForPin = dialogPinPrompt(context, profile.displayName);
final all = await connections.list(); final all = await connections.list();
PlexAccountConnection? account; PlexAccountConnection? account;
for (final c in all) { for (final c in all) {
@@ -248,10 +250,7 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
account: account, account: account,
homeUserUuid: homeUuid, homeUserUuid: homeUuid,
requiresPin: true, requiresPin: true,
promptForPin: ({String? errorMessage}) async { promptForPin: promptForPin,
if (!context.mounted) return null;
return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
},
persistTo: pcRegistry, persistTo: pcRegistry,
persistProfileId: profile.id, persistProfileId: profile.id,
logLabel: profile.displayName, logLabel: profile.displayName,
+103 -182
View File
@@ -9,65 +9,6 @@ import 'profile_connection_registry.dart';
import 'profile_merge.dart'; import 'profile_merge.dart';
import 'profile_registry.dart'; import 'profile_registry.dart';
Future<void> 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<void> 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 /// Profile ids affected by a Plex account removal. Planning is read-only so
/// callers can finish failure-prone cleanup before committing join/account /// callers can finish failure-prone cleanup before committing join/account
/// deletion. /// deletion.
@@ -96,27 +37,73 @@ Future<PlexAccountRemoval> planPlexAccountConnectionRemoval({
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
} }
/// Sign out of a Plex account: remove the account [Connection], every join /// Where the session should land after a profile or connection removal.
/// row referencing it, and everything owned by its virtual Plex Home enum PostRemovalRoute { signedOut, staySignedIn }
/// profiles — including borrowed Jellyfin connections left unreferenced,
/// which previously survived as orphans and wedged the session (#1423). /// Removal of profile↔connection join rows and everything they leave
/// /// unreferenced, bound to one set of registries. Every flow resolves the same
/// Pass a read-only [plannedRemoval] from /// instances from the provider tree, so callers construct this once and call
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup /// through it.
/// must finish before this destructive commit. Omitting it preserves the class ProfileConnectionCleanup {
/// atomic add/cancel-account cleanup path. ProfileConnectionCleanup({
/// required this.profileConnections,
/// All cleanup is explicit and completes before this returns; correctness required this.connections,
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which required this.storage,
/// runs later and no-ops. this.serverManager,
Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({ });
required PlexAccountConnection account,
required ProfileConnectionRegistry profileConnections, final ProfileConnectionRegistry profileConnections;
required ConnectionRegistry connections, final ConnectionRegistry connections;
required StorageService storage, final StorageService storage;
MultiServerManager? serverManager, final MultiServerManager? serverManager;
Future<void> 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,
);
if (connection is JellyfinConnection) {
await _removeUnreferencedJellyfinConnection(connection);
}
}
Future<void> 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<PlexAccountRemoval> removePlexAccountConnection(
PlexAccountConnection account, {
PlexAccountRemoval? plannedRemoval, PlexAccountRemoval? plannedRemoval,
}) async { }) async {
final removal = final removal =
plannedRemoval ?? plannedRemoval ??
await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections);
@@ -126,14 +113,7 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
// Remove direct join rows first so per-profile pref cleanup observes each // 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. // 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)) { for (final row in rows.where((r) => r.connectionId == account.id)) {
await removeProfileConnectionAndCleanup( await removeProfileConnection(profileId: row.profileId, connection: account);
profileId: row.profileId,
connection: account,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
} }
await connections.remove(account.id); await connections.remove(account.id);
await storage.clearPlexHomeUsersCache(account.id); await storage.clearPlexHomeUsersCache(account.id);
@@ -141,42 +121,24 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
// The account's virtual profiles die with the connection; their borrowed // The account's virtual profiles die with the connection; their borrowed
// connections and per-profile prefs must go too. // connections and per-profile prefs must go too.
for (final profileId in removedVirtualProfileIds) { for (final profileId in removedVirtualProfileIds) {
await removeAllProfileConnectionsAndCleanup( await removeAllProfileConnections(profileId);
profileId: profileId,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
await storage.clearProfileLastUsed(profileId); await storage.clearProfileLastUsed(profileId);
await storage.clearUserScopedPreferencesForProfile(profileId); await storage.clearUserScopedPreferencesForProfile(profileId);
} }
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
} }
/// Where the session should land after a profile or connection removal. /// In-session mirror of the boot guard (`main.dart`: "stored connections
enum PostRemovalRoute { signedOut, staySignedIn } /// exist but no profiles resolved — returning to auth"): prune orphaned
/// Jellyfin connections, then decide whether any selectable profile remains.
/// In-session mirror of the boot guard (`main.dart`: "stored connections /// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
/// exist but no profiles resolved — returning to auth"): prune orphaned /// accounts are harmless because the connection map is re-read here.
/// Jellyfin connections, then decide whether any selectable profile remains. Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
/// accounts are harmless because the connection map is re-read here.
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
required ProfileRegistry profileRegistry, required ProfileRegistry profileRegistry,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required Map<String, List<PlexHomeUser>> plexHomeUsers, required Map<String, List<PlexHomeUser>> plexHomeUsers,
required StorageService storage, }) async {
MultiServerManager? serverManager, await pruneUnreferencedJellyfinConnections();
}) async {
await pruneUnreferencedJellyfinConnections(
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
final conns = await connections.list(); final conns = await connections.list();
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]); if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
@@ -188,89 +150,48 @@ Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalSta
); );
if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]); if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
return (route: PostRemovalRoute.staySignedIn, profiles: merged); return (route: PostRemovalRoute.staySignedIn, profiles: merged);
} }
Future<int> pruneUnreferencedJellyfinConnections({ Future<int> pruneUnreferencedJellyfinConnections() async {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
final all = await connections.list(); final all = await connections.list();
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
var removed = 0; var removed = 0;
for (final connection in all.whereType<JellyfinConnection>()) { for (final connection in all.whereType<JellyfinConnection>()) {
if (referencedConnectionIds.contains(connection.id)) continue; if (referencedConnectionIds.contains(connection.id)) continue;
await _removeJellyfinConnection( await _removeJellyfinConnection(connection);
connection,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
removed++; removed++;
} }
return removed; return removed;
} }
Future<void> _removeUnreferencedJellyfinConnection( Future<void> _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async {
JellyfinConnection connection, {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return;
await _removeJellyfinConnection( await _removeJellyfinConnection(connection);
connection, }
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
}
Future<void> _removeJellyfinConnection( Future<void> _removeJellyfinConnection(JellyfinConnection connection) async {
JellyfinConnection connection, {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
await connections.remove(connection.id); await connections.remove(connection.id);
serverManager?.removeJellyfinConnection(connection); serverManager?.removeJellyfinConnection(connection);
final serverId = ServerId.tryParse(connection.serverMachineId); final serverId = ServerId.tryParse(connection.serverMachineId);
if (serverId != null && if (serverId != null && !await _isServerReferenced(serverId)) {
!await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) {
await storage.clearLibraryPreferencesForServerEverywhere(serverId); await storage.clearLibraryPreferencesForServerEverywhere(serverId);
} }
} }
Future<void> _clearProfileServerPrefsNoLongerReferenced({ Future<void> _clearProfileServerPrefsNoLongerReferenced({
required String profileId, required String profileId,
required Set<ServerId> removedServerIds, required Set<ServerId> removedServerIds,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
required bool clearEverywhereWhenUnreferenced, required bool clearEverywhereWhenUnreferenced,
}) async { }) async {
if (removedServerIds.isEmpty) return; if (removedServerIds.isEmpty) return;
final remainingProfileServerIds = await _serverIdsForProfile( final remainingProfileServerIds = await _serverIdsForProfile(profileId);
profileId,
profileConnections: profileConnections,
connections: connections,
);
final activeProfileId = storage.getActiveProfileId(); final activeProfileId = storage.getActiveProfileId();
for (final serverId in removedServerIds) { for (final serverId in removedServerIds) {
if (remainingProfileServerIds.contains(serverId)) continue; if (remainingProfileServerIds.contains(serverId)) continue;
final serverStillReferenced = await _isServerReferenced( final serverStillReferenced = await _isServerReferenced(serverId);
serverId,
profileConnections: profileConnections,
connections: connections,
);
if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { if (serverStillReferenced || !clearEverywhereWhenUnreferenced) {
await storage.clearLibraryPreferencesForServer( await storage.clearLibraryPreferencesForServer(
serverId, serverId,
@@ -281,13 +202,13 @@ Future<void> _clearProfileServerPrefsNoLongerReferenced({
await storage.clearLibraryPreferencesForServerEverywhere(serverId); await storage.clearLibraryPreferencesForServerEverywhere(serverId);
} }
} }
} }
Future<Set<ServerId>> _serverIdsForProfile( /// Server ids reachable through this profile's join rows. Narrower than
String profileId, { /// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
required ProfileConnectionRegistry profileConnections, /// parent is not counted here, so folding the two together would change which
required ConnectionRegistry connections, /// per-profile prefs survive an unlink.
}) async { Future<Set<ServerId>> _serverIdsForProfile(String profileId) async {
final rows = await profileConnections.listForProfile(profileId); final rows = await profileConnections.listForProfile(profileId);
if (rows.isEmpty) return const {}; if (rows.isEmpty) return const {};
@@ -297,13 +218,9 @@ Future<Set<ServerId>> _serverIdsForProfile(
for (final row in rows) for (final row in rows)
if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection),
}; };
} }
Future<bool> _isServerReferenced( Future<bool> _isServerReferenced(ServerId serverId) async {
ServerId serverId, {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
}) async {
final rows = await profileConnections.listAll(); final rows = await profileConnections.listAll();
if (rows.isEmpty) return false; if (rows.isEmpty) return false;
@@ -314,8 +231,12 @@ Future<bool> _isServerReferenced(
if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; 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<ServerId> _serverIdsForConnection(Connection connection) { Set<ServerId> _serverIdsForConnection(Connection connection) {
return switch (connection) { return switch (connection) {
PlexAccountConnection(:final servers) => { PlexAccountConnection(:final servers) => {
@@ -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;
}
+98 -100
View File
@@ -684,18 +684,13 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}'); appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
final candidate = _peerServiceFactory(); String? winner;
_pendingRemotePeer = candidate; final connected = await _runRemoteConnect(
_session = RemoteSession( generation: generation,
role: RemoteSessionRole.remote, seedConnectingSession: true,
status: RemoteSessionStatus.connecting, rethrowOnFailure: true,
createdAt: DateTime.now(), join: (peer) async {
); winner = await peer.joinSessionRacingWithContexts(
_setupPeerServiceListeners(candidate, generation);
safeNotifyListeners();
try {
final winner = await candidate.joinSessionRacingWithContexts(
_deviceName, _deviceName,
_platform, _platform,
host.addresses, host.addresses,
@@ -703,38 +698,18 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
authContextId: authContext.id, authContextId: authContext.id,
expectedHostClientId: host.clientId, expectedHostClientId: host.clientId,
); );
if (!_ownsPeer(candidate, generation)) { },
await _disposePeerOnce(candidate); onConnected: (peer) {
return; _lastHostAddresses = [winner!];
} _lastAuthContextId = peer.selectedAuthContextId ?? authContext.id;
_lastHostClientId = peer.selectedHostClientId ?? host.clientId;
_pendingRemotePeer = null;
_peerService = candidate;
_lastHostAddresses = [winner];
_lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id;
_lastHostClientId = candidate.selectedHostClientId ?? host.clientId;
_session = _session?.copyWith(status: RemoteSessionStatus.connected); _session = _session?.copyWith(status: RemoteSessionStatus.connected);
safeNotifyListeners(); },
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner'); failureLog: 'CompanionRemote: Failed to connect to host',
} catch (error, stackTrace) { onFailure: _failRemoteConnectSession,
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(); if (connected) {
rethrow; appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
} }
} }
@@ -754,39 +729,22 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress'); appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
final candidate = _peerServiceFactory(); await _runRemoteConnect(
_pendingRemotePeer = candidate; generation: generation,
_session = RemoteSession( seedConnectingSession: true,
role: RemoteSessionRole.remote, rethrowOnFailure: true,
status: RemoteSessionStatus.connecting, join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts),
createdAt: DateTime.now(), onConnected: (peer) {
); _lastAuthContextId = peer.selectedAuthContextId;
_setupPeerServiceListeners(candidate, generation); _lastHostClientId = peer.selectedHostClientId ?? '';
safeNotifyListeners();
try {
await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return;
}
_pendingRemotePeer = null;
_peerService = candidate;
_lastAuthContextId = candidate.selectedAuthContextId;
_lastHostClientId = candidate.selectedHostClientId ?? '';
_session = _session?.copyWith(status: RemoteSessionStatus.connected); _session = _session?.copyWith(status: RemoteSessionStatus.connected);
safeNotifyListeners(); },
} catch (error, stackTrace) { failureLog: 'CompanionRemote: Failed to connect to manual host',
if (!_ownsPeer(candidate, generation)) { onFailure: _failRemoteConnectSession,
await _disposePeerOnce(candidate); );
return;
} }
_pendingRemotePeer = null; void _failRemoteConnectSession(Object error) {
_cleanupSubscriptions();
await _disposePeerOnce(candidate);
appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace);
_session = _session?.copyWith( _session = _session?.copyWith(
status: RemoteSessionStatus.error, status: RemoteSessionStatus.error,
errorMessage: _localizedRemoteError( errorMessage: _localizedRemoteError(
@@ -795,7 +753,60 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
), ),
); );
safeNotifyListeners(); safeNotifyListeners();
rethrow; }
/// 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<bool> _runRemoteConnect({
required int generation,
required Future<void> 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;
if (seedConnectingSession) {
_session = RemoteSession(
role: RemoteSessionRole.remote,
status: RemoteSessionStatus.connecting,
createdAt: DateTime.now(),
);
}
_setupPeerServiceListeners(candidate, generation);
if (seedConnectingSession) safeNotifyListeners();
try {
await join(candidate);
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return false;
}
_pendingRemotePeer = null;
_peerService = candidate;
onConnected(candidate);
safeNotifyListeners();
return true;
} catch (error, stackTrace) {
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return false;
}
_pendingRemotePeer = null;
_cleanupSubscriptions();
await _disposePeerOnce(candidate);
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; if (generation != _remoteGeneration || isDisposed) return;
final candidate = _peerServiceFactory();
_pendingRemotePeer = candidate;
_setupPeerServiceListeners(candidate, generation);
final authContextId = _authContextForId(_lastAuthContextId)?.id; final authContextId = _authContextForId(_lastAuthContextId)?.id;
final expectedHostClientId = _lastHostClientId ?? ''; final expectedHostClientId = _lastHostClientId ?? '';
try { final reconnected = await _runRemoteConnect(
await candidate.joinSessionWithContexts( generation: generation,
join: (peer) => peer.joinSessionWithContexts(
_deviceName, _deviceName,
_platform, _platform,
hostAddresses.first, hostAddresses.first,
_authContexts, _authContexts,
authContextId: authContextId, authContextId: authContextId,
expectedHostClientId: expectedHostClientId, expectedHostClientId: expectedHostClientId,
); ),
if (!_ownsPeer(candidate, generation)) { onConnected: (peer) {
await _disposePeerOnce(candidate); _lastAuthContextId = peer.selectedAuthContextId ?? authContextId;
return; _lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId;
}
_pendingRemotePeer = null;
_peerService = candidate;
_lastAuthContextId = candidate.selectedAuthContextId ?? authContextId;
_lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId;
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
_reconnectAttempts = 0; _reconnectAttempts = 0;
safeNotifyListeners(); },
appLogger.d('CompanionRemote: Reconnected successfully'); failureLog: 'CompanionRemote: Reconnect failed',
} catch (error, stackTrace) { onFailure: (_) {
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) { if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
_scheduleReconnect(generation); _scheduleReconnect(generation);
} }
},
);
if (reconnected) {
appLogger.d('CompanionRemote: Reconnected successfully');
} }
} }
+8 -58
View File
@@ -14,7 +14,7 @@ import '../services/system_shelf_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/coalesced_load_coordinator.dart'; import '../utils/coalesced_load_coordinator.dart';
import '../utils/deletion_notifier.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/media_hub_ordering.dart';
import '../utils/watch_state_notifier.dart'; import '../utils/watch_state_notifier.dart';
import 'hidden_libraries_provider.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 /// Watch on-deck items and their parent shows/seasons (an episode's watch
/// flip changes what Continue Watching should show for its series). /// flip changes what Continue Watching should show for its series).
Set<String>? get _watchedIds { Set<String>? get _watchedIds => hierarchicalEventIds(_onDeck);
final keys = <String>{};
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<String>? get _watchedGlobalKeys { Set<String>? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck);
final keys = <String>{};
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;
}
void _onWatchStateChanged(WatchStateEvent event) { void _onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) { if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
@@ -512,46 +493,15 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
unawaited(refreshContinueWatching()); unawaited(refreshContinueWatching());
} }
/// Everything on screen: the Continue Watching row plus every hub row.
Iterable<MediaItem> get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items));
/// Deletions can affect any visible list, so the filter covers on-deck and /// 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 /// hub items plus their parents (a deleted season/show takes its visible
/// episodes with it). /// episodes with it).
Set<String>? get _deletionIds { Set<String>? get _deletionIds => hierarchicalEventIds(_visibleItems);
final keys = <String>{};
void addItem(MediaItem item) {
keys.add(item.id);
if (item.parentId != null) keys.add(item.parentId!);
if (item.grandparentId != null) keys.add(item.grandparentId!);
}
_onDeck.forEach(addItem); Set<String>? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems);
for (final hub in _hubs) {
hub.items.forEach(addItem);
}
return keys;
}
Set<String>? get _deletionGlobalKeys {
final keys = <String>{};
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;
}
void _onDeletion(DeletionEvent event) { void _onDeletion(DeletionEvent event) {
// On-deck and hubs are server-backed: a download-only deletion leaves the // On-deck and hubs are server-backed: a download-only deletion leaves the
+1 -1
View File
@@ -139,7 +139,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
hydrated.add( hydrated.add(
HydratedWatchStatePatch( HydratedWatchStatePatch(
globalKey: scopedKey, globalKey: scopedKey,
patch: WatchStatePatch.fromSnapshot(snapshot), patch: snapshot,
updatedAt: latest.updatedAt, updatedAt: latest.updatedAt,
order: latest.id, order: latest.id,
), ),
+24 -61
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import '../media/ids.dart'; import '../media/ids.dart';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
@@ -18,6 +17,7 @@ import '../services/download_manager_service.dart';
import '../services/api_cache.dart'; import '../services/api_cache.dart';
import '../services/download_artwork_service.dart'; import '../services/download_artwork_service.dart';
import '../services/download_storage_service.dart'; import '../services/download_storage_service.dart';
import '../services/downloaded_video_source.dart';
import '../services/multi_server_manager.dart'; import '../services/multi_server_manager.dart';
import '../services/offline_mode_source.dart'; import '../services/offline_mode_source.dart';
import '../services/watch_state_resolver.dart'; import '../services/watch_state_resolver.dart';
@@ -26,7 +26,6 @@ import '../media/media_server_client.dart';
import '../services/sync_rule_executor.dart'; import '../services/sync_rule_executor.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/deletion_notifier.dart'; import '../utils/deletion_notifier.dart';
import '../utils/downloaded_version_match.dart';
import '../media/episode_collection.dart'; import '../media/episode_collection.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
import '../utils/content_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 /// Check if an item is in the queue
/// For shows/seasons, checks if any episodes are queued /// For shows/seasons, checks if any episodes are queued
@visibleForTesting
bool isQueued(String globalKey) { bool isQueued(String globalKey) {
final progress = getProgress(globalKey); final progress = getProgress(globalKey);
return progress?.status == DownloadStatus.queued; return progress?.status == DownloadStatus.queued;
@@ -1007,46 +1007,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
appLogger.w('No downloaded item found for globalKey: $globalKey'); appLogger.w('No downloaded item found for globalKey: $globalKey');
return null; return null;
} }
if (downloadedItem.status != DownloadStatus.completed.index) {
appLogger.w('Download not complete. Status: ${downloadedItem.status}'); final source = await resolveDownloadedVideoSource(
return null;
}
if (!downloadedVersionMatches(
downloadedItem, downloadedItem,
requestedMediaIndex: mediaIndex, requestedMediaIndex: mediaIndex,
requestedMediaSourceId: mediaSourceId, requestedMediaSourceId: mediaSourceId,
)) {
appLogger.w(
'Downloaded version mismatch for $globalKey: have index ${downloadedItem.mediaIndex} '
'(source ${downloadedItem.mediaSourceId}), expected index $mediaIndex '
'(source ${mediaSourceId?.trim()})',
); );
return null; return source?.path;
}
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;
} }
/// Queue a download for a media item. /// 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. /// Queue every playable item from a collection/playlist for download.
/// ///
/// Movies, episodes, and tracks are queued directly. Shows and seasons are /// Expansion follows [collectListLeaves] so a one-shot list download queues
/// expanded into their episodes and albums/artists into their tracks (when /// exactly what a sync rule on the same list would.
/// [expandShows] is true). Nested collections/playlists and unknown types ///
/// 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<int> queueListDownload( Future<int> queueListDownload(
List<MediaItem> items, List<MediaItem> items,
MediaServerClient client, { MediaServerClient client, {
DownloadFilter filter = DownloadFilter.all, DownloadFilter filter = DownloadFilter.all,
bool expandShows = true,
SyncRuleItem? syncRule, SyncRuleItem? syncRule,
}) async { }) async {
if (!_downloadManager.downloadsSupported) return 0; if (!_downloadManager.downloadsSupported) return 0;
@@ -1168,30 +1137,24 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
if (!_isQueueOwnershipCurrent(ownership)) return 0; if (!_isQueueOwnershipCurrent(ownership)) return 0;
final unwatchedOnly = filter == DownloadFilter.unwatched;
final membership = <MediaItem>[]; final membership = <MediaItem>[];
final candidates = <MediaItem>[]; final candidates = <MediaItem>[];
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 = <MediaItem>[];
await collectListLeaves(client, [item], unwatchedOnly: unwatchedOnly, out: leaves);
candidates.addAll(leaves);
if (syncRule != null) { 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.
if (filter == DownloadFilter.all && syncRule != null) { await collectListLeaves(client, [item], unwatchedOnly: false, out: membership);
candidates.addAll(membership);
} else { } else {
await _syncRuleExecutor.collectItemsForList( membership.addAll(leaves);
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; if (!_isQueueOwnershipCurrent(ownership)) return 0;
@@ -76,6 +76,7 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi
} }
/// Check if a specific library is hidden /// Check if a specific library is hidden
@visibleForTesting
bool isLibraryHidden(String libraryKey) => _hiddenLibraryKeys.contains(libraryKey); bool isLibraryHidden(String libraryKey) => _hiddenLibraryKeys.contains(libraryKey);
/// Refresh hidden libraries from storage /// Refresh hidden libraries from storage
+2
View File
@@ -65,9 +65,11 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
bool get isLoading => _loadState == LibrariesLoadState.loading; bool get isLoading => _loadState == LibrariesLoadState.loading;
/// Whether libraries have been loaded at least once /// Whether libraries have been loaded at least once
@visibleForTesting
bool get hasLoaded => _loadState == LibrariesLoadState.loaded; bool get hasLoaded => _loadState == LibrariesLoadState.loaded;
/// Current load state /// Current load state
@visibleForTesting
LibrariesLoadState get loadState => _loadState; LibrariesLoadState get loadState => _loadState;
/// Error message if loading failed /// Error message if loading failed
+15 -33
View File
@@ -108,24 +108,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
/// filter to a one-element set when no filter is currently set. /// filter to a one-element set when no filter is currently set.
void addToVisibleServerIds(ServerId serverId) { void addToVisibleServerIds(ServerId serverId) {
final current = _visibleServerIds; final current = _visibleServerIds;
if (current == null) { if (current != null && current.contains(serverId)) return;
_serverManager.setVisibleServerIds({serverId}); _serverManager.setVisibleServerIds({...?current, serverId});
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
safeNotifyListeners();
_refreshLiveTvAvailabilitySoon();
return;
}
if (current.contains(serverId)) return;
_serverManager.setVisibleServerIds({...current, serverId});
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
safeNotifyListeners(); safeNotifyListeners();
_refreshLiveTvAvailabilitySoon(); _refreshLiveTvAvailabilitySoon();
} }
/// Keep only ids the manager considers visible under the active filter.
List<String> _visible(List<String> ids) =>
ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList();
void _pruneLiveTvServersForVisibility() { void _pruneLiveTvServersForVisibility() {
final filter = _visibleServerIds; if (_visibleServerIds == null) return;
if (filter == null) return; _liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId)));
_liveTvServers.removeWhere((s) => !filter.contains(s.serverId));
_hasLiveTv = _liveTvServers.isNotEmpty; _hasLiveTv = _liveTvServers.isNotEmpty;
} }
@@ -199,20 +195,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
} }
/// Get all online server IDs (visibility-filtered). /// Get all online server IDs (visibility-filtered).
List<String> get onlineServerIds { List<String> get onlineServerIds => _visible(_serverManager.onlineServerIds);
final all = _serverManager.onlineServerIds;
final filter = _visibleServerIds;
if (filter == null) return all;
return all.where(filter.contains).toList();
}
/// Get all server IDs (visibility-filtered). /// Get all server IDs (visibility-filtered).
List<String> get serverIds { List<String> get serverIds => _visible(_serverManager.serverIds);
final all = _serverManager.serverIds;
final filter = _visibleServerIds;
if (filter == null) return all;
return all.where(filter.contains).toList();
}
/// Server ids the active profile is expected to have, including unreachable /// Server ids the active profile is expected to have, including unreachable
/// Plex servers that have no live client yet. /// 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). /// Check if a server is online (and visible under the active profile).
bool isServerOnline(ServerId serverId) { bool isServerOnline(ServerId serverId) =>
final filter = _visibleServerIds; _serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId);
if (filter != null && !filter.contains(serverId)) return false;
return _serverManager.isServerOnline(serverId);
}
/// Get number of online servers /// Get number of online servers
int get onlineServerCount => onlineServerIds.length; int get onlineServerCount => onlineServerIds.length;
@@ -312,10 +295,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
} }
} }
final filter = _visibleServerIds; final visibleLiveTvServers = newLiveTvServers
final visibleLiveTvServers = filter == null .where((s) => _serverManager.isServerVisible(ServerId(s.serverId)))
? newLiveTvServers .toList();
: newLiveTvServers.where((s) => filter.contains(s.serverId)).toList();
final hadLiveTv = _hasLiveTv; final hadLiveTv = _hasLiveTv;
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
+2
View File
@@ -75,9 +75,11 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi
} }
/// Whether there is network connectivity (WiFi, mobile data, etc.) /// Whether there is network connectivity (WiFi, mobile data, etc.)
@visibleForTesting
bool get hasNetworkConnection => _hasNetworkConnection; bool get hasNetworkConnection => _hasNetworkConnection;
/// Whether at least one media server (Plex or Jellyfin) is reachable /// Whether at least one media server (Plex or Jellyfin) is reachable
@visibleForTesting
bool get hasServerConnection => _hasServerConnection; bool get hasServerConnection => _hasServerConnection;
bool get _hasKnownVisibleServers => bool get _hasKnownVisibleServers =>
@@ -71,6 +71,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
/// 2. Metadata from download provider /// 2. Metadata from download provider
/// ///
/// Returns null if no position is available. /// Returns null if no position is available.
@visibleForTesting
Future<int?> getViewOffset(String globalKey) async { Future<int?> getViewOffset(String globalKey) async {
// First check local offline progress // First check local offline progress
final localOffset = await _syncService.getLocalViewOffset(globalKey); final localOffset = await _syncService.getLocalViewOffset(globalKey);
+12 -39
View File
@@ -306,7 +306,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
var anchor = current; var anchor = current;
// Bounded so a pathological all-same-file queue cannot spin. // Bounded so a pathological all-same-file queue cannot spin.
for (var steps = 0; steps <= _playQueueTotalCount; steps++) { for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemAfter(anchor); final result = await _itemAtOffset(anchor, 1);
final candidate = result.item; final candidate = result.item;
if (result.status != QueueNavigationStatus.found || candidate == null) { if (result.status != QueueNavigationStatus.found || candidate == null) {
return result; return result;
@@ -337,7 +337,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
final current = _loadedItems[indexResult.index!]; final current = _loadedItems[indexResult.index!];
MediaItem candidate = current; MediaItem candidate = current;
for (var steps = 0; steps <= _playQueueTotalCount; steps++) { for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemBefore(candidate); final result = await _itemAtOffset(candidate, -1);
final before = result.item; final before = result.item;
if (result.status != QueueNavigationStatus.found || before == null) { if (result.status != QueueNavigationStatus.found || before == null) {
return result; return result;
@@ -348,7 +348,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
// Collapse to the first episode of the candidate's same-file group. // Collapse to the first episode of the candidate's same-file group.
for (var steps = 0; steps <= _playQueueTotalCount; steps++) { for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
final result = await _itemBefore(candidate); final result = await _itemAtOffset(candidate, -1);
final before = result.item; final before = result.item;
if (result.status == QueueNavigationStatus.failed) return result; if (result.status == QueueNavigationStatus.failed) return result;
if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) { if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) {
@@ -359,18 +359,19 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
return QueueNavigationResult.found(candidate); 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 /// window when needed. The centered response proves whether [anchor] is at
/// the global boundary; a window-local index is never compared with the /// the global boundary; a window-local index is never compared with the
/// queue's global item count. /// queue's global item count.
Future<QueueNavigationResult> _itemAfter(MediaItem anchor) async { Future<QueueNavigationResult> _itemAtOffset(MediaItem anchor, int delta) async {
final anchorId = playQueueItemIdFor(anchor); final anchorId = playQueueItemIdFor(anchor);
if (anchorId == null) return const QueueNavigationResult.unavailable(); if (anchorId == null) return const QueueNavigationResult.unavailable();
var anchorIndex = _findLoadedIndex(anchorId); var anchorIndex = _findLoadedIndex(anchorId);
if (anchorIndex == -1) return const QueueNavigationResult.unavailable(); if (anchorIndex == -1) return const QueueNavigationResult.unavailable();
if (anchorIndex + 1 < _loadedItems.length) { var target = anchorIndex + delta;
return QueueNavigationResult.found(_loadedItems[anchorIndex + 1]); if (target >= 0 && target < _loadedItems.length) {
return QueueNavigationResult.found(_loadedItems[target]);
} }
// Local queues are fully resident, so their window edge is the queue edge. // 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 // 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)) { if (!await _loadServerWindow(anchorId)) {
return const QueueNavigationResult.failed(); return const QueueNavigationResult.failed();
} }
anchorIndex = _findLoadedIndex(anchorId); anchorIndex = _findLoadedIndex(anchorId);
if (anchorIndex == -1) return const QueueNavigationResult.failed(); if (anchorIndex == -1) return const QueueNavigationResult.failed();
return anchorIndex + 1 < _loadedItems.length target = anchorIndex + delta;
? QueueNavigationResult.found(_loadedItems[anchorIndex + 1]) return target >= 0 && target < _loadedItems.length
: const QueueNavigationResult.boundary(); ? QueueNavigationResult.found(_loadedItems[target])
}
/// The queue item immediately before [anchor], extending a server-backed
/// window when needed.
Future<QueueNavigationResult> _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])
: const QueueNavigationResult.boundary(); : const QueueNavigationResult.boundary();
} }
+1
View File
@@ -88,6 +88,7 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W
static const _themeChannel = MethodChannel('com.plezy/theme'); static const _themeChannel = MethodChannel('com.plezy/theme');
@visibleForTesting
Future<void> setThemeMode(settings.ThemeMode mode) async { Future<void> setThemeMode(settings.ThemeMode mode) async {
if (_themeMode == mode) return; if (_themeMode == mode) return;
final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance(); final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance();
+106 -164
View File
@@ -62,13 +62,23 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
final MalAuthService _malAuth; final MalAuthService _malAuth;
final AnilistAuthService _anilistAuth; final AnilistAuthService _anilistAuth;
final SimklAuthService _simklAuth; final SimklAuthService _simklAuth;
final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal);
final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist);
final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl);
TrackerSession? _mal; final _TrackerSlot _mal = _TrackerSlot(
TrackerSession? _anilist; TrackerService.mal,
TrackerSession? _simkl; (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 = ''; String _activeUserUuid = '';
int _profileBindingGeneration = 0; int _profileBindingGeneration = 0;
@@ -76,22 +86,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
Completer<void>? _cancelCompleter; Completer<void>? _cancelCompleter;
int _connectGeneration = 0; int _connectGeneration = 0;
// Bumped on every rebind so a late callback from a disposed client (e.g. an TrackerSession? get mal => _mal.session;
// in-flight MAL token refresh that resolves after a profile switch) can't TrackerSession? get anilist => _anilist.session;
// persist or clear a session under the wrong profile, and so a disconnect TrackerSession? get simkl => _simkl.session;
// 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; bool get isMalConnected => _mal.session != null;
TrackerSession? get anilist => _anilist; bool get isAnilistConnected => _anilist.session != null;
TrackerSession? get simkl => _simkl; bool get isSimklConnected => _simkl.session != null;
bool get isMalConnected => _mal != null;
bool get isAnilistConnected => _anilist != null;
bool get isSimklConnected => _simkl != null;
/// The live MAL client for the Explore catalog, shared with the scrobble /// The live MAL client for the Explore catalog, shared with the scrobble
/// tracker so both ride one session (MAL rotates refresh tokens — a second /// 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 /// provider's own session so a freshly-mounted profile subtree never sees
/// the previous profile's client while its sessions are still loading; /// the previous profile's client while its sessions are still loading;
/// every rebind is followed by a notify, so proxy consumers track identity. /// 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], /// Live AniList and Simkl clients for Explore. Like [malCatalogClient],
/// these are gated on this provider's profile-bound sessions so a fresh /// these are gated on this provider's profile-bound sessions so a fresh
/// profile subtree cannot observe clients still bound to the prior profile. /// profile subtree cannot observe clients still bound to the prior profile.
AnilistClient? get anilistCatalogClient => _anilist == null ? null : AnilistTracker.instance.client; AnilistClient? get anilistCatalogClient => _anilist.session == null ? null : AnilistTracker.instance.client;
SimklClient? get simklCatalogClient => _simkl == null ? null : SimklTracker.instance.client; SimklClient? get simklCatalogClient => _simkl.session == null ? null : SimklTracker.instance.client;
String? get malUsername => _mal?.username; String? get malUsername => _mal.session?.username;
String? get anilistUsername => _anilist?.username; String? get anilistUsername => _anilist.session?.username;
String? get simklUsername => _simkl?.username; String? get simklUsername => _simkl.session?.username;
bool isConnecting(TrackerService service) => _connecting == service; 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 // Snapshot each service's rebind generation before the await so a disconnect
// that races this load only suppresses its own service (whose generation // that races this load only suppresses its own service (whose generation
// moves) rather than dropping the freshly-loaded sessions for the others. // moves) rather than dropping the freshly-loaded sessions for the others.
final malRebind = _malRebind.value; final rebinds = [for (final slot in _slots) slot.rebindGeneration];
final anilistRebind = _anilistRebind.value; final results = await Future.wait<TrackerSession?>([for (final slot in _slots) slot.store.load(userUuid)]);
final simklRebind = _simklRebind.value;
final results = await Future.wait<TrackerSession?>([
_malStore.load(userUuid),
_anilistStore.load(userUuid),
_simklStore.load(userUuid),
]);
if (!_isCurrentProfileBinding(userUuid, generation)) return; if (!_isCurrentProfileBinding(userUuid, generation)) return;
if (_malRebind.value == malRebind) { for (var i = 0; i < _slots.length; i++) {
_mal = results.first; final slot = _slots[i];
_rebindMal(); if (slot.rebindGeneration != rebinds[i]) continue;
} slot.session = results[i];
if (_anilistRebind.value == anilistRebind) { _rebind(slot);
_anilist = results[1];
_rebindAnilist();
}
if (_simklRebind.value == simklRebind) {
_simkl = results[2];
_rebindSimkl();
} }
// Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so // Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so
// the next lookup re-evaluates whether to consult Fribb. // the next lookup re-evaluates whether to consult Fribb.
@@ -159,78 +148,51 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
Future<bool> connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( Future<bool> connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect(
service: TrackerService.mal, _mal,
alreadyConnected: isMalConnected,
authorize: () => _malAuth.authorize( authorize: () => _malAuth.authorize(
onCodeReady: onCodeReady, onCodeReady: onCodeReady,
shouldCancel: () => _cancelCompleter?.isCompleted ?? false, shouldCancel: _isConnectCancelled,
onCancel: _cancelCompleter!.future, onCancel: _cancelCompleter!.future,
), ),
enrich: _enrichMal, enrich: _enrichMal,
store: _malStore,
assign: (s) {
_mal = s;
_rebindMal();
},
); );
Future<void> disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () { Future<void> disconnectMal() => _clearAndRebind(_mal);
_mal = null;
_rebindMal();
});
Future<bool> connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect( Future<bool> connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect(
service: TrackerService.anilist, _anilist,
alreadyConnected: isAnilistConnected,
authorize: () => _anilistAuth.authorize( authorize: () => _anilistAuth.authorize(
onCodeReady: onCodeReady, onCodeReady: onCodeReady,
shouldCancel: () => _cancelCompleter?.isCompleted ?? false, shouldCancel: _isConnectCancelled,
onCancel: _cancelCompleter!.future, onCancel: _cancelCompleter!.future,
), ),
enrich: _enrichAnilist, enrich: _enrichAnilist,
store: _anilistStore,
assign: (s) {
_anilist = s;
_rebindAnilist();
},
); );
Future<void> disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () { Future<void> disconnectAnilist() => _clearAndRebind(_anilist);
_anilist = null;
_rebindAnilist();
});
Future<bool> connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect( Future<bool> connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect(
service: TrackerService.simkl, _simkl,
alreadyConnected: isSimklConnected,
authorize: () => _simklAuth.authorize( authorize: () => _simklAuth.authorize(
onCodeReady: onCodeReady, onCodeReady: onCodeReady,
shouldCancel: () => _cancelCompleter?.isCompleted ?? false, shouldCancel: _isConnectCancelled,
onCancel: _cancelCompleter!.future, onCancel: _cancelCompleter!.future,
), ),
enrich: _enrichSimkl, enrich: _enrichSimkl,
store: _simklStore,
assign: (s) {
_simkl = s;
_rebindSimkl();
},
); );
Future<void> disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () { Future<void> disconnectSimkl() => _clearAndRebind(_simkl);
_simkl = null;
_rebindSimkl();
});
Future<bool> _runConnect({ bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false;
required TrackerService service,
required bool alreadyConnected, Future<bool> _runConnect(
_TrackerSlot slot, {
required Future<TrackerSession?> Function() authorize, required Future<TrackerSession?> Function() authorize,
required Future<TrackerSession> Function(TrackerSession raw) enrich, required Future<TrackerSession> Function(TrackerSession raw) enrich,
required TrackerAccountStore store,
required void Function(TrackerSession session) assign,
}) async { }) 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 userUuid = _activeUserUuid;
final generation = ++_connectGeneration; final generation = ++_connectGeneration;
_connecting = service; _connecting = service;
@@ -248,11 +210,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
enrich: enrich, enrich: enrich,
save: (session) async { save: (session) async {
if (!_isCurrentConnect(service, userUuid, generation)) return; if (!_isCurrentConnect(service, userUuid, generation)) return;
await store.save(userUuid, session); await slot.store.save(userUuid, session);
}, },
assign: (session) { assign: (session) {
if (!_isCurrentConnect(service, userUuid, generation)) return; if (!_isCurrentConnect(service, userUuid, generation)) return;
assign(session); slot.session = session;
_rebind(slot);
TrackerCoordinator.instance.invalidateResolverCache(); TrackerCoordinator.instance.invalidateResolverCache();
assigned = true; assigned = true;
}, },
@@ -267,20 +230,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
} }
Future<void> _clearAndRebind( Future<void> _clearAndRebind(_TrackerSlot slot) async {
TrackerService service, _invalidateConnect(slot.service);
TrackerAccountStore store,
void Function() clearAndRebind,
) async {
_invalidateConnect(service);
final userUuid = _activeUserUuid; final userUuid = _activeUserUuid;
// `clearAndRebind` bumps the affected service's rebind generation, which is // The rebind bumps the affected service's generation, which is what stops
// what stops an in-flight profile load from resurrecting the cleared // an in-flight profile load from resurrecting the cleared session — so we
// session — so we no longer touch the shared profile-binding generation // no longer touch the shared profile-binding generation (which would also
// (which would also abort that load for the other two services). // abort that load for the other two services).
clearAndRebind(); slot.session = null;
_rebind(slot);
safeNotifyListeners(); safeNotifyListeners();
await store.clear(userUuid); await slot.store.clear(userUuid);
} }
void _invalidateConnect([TrackerService? service]) { void _invalidateConnect([TrackerService? service]) {
@@ -322,68 +282,33 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
}, },
); );
/// Snapshot the active profile + bump this service's rebind generation, /// Push a slot's session to its tracker, snapshotting the active profile and
/// returning the bound uuid and an `isCurrent` predicate. Bumping here is what /// bumping the slot's rebind generation first. Bumping here is what lets a
/// lets a stale client callback — or a racing profile load — detect that it /// stale client callback — or a racing profile load — detect that it has been
/// has been superseded for this service. /// superseded for this service.
(String, bool Function()) _beginRebind(_RebindGeneration gen) { void _rebind(_TrackerSlot slot) {
if (isDisposed) return;
final boundUuid = _activeUserUuid; final boundUuid = _activeUserUuid;
final generation = gen.bump(); final generation = ++slot.rebindGeneration;
bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == gen.value; bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == slot.rebindGeneration;
return (boundUuid, isCurrent); slot.bind(
} slot.session,
onInvalidated: () {
void _rebindMal() {
if (isDisposed) return;
final (boundUuid, isCurrent) = _beginRebind(_malRebind);
MalTracker.instance.rebindSession(
_mal,
onSessionInvalidated: () {
if (isCurrent()) _handleInvalidated(_malStore, boundUuid, () => _mal = null, _rebindMal);
},
onSessionUpdated: (next) {
if (!isCurrent()) return; if (!isCurrent()) return;
_mal = next; slot.store.clear(boundUuid);
_malStore.save(boundUuid, next); slot.session = null;
_rebind(slot);
safeNotifyListeners();
},
onUpdated: (next) {
if (!isCurrent()) return;
slot.session = next;
slot.store.save(boundUuid, next);
safeNotifyListeners(); 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 @override
void dispose() { void dispose() {
_invalidateConnect(); _invalidateConnect();
@@ -394,12 +319,29 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
} }
/// A monotonic per-service rebind counter. Each rebind bumps it so a stale /// Pushes a session to one service's tracker singleton. `onUpdated` is only
/// client callback — or a profile load that started earlier — can tell it has /// wired for MAL, the one service that rotates its refresh token.
/// been superseded for that service. typedef _TrackerBind =
class _RebindGeneration { void Function(
int _value = 0; TrackerSession? session, {
required void Function() onInvalidated,
void Function(TrackerSession session)? onUpdated,
});
int bump() => ++_value; /// Owns one service's session, the generation guarding its rebinds, and the
int get value => _value; /// 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;
} }
+8 -40
View File
@@ -11,36 +11,10 @@ import '../services/watch_state_resolver.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
import '../utils/watch_state_notifier.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 @immutable
class HydratedWatchStatePatch { class HydratedWatchStatePatch {
final String globalKey; final String globalKey;
final WatchStatePatch patch; final WatchStateSnapshot patch;
final int updatedAt; final int updatedAt;
final int order; final int order;
@@ -53,7 +27,7 @@ class HydratedWatchStatePatch {
} }
class _WatchStatePatchEntry { class _WatchStatePatchEntry {
final WatchStatePatch patch; final WatchStateSnapshot patch;
final int updatedAt; final int updatedAt;
final int sequence; final int sequence;
final bool isSessionEvent; final bool isSessionEvent;
@@ -123,9 +97,10 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
return _exactEntryFor(globalKey); 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); var best = _entryFor(item.globalKey);
if (item.parentChain.isNotEmpty) { if (item.parentChain.isNotEmpty) {
final serverId = serverIdOrNull(item.serverId); final serverId = serverIdOrNull(item.serverId);
@@ -147,14 +122,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
return [for (final item in items) apply(item)]; return [for (final item in items) apply(item)];
} }
static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) { static MediaItem applyPatch(MediaItem item, WatchStateSnapshot? patch) => patch == null ? item : patch.apply(item);
if (patch == null) return item;
return WatchStateSnapshot(
isWatched: patch.isWatched,
hasViewOffsetMs: patch.hasViewOffsetMs,
viewOffsetMs: patch.viewOffsetMs,
).apply(item);
}
void setActiveProfileId(String? profileId) { void setActiveProfileId(String? profileId) {
if (_activeProfileId == profileId) return; if (_activeProfileId == profileId) return;
@@ -216,7 +184,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
? buildGlobalKey(ServerId(resolvedScope), event.itemId) ? buildGlobalKey(ServerId(resolvedScope), event.itemId)
: event.globalKey; : event.globalKey;
_patches[key] = _WatchStatePatchEntry( _patches[key] = _WatchStatePatchEntry(
WatchStatePatch.fromSnapshot(snapshot), snapshot,
updatedAt: DateTime.now().millisecondsSinceEpoch, updatedAt: DateTime.now().millisecondsSinceEpoch,
sequence: ++_sequence, sequence: ++_sequence,
isSessionEvent: true, isSessionEvent: true,
@@ -240,7 +208,7 @@ extension WatchStateResolution on BuildContext {
/// ancestor). Use in `build`. /// ancestor). Use in `build`.
MediaItem withFreshWatchState(MediaItem item) { MediaItem withFreshWatchState(MediaItem item) {
try { try {
final patch = select<WatchStateStore, WatchStatePatch?>((store) => store.patchForItem(item)); final patch = select<WatchStateStore, WatchStateSnapshot?>((store) => store.patchForItem(item));
return WatchStateStore.applyPatch(item, patch); return WatchStateStore.applyPatch(item, patch);
} on ProviderNotFoundException { } on ProviderNotFoundException {
return item; return item;
+9 -28
View File
@@ -7,6 +7,7 @@ import '../media/media_item.dart';
import '../media/media_kind.dart'; import '../media/media_kind.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../mixins/paginated_item_loader.dart'; import '../mixins/paginated_item_loader.dart';
import '../mixins/standard_paginated_view.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/media_server_http_client.dart'; import '../utils/media_server_http_client.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
@@ -48,7 +49,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
with with
GridFocusNodeMixin<ActorMediaScreen>, GridFocusNodeMixin<ActorMediaScreen>,
FocusableDetailScreenMixin<ActorMediaScreen>, FocusableDetailScreenMixin<ActorMediaScreen>,
PaginatedItemLoader<MediaItem, ActorMediaScreen> { PaginatedItemLoader<MediaItem, ActorMediaScreen>,
PaginatedItemUpdatable<ActorMediaScreen>,
StandardPaginatedView<MediaItem, ActorMediaScreen> {
static const int _pageSize = 200; static const int _pageSize = 200;
@override @override
@@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
} }
@override @override
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { Future<void> loadItems() {
for (final entry in loadedItems.entries) { return loadStandardPaginatedItems(
if (entry.value.globalKey == sourceGlobalKey) {
loadedItems[entry.key] = updatedItem;
return;
}
}
}
@override
Future<void> loadItems() async {
await loadInitialPaginatedItems(
pageSize: _pageSize, pageSize: _pageSize,
resetViewState: () { errorMessageFor: (error, stackTrace) {
isLoading = true; appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
errorMessage = null; return t.messages.errorLoading(error: error.toString());
items = [];
},
applyLoadedItems: (loaded) {
items = loaded;
isLoading = false;
},
applyError: (error, _) {
errorMessage = t.messages.errorLoading(error: error.toString());
isLoading = false;
}, },
onLoaded: (loadedCount, totalCount) { onLoaded: (loadedCount, totalCount) {
appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}'); appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}');
autoFocusFirstItemAfterLoad(); autoFocusFirstItemAfterLoad();
}, },
onError: (error, stackTrace) {
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
},
); );
} }
+2 -2
View File
@@ -12,6 +12,7 @@ import '../profiles/active_profile_provider.dart';
import '../profiles/plex_home_service.dart'; import '../profiles/plex_home_service.dart';
import '../profiles/profile.dart'; import '../profiles/profile.dart';
import '../profiles/profile_connection_registry.dart'; import '../profiles/profile_connection_registry.dart';
import '../profiles/profile_selection_policy.dart';
import '../services/plex_auth_service.dart'; import '../services/plex_auth_service.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
@@ -196,8 +197,7 @@ class _AuthScreenState extends State<AuthScreen> {
activeProfile: activeProfiles.active, activeProfile: activeProfiles.active,
hasProfiles: activeProfiles.profiles.isNotEmpty, hasProfiles: activeProfiles.profiles.isNotEmpty,
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true, accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
requireProfileSelectionOnOpen: requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings),
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles,
); );
if (promptHandled) { if (promptHandled) {
final selected = await Navigator.of( final selected = await Navigator.of(
+27 -47
View File
@@ -1,11 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../media/ids.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 'package:provider/provider.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_playlist.dart'; import '../media/media_playlist.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../providers/download_provider.dart';
import '../providers/multi_server_provider.dart'; import '../providers/multi_server_provider.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../services/media_list_playback_launcher.dart'; import '../services/media_list_playback_launcher.dart';
@@ -43,19 +42,34 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
/// Optional icon to show when list is empty /// Optional icon to show when list is empty
IconData? get emptyIcon => null; 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<DownloadProvider>().syncRuleKeyForClient(
client,
id,
serverId: ServerId(_mediaItemServerId ?? client.serverId),
);
}
String? _resolveMediaItemServerId() { String? _resolveMediaItemServerId() {
final item = mediaItem; final serverId = _mediaItemServerId;
String? serverId; if (serverId != null) return serverId;
if (item is MediaItem) {
serverId = item.serverId;
} else if (item is MediaPlaylist) {
serverId = item.serverId;
}
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false); final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
serverId = multiServerProvider.onlineServerIds.firstOrNull; return multiServerProvider.onlineServerIds.firstOrNull;
}
return serverId;
} }
MediaServerClient _getMediaClientForMediaItem() { MediaServerClient _getMediaClientForMediaItem() {
@@ -133,40 +147,6 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
return []; return [];
} }
/// Build standard app bar actions (play, shuffle, delete)
/// Subclasses can override to customize actions
List<Widget> 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 /// Mixin that provides standard loadItems implementation for media lists
+6 -54
View File
@@ -1,19 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.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 '../i18n/strings.g.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../mixins/debounced_media_search.dart'; import '../mixins/debounced_media_search.dart';
import '../services/catalog/catalog_source.dart'; import '../services/catalog/catalog_source.dart';
import '../utils/focus_utils.dart'; import '../utils/focus_utils.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
import '../widgets/app_icon.dart';
import '../widgets/focusable_media_card.dart'; import '../widgets/focusable_media_card.dart';
import '../widgets/focused_scroll_scaffold.dart'; import '../widgets/focused_scroll_scaffold.dart';
import '../widgets/loading_indicator_box.dart'; import '../widgets/loading_indicator_box.dart';
import '../widgets/pill_input_decoration.dart'; import '../widgets/search_input_field.dart';
import 'libraries/state_messages.dart'; import 'libraries/state_messages.dart';
/// Free-text search of one catalog source (the Explore tab's active source), /// 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<CatalogSearchScreen> with DebouncedMediaSearch { class _CatalogSearchScreenState extends State<CatalogSearchScreen> with DebouncedMediaSearch {
final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear');
@override @override
String get searchDebugLabel => 'CatalogSearch'; String get searchDebugLabel => 'CatalogSearch';
@@ -46,17 +42,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
FocusUtils.requestFocusAfterBuild(this, searchFocusNode); FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
} }
@override
void dispose() {
_clearFocusNode.dispose();
super.dispose();
}
void _clearSearch() {
searchController.clear();
searchFocusNode.requestFocus();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final sourceName = widget.source.displayName; final sourceName = widget.source.displayName;
@@ -64,36 +49,13 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
title: Text(t.explore.searchHint(source: sourceName)), title: Text(t.explore.searchHint(source: sourceName)),
slivers: [ slivers: [
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: SearchInputField(
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
child: Stack(
alignment: Alignment.centerRight,
children: [
FocusableTextField(
controller: searchController, controller: searchController,
focusNode: searchFocusNode, focusNode: searchFocusNode,
textInputAction: TextInputAction.search, debugLabel: searchDebugLabel,
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), 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, onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
autoScroll: false, onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
),
],
),
), ),
), ),
if (isSearching) if (isSearching)
@@ -125,11 +87,7 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
} }
Widget _buildResultsList() { Widget _buildResultsList() {
return SliverPadding( return buildResultsSliver((context, index) {
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = searchResults[index]; final item = searchResults[index];
return FocusableMediaCard( return FocusableMediaCard(
key: Key(item.globalKey), key: Key(item.globalKey),
@@ -139,12 +97,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
focusNode: index == 0 ? firstResultFocusNode : null, focusNode: index == 0 ? firstResultFocusNode : null,
onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null, onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null,
); );
}, });
childCount: searchResults.length,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
),
),
);
} }
} }
+22 -69
View File
@@ -1,17 +1,17 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../media/ids.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../focus/focusable_action_bar.dart'; import '../focus/focusable_action_bar.dart';
import '../media/library_query.dart'; import '../media/library_query.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../mixins/paginated_item_loader.dart'; import '../mixins/paginated_item_loader.dart';
import '../mixins/standard_paginated_view.dart';
import '../providers/download_provider.dart'; import '../providers/download_provider.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/content_utils.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/error_message_utils.dart'; import '../utils/error_message_utils.dart';
import '../utils/download_utils.dart'; import '../utils/download_utils.dart';
import '../utils/platform_detector.dart';
import '../utils/media_server_http_client.dart'; import '../utils/media_server_http_client.dart';
import '../utils/snackbar_helper.dart'; import '../utils/snackbar_helper.dart';
import '../widgets/desktop_app_bar.dart'; import '../widgets/desktop_app_bar.dart';
@@ -35,7 +35,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
with with
GridFocusNodeMixin<CollectionDetailScreen>, GridFocusNodeMixin<CollectionDetailScreen>,
FocusableDetailScreenMixin<CollectionDetailScreen>, FocusableDetailScreenMixin<CollectionDetailScreen>,
PaginatedItemLoader<MediaItem, CollectionDetailScreen> { PaginatedItemLoader<MediaItem, CollectionDetailScreen>,
PaginatedItemUpdatable<CollectionDetailScreen>,
StandardPaginatedView<MediaItem, CollectionDetailScreen> {
static const int _pageSize = 200; static const int _pageSize = 200;
@override @override
@@ -75,49 +77,21 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
} }
@override @override
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { Future<void> loadItems() {
// Search [loadedItems] (not the flat [items] snapshot, which only has return loadStandardPaginatedItems(
// the first page) so refreshing an item at a scrolled-in position updates
// the grid in place.
for (final entry in loadedItems.entries) {
if (entry.value.globalKey == sourceGlobalKey) {
loadedItems[entry.key] = updatedItem;
return;
}
}
}
@override
Future<void> loadItems() async {
String? loadErrorMessage;
await loadInitialPaginatedItems(
pageSize: _pageSize, pageSize: _pageSize,
resetViewState: () { errorMessageFor: (error, stackTrace) =>
isLoading = true; localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection),
errorMessage = null;
items = [];
},
applyLoadedItems: (loaded) {
items = loaded;
isLoading = false;
},
applyError: (error, stackTrace) {
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: t.collections.collection);
isLoading = false;
},
onLoaded: (loadedCount, totalCount) { onLoaded: (loadedCount, totalCount) {
appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}'); appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}');
autoFocusFirstItemAfterLoad(); autoFocusFirstItemAfterLoad();
}, },
onError: (error, stackTrace) {
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection);
},
); );
} }
@override @override
List<FocusableAction> getAppBarActions() { List<FocusableAction> getAppBarActions() {
final ruleKey = _collectionSyncRuleKey(); final ruleKey = syncRuleKey;
// Select the specific bool we care about so unrelated DownloadProvider // Select the specific bool we care about so unrelated DownloadProvider
// ticks (e.g. active download progress) don't rebuild the app bar. // ticks (e.g. active download progress) don't rebuild the app bar.
final hasRule = context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey)); final hasRule = context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
@@ -127,18 +101,15 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
], ],
if (!PlatformDetector.isAppleTV()) // Emptiness is handled inside [_downloadCollection], so the download
FocusableAction( // entry stays visible for empty collections.
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, ...buildSyncRuleActions(
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, context,
onPressed: hasRule ? _manageCollectionSyncRule : _downloadCollection, ruleKey: ruleKey,
iconColor: hasRule ? Colors.teal : null, displayTitle: widget.collection.displayTitle,
), hasRule: hasRule,
if (!PlatformDetector.isAppleTV() && hasRule) showDownload: true,
FocusableAction( onDownload: _downloadCollection,
icon: Symbols.sync_disabled_rounded,
tooltip: t.downloads.removeSyncRule,
onPressed: _removeCollectionSyncRule,
), ),
FocusableAction( FocusableAction(
icon: Symbols.delete_rounded, icon: Symbols.delete_rounded,
@@ -164,9 +135,10 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
libraryTitle: widget.collection.libraryTitle, libraryTitle: widget.collection.libraryTitle,
); );
if (!mounted) return; if (!mounted) return;
final result = await showCollectionDownloadOptionsAndQueue( final result = await showListDownloadOptionsAndQueue(
context, context,
collectionMetadata: widget.collection, rootMetadata: widget.collection,
targetType: ContentTypes.collection,
items: allItems, items: allItems,
client: mediaClient, client: mediaClient,
downloadProvider: downloadProvider, downloadProvider: downloadProvider,
@@ -181,25 +153,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
} }
} }
Future<void> _manageCollectionSyncRule() =>
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _collectionSyncRuleKey());
Future<void> _removeCollectionSyncRule() => removeSyncRuleAndSnack(
context,
downloadProvider: context.read<DownloadProvider>(),
globalKey: _collectionSyncRuleKey(),
displayTitle: widget.collection.displayTitle,
);
String _collectionSyncRuleKey() {
final serverId = widget.collection.serverId ?? mediaClient.serverId;
return context.read<DownloadProvider>().syncRuleKeyForClient(
mediaClient,
widget.collection.id,
serverId: ServerId(serverId),
);
}
Future<void> _deleteCollection() async { Future<void> _deleteCollection() async {
final confirmed = await showDeleteConfirmation( final confirmed = await showDeleteConfirmation(
context, context,
+25 -160
View File
@@ -21,7 +21,7 @@ import '../utils/media_image_helper.dart';
import '../utils/content_utils.dart'; import '../utils/content_utils.dart';
import '../widgets/cycling_media_backdrop.dart'; import '../widgets/cycling_media_backdrop.dart';
import '../widgets/optimized_media_image.dart' show ClearLogoImage, blurArtwork; 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/discover_provider.dart';
import '../providers/multi_server_provider.dart'; import '../providers/multi_server_provider.dart';
import '../providers/watch_state_store.dart'; import '../providers/watch_state_store.dart';
@@ -47,6 +47,7 @@ import '../i18n/strings.g.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/dialogs.dart'; import '../utils/dialogs.dart';
import '../utils/formatters.dart'; import '../utils/formatters.dart';
import '../utils/hub_icons.dart';
import '../utils/media_navigation_helper.dart'; import '../utils/media_navigation_helper.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/video_player_navigation.dart'; import '../utils/video_player_navigation.dart';
@@ -176,8 +177,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!; if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!;
final hubs = <MediaHub>[]; final hubs = <MediaHub>[];
if (_onDeck.isNotEmpty) { if (_onDeck.isNotEmpty) {
hubs.add( hubs.add(_continueWatchingHub);
MediaHub( }
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
_tvBrowseHubsCache = hubs;
_tvBrowseHubsCacheKey = key;
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', id: 'continue_watching',
title: t.discover.continueWatching, title: t.discover.continueWatching,
type: 'mixed', type: 'mixed',
@@ -185,14 +195,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
more: _hasMoreContinueWatching, more: _hasMoreContinueWatching,
items: _onDeck, items: _onDeck,
),
); );
}
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
_tvBrowseHubsCache = hubs;
_tvBrowseHubsCacheKey = key;
return hubs;
}
void _setSpotlightItem(MediaItem item) => _spotlight.select(item); void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
@@ -597,101 +600,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
unawaited(_discover.load()); 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. /// Whether the loaded hubs span more than one connected server.
bool _hubsSpanMultipleServers() { bool _hubsSpanMultipleServers() {
final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet(); final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet();
@@ -815,26 +723,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
} }
Widget _buildOverlaidAppBar() { Widget _buildOverlaidAppBar() {
final statusBarHeight = MediaQuery.paddingOf(context).top;
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
final foregroundColor = colorScheme.onSurface; final foregroundColor = colorScheme.onSurface;
return RasterizedGradient( return ToolbarScrim(
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( child: Row(
children: [ children: [
if (!PlatformDetector.isTV()) if (!PlatformDetector.isTV())
@@ -852,11 +743,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
onNavigateLeft: _navigateToSidebar, onNavigateLeft: _navigateToSidebar,
onNavigateDown: _focusContentFromAppBar, onNavigateDown: _focusContentFromAppBar,
actions: [ actions: [
FocusableAction( FocusableAction(icon: Symbols.refresh_rounded, iconColor: foregroundColor, onPressed: _discover.load),
icon: Symbols.refresh_rounded,
iconColor: foregroundColor,
onPressed: _discover.load,
),
// Watch Together // Watch Together
FocusableAction( FocusableAction(
onPressed: () => onPressed: () =>
@@ -869,10 +756,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
fill: watchTogether.isInSession ? 1 : 0, fill: watchTogether.isInSession ? 1 : 0,
color: watchTogether.isInSession ? colorScheme.primary : foregroundColor, color: watchTogether.isInSession ? colorScheme.primary : foregroundColor,
), ),
onPressed: () => Navigator.push( onPressed: () =>
context, Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
),
tooltip: t.watchTogether.title, tooltip: t.watchTogether.title,
), ),
if (watchTogether.isInSession && watchTogether.participantCount > 1) if (watchTogether.isInSession && watchTogether.participantCount > 1)
@@ -900,10 +785,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (isDesktop) { if (isDesktop) {
RemoteSessionDialog.show(context); RemoteSessionDialog.show(context);
} else { } else {
Navigator.push( Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen()));
context,
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
);
} }
}, },
child: Stack( child: Stack(
@@ -961,8 +843,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
), ),
], ],
), ),
),
),
); );
} }
@@ -993,6 +873,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final bottomPadding = MediaQuery.paddingOf(context).bottom; final bottomPadding = MediaQuery.paddingOf(context).bottom;
final theme = Theme.of(context); final theme = Theme.of(context);
final continueWatchingHub = _onDeck.isEmpty ? null : _continueWatchingHub;
return Material( return Material(
color: theme.scaffoldBackgroundColor, color: theme.scaffoldBackgroundColor,
child: Stack( child: Stack(
@@ -1016,21 +897,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load), if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
if (!_isLoading && _errorMessage == null) ...[ if (!_isLoading && _errorMessage == null) ...[
// On Deck / Continue Watching // On Deck / Continue Watching
if (_onDeck.isNotEmpty) if (continueWatchingHub != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: HubSection( child: HubSection(
key: _continueWatchingHubKey, key: _continueWatchingHubKey,
hub: MediaHub( hub: continueWatchingHub,
id: 'continue_watching',
title: t.discover.continueWatching,
type: 'mixed',
identifier: '_continue_watching_',
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
more: _hasMoreContinueWatching,
items: _onDeck,
),
focusMemory: _hubFocusMemory, focusMemory: _hubFocusMemory,
icon: Symbols.play_circle_rounded, icon: hubIconFor(continueWatchingHub),
onRefresh: _discover.updateItem, onRefresh: _discover.updateItem,
onRemoveFromContinueWatching: _discover.refreshContinueWatching, onRemoveFromContinueWatching: _discover.refreshContinueWatching,
isInContinueWatching: true, isInContinueWatching: true,
@@ -1048,7 +921,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null, key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null,
hub: _hubs[i], hub: _hubs[i],
focusMemory: _hubFocusMemory, focusMemory: _hubFocusMemory,
icon: _getHubIcon(_hubs[i].title), icon: hubIconFor(_hubs[i]),
showServerName: showServerNameOnHubs || hubsSpanMultipleServers, showServerName: showServerNameOnHubs || hubsSpanMultipleServers,
onRefresh: _discover.updateItem, onRefresh: _discover.updateItem,
// Hub index is i + 1 if continue watching exists, otherwise i // Hub index is i + 1 if continue watching exists, otherwise i
@@ -1135,7 +1008,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
initialHubId: 'continue_watching', initialHubId: 'continue_watching',
focusMemory: _hubFocusMemory, focusMemory: _hubFocusMemory,
showServerName: showServerName, showServerName: showServerName,
iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), iconForHub: (hub, _) => hubIconFor(hub),
onFocusedItemChanged: _setSpotlightItem, onFocusedItemChanged: _setSpotlightItem,
onRefresh: _discover.updateItem, onRefresh: _discover.updateItem,
onRemoveFromContinueWatching: _discover.refreshContinueWatching, onRemoveFromContinueWatching: _discover.refreshContinueWatching,
@@ -1155,7 +1028,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
final hubsSpanMultipleServers = _hubsSpanMultipleServers(); final hubsSpanMultipleServers = _hubsSpanMultipleServers();
final browseHubs = _tvBrowseHubs; final browseHubs = _tvBrowseHubs;
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
return TvSpotlightScaffold( return TvSpotlightScaffold(
hubs: browseHubs, hubs: browseHubs,
@@ -1189,14 +1061,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
bottom: 0, bottom: 0,
child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers), child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers),
), ),
Builder( TvToolbarOverlay(child: _buildOverlaidAppBar()),
builder: (context) => SideNavigationBleedBuilder(
targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context),
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
builder: (context, animatedBleed, child) =>
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
),
),
if (_switchingProfile) const ProfileSwitchingOverlay(), if (_switchingProfile) const ProfileSwitchingOverlay(),
], ],
), ),
+4 -29
View File
@@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/hub_section.dart'; import '../widgets/hub_section.dart';
import '../widgets/focusable_popup_menu_button.dart'; import '../widgets/focusable_popup_menu_button.dart';
import '../widgets/settings_builder.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_browse_rail.dart';
import '../widgets/tv_spotlight_scaffold.dart'; import '../widgets/tv_spotlight_scaffold.dart';
import 'catalog_search_screen.dart'; import 'catalog_search_screen.dart';
@@ -332,25 +332,9 @@ class ExploreScreenState extends State<ExploreScreen>
Widget _buildTvToolbar(CatalogSourcesProvider sources) { Widget _buildTvToolbar(CatalogSourcesProvider sources) {
final active = sources.activeSource; final active = sources.activeSource;
final statusBarHeight = MediaQuery.paddingOf(context).top; final foregroundColor = Theme.of(context).colorScheme.onSurface;
final colorScheme = Theme.of(context).colorScheme;
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
final foregroundColor = colorScheme.onSurface;
return RasterizedGradient( return ToolbarScrim(
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( child: Row(
children: [ children: [
const Spacer(), const Spacer(),
@@ -394,13 +378,11 @@ class ExploreScreenState extends State<ExploreScreen>
), ),
], ],
), ),
),
); );
} }
Widget _buildTvContent(List<ExploreRowHub> rowHubs, CatalogSourcesProvider sources) { Widget _buildTvContent(List<ExploreRowHub> rowHubs, CatalogSourcesProvider sources) {
final tvHubs = [for (final rowHub in rowHubs) rowHub.hub]; final tvHubs = [for (final rowHub in rowHubs) rowHub.hub];
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
return TvSpotlightScaffold( return TvSpotlightScaffold(
hubs: tvHubs, hubs: tvHubs,
spotlightListenable: _spotlight, spotlightListenable: _spotlight,
@@ -448,14 +430,7 @@ class ExploreScreenState extends State<ExploreScreen>
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
), ),
), ),
Builder( TvToolbarOverlay(child: _buildTvToolbar(sources)),
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!),
),
),
], ],
), ),
); );
+10 -48
View File
@@ -4,7 +4,6 @@ import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_playlist.dart';
import '../mixins/grid_focus_node_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
@@ -15,14 +14,6 @@ import '../widgets/media_card_sliver_layout.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../widgets/skeleton_media_card.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. /// Mixin that provides common focus navigation functionality for detail screens.
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
/// ///
@@ -164,56 +155,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
/// Used by collection, smart playlist, and music artist detail screens. /// Used by collection, smart playlist, and music artist detail screens.
/// [shape] overrides the grid cell silhouette (e.g. [CardShape.square] /// [shape] overrides the grid cell silhouette (e.g. [CardShape.square]
/// for album grids); null keeps the stock poster geometry. /// 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({ Widget buildFocusableGrid({
required List<dynamic> items, required List<MediaItem> items,
required void Function(MediaItem source) onRefresh, required void Function(MediaItem source) onRefresh,
String? collectionId, String? collectionId,
VoidCallback? onListRefresh, VoidCallback? onListRefresh,
CardShape? shape, CardShape? shape,
}) { }) {
return SettingsBuilder( return buildSparseFocusableGrid(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], totalItems: items.length,
builder: (context) { itemAt: (index) => items[index],
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, onRefresh: onRefresh,
collectionId: collectionId, collectionId: collectionId,
onListRefresh: onListRefresh, onListRefresh: onListRefresh,
fullBleedImage: useFullCardLayout && position.isGrid, shape: shape,
cardShapeOverride: shape,
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
);
},
);
},
); );
} }
/// 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 /// slots; for each, [itemAt] returns the loaded item or null if not yet
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so /// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
/// the caller can kick off a page fetch containing that index. /// the caller can kick off a page fetch containing that index.
@@ -242,7 +204,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onSkeletonVisible?.call(index); onSkeletonVisible?.call(index);
return const SkeletonMediaCard(); return const SkeletonMediaCard();
} }
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item'); final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard( return FocusableMediaCard(
key: Key(item.id), key: Key(item.id),
item: item, item: item,
+7 -30
View File
@@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart'; import '../widgets/loading_indicator_box.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../focus/focusable_action_bar.dart'; import '../focus/focusable_action_bar.dart';
import '../focus/focusable_button.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart';
import '../mixins/paginated_item_loader.dart'; import '../mixins/paginated_item_loader.dart';
@@ -493,34 +492,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error; Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error;
bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading; 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 @override
void refresh() { void refresh() {
_loadMoreItems(); _loadMoreItems();
@@ -633,7 +604,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
}, },
), ),
if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null)) if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null))
_buildContinuationStatusSliver(), ContinuationStatusSliver(
error: _pageLoadError,
onRetry: _retryHubContinuation,
retryFocusNode: _continuationRetryFocusNode,
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent,
),
], ],
), ),
), ),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import 'state_messages.dart'; import 'state_messages.dart';
@@ -10,9 +11,7 @@ class SliverErrorState extends StatelessWidget {
final String? retryLabel; final String? retryLabel;
final FocusNode? actionFocusNode; final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp; final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft; final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack; final VoidCallback? onActionBack;
final bool actionAutofocus; final bool actionAutofocus;
final bool actionUseBackgroundFocus; final bool actionUseBackgroundFocus;
@@ -24,9 +23,7 @@ class SliverErrorState extends StatelessWidget {
this.retryLabel, this.retryLabel,
this.actionFocusNode, this.actionFocusNode,
this.onActionNavigateUp, this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft, this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack, this.onActionBack,
this.actionAutofocus = false, this.actionAutofocus = false,
this.actionUseBackgroundFocus = false, this.actionUseBackgroundFocus = false,
@@ -41,9 +38,7 @@ class SliverErrorState extends StatelessWidget {
retryLabel: retryLabel, retryLabel: retryLabel,
actionFocusNode: actionFocusNode, actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp, onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft, onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack, onActionBack: onActionBack,
actionAutofocus: actionAutofocus, actionAutofocus: actionAutofocus,
actionUseBackgroundFocus: actionUseBackgroundFocus, actionUseBackgroundFocus: actionUseBackgroundFocus,
@@ -61,9 +56,7 @@ class SliverEmptyState extends StatelessWidget {
final IconData? actionIcon; final IconData? actionIcon;
final FocusNode? actionFocusNode; final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp; final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft; final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack; final VoidCallback? onActionBack;
const SliverEmptyState({ const SliverEmptyState({
@@ -76,9 +69,7 @@ class SliverEmptyState extends StatelessWidget {
this.actionIcon, this.actionIcon,
this.actionFocusNode, this.actionFocusNode,
this.onActionNavigateUp, this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft, this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack, this.onActionBack,
}); });
@@ -93,14 +84,61 @@ class SliverEmptyState extends StatelessWidget {
actionIcon: actionIcon, actionIcon: actionIcon,
actionFocusNode: actionFocusNode, actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp, onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft, onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack, 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 /// A widget that handles loading, error, empty, and content states
/// Provides a consistent UI pattern across the app for data-driven screens /// Provides a consistent UI pattern across the app for data-driven screens
class ContentStateBuilder<T> extends StatelessWidget { class ContentStateBuilder<T> extends StatelessWidget {
+13 -35
View File
@@ -6,6 +6,7 @@ import '../../media/media_item.dart';
import '../../media/media_kind.dart'; import '../../media/media_kind.dart';
import '../../media/media_server_client.dart'; import '../../media/media_server_client.dart';
import '../../services/jellyfin_sequential_launcher.dart'; import '../../services/jellyfin_sequential_launcher.dart';
import '../../services/media_list_playback_launcher.dart';
import '../../services/play_queue_launcher.dart'; import '../../services/play_queue_launcher.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/error_message_utils.dart'; import '../../utils/error_message_utils.dart';
@@ -243,42 +244,19 @@ class FolderTreeViewState extends State<FolderTreeView> {
} }
} }
Future<void> _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<void> _launchFolder(MediaItem folder, {required bool shuffle}) async {
final MediaListPlaybackLauncher launcher;
if (folder.backend == MediaBackend.jellyfin) { if (folder.backend == MediaBackend.jellyfin) {
final launcher = JellyfinSequentialLauncher(context: context); launcher = JellyfinSequentialLauncher(context: context);
await launcher.launchFromFolder(folder: folder, shuffle: false); } else {
return;
}
final folderKey = folder.backendFolderKey;
if (folderKey == null) return;
final client = context.getPlexClientForServer(ServerId(widget.serverId!)); final client = context.getPlexClientForServer(ServerId(widget.serverId!));
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
await launcher.launchFromFolder(
folderKey: folderKey,
shuffle: false,
libraryId: folder.libraryId,
libraryTitle: folder.libraryTitle,
);
} }
await launcher.launchFromFolder(folder: folder, shuffle: shuffle);
Future<void> _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,
);
} }
/// Expandable rows: directory rows plus Jellyfin media containers whose /// Expandable rows: directory rows plus Jellyfin media containers whose
@@ -400,8 +378,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
serverId: widget.serverId, serverId: widget.serverId,
onExpand: isExpandable ? () => _toggleFolder(item) : null, onExpand: isExpandable ? () => _toggleFolder(item) : null,
onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null, onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null,
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null, onPlayAll: canPlayFolder ? () => _launchFolder(item, shuffle: false) : null,
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, onShuffle: canPlayFolder ? () => _launchFolder(item, shuffle: true) : null,
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
onNavigateLeft: widget.onNavigateLeft, onNavigateLeft: widget.onNavigateLeft,
-18
View File
@@ -39,9 +39,7 @@ class StateMessageWidget extends StatelessWidget {
final IconData? actionIcon; final IconData? actionIcon;
final FocusNode? actionFocusNode; final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp; final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft; final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack; final VoidCallback? onActionBack;
/// Whether the action button should request focus when it appears. /// Whether the action button should request focus when it appears.
@@ -63,9 +61,7 @@ class StateMessageWidget extends StatelessWidget {
this.actionLabel, this.actionLabel,
this.actionFocusNode, this.actionFocusNode,
this.onActionNavigateUp, this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft, this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack, this.onActionBack,
this.actionIcon, this.actionIcon,
this.actionAutofocus = false, this.actionAutofocus = false,
@@ -110,9 +106,7 @@ class StateMessageWidget extends StatelessWidget {
FocusableButton( FocusableButton(
focusNode: actionFocusNode, focusNode: actionFocusNode,
onNavigateUp: onActionNavigateUp, onNavigateUp: onActionNavigateUp,
onNavigateDown: onActionNavigateDown,
onNavigateLeft: onActionNavigateLeft, onNavigateLeft: onActionNavigateLeft,
onNavigateRight: onActionNavigateRight,
onBack: onActionBack, onBack: onActionBack,
onPressed: onAction, onPressed: onAction,
autofocus: actionAutofocus, autofocus: actionAutofocus,
@@ -155,9 +149,7 @@ class EmptyStateWidget extends StatelessWidget {
final IconData? actionIcon; final IconData? actionIcon;
final FocusNode? actionFocusNode; final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp; final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft; final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack; final VoidCallback? onActionBack;
const EmptyStateWidget({ const EmptyStateWidget({
@@ -171,9 +163,7 @@ class EmptyStateWidget extends StatelessWidget {
this.actionIcon, this.actionIcon,
this.actionFocusNode, this.actionFocusNode,
this.onActionNavigateUp, this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft, this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack, this.onActionBack,
}); });
@@ -189,9 +179,7 @@ class EmptyStateWidget extends StatelessWidget {
actionIcon: actionIcon ?? Symbols.add_rounded, actionIcon: actionIcon ?? Symbols.add_rounded,
actionFocusNode: actionFocusNode, actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp, onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft, onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack, onActionBack: onActionBack,
); );
} }
@@ -218,9 +206,7 @@ class ErrorStateWidget extends StatelessWidget {
final String? retryLabel; final String? retryLabel;
final FocusNode? actionFocusNode; final FocusNode? actionFocusNode;
final VoidCallback? onActionNavigateUp; final VoidCallback? onActionNavigateUp;
final VoidCallback? onActionNavigateDown;
final VoidCallback? onActionNavigateLeft; final VoidCallback? onActionNavigateLeft;
final VoidCallback? onActionNavigateRight;
final VoidCallback? onActionBack; final VoidCallback? onActionBack;
const ErrorStateWidget({ const ErrorStateWidget({
@@ -231,9 +217,7 @@ class ErrorStateWidget extends StatelessWidget {
this.retryLabel, this.retryLabel,
this.actionFocusNode, this.actionFocusNode,
this.onActionNavigateUp, this.onActionNavigateUp,
this.onActionNavigateDown,
this.onActionNavigateLeft, this.onActionNavigateLeft,
this.onActionNavigateRight,
this.onActionBack, this.onActionBack,
this.actionAutofocus = false, this.actionAutofocus = false,
this.actionUseBackgroundFocus = false, this.actionUseBackgroundFocus = false,
@@ -251,9 +235,7 @@ class ErrorStateWidget extends StatelessWidget {
actionIcon: Symbols.refresh_rounded, actionIcon: Symbols.refresh_rounded,
actionFocusNode: actionFocusNode, actionFocusNode: actionFocusNode,
onActionNavigateUp: onActionNavigateUp, onActionNavigateUp: onActionNavigateUp,
onActionNavigateDown: onActionNavigateDown,
onActionNavigateLeft: onActionNavigateLeft, onActionNavigateLeft: onActionNavigateLeft,
onActionNavigateRight: onActionNavigateRight,
onActionBack: onActionBack, onActionBack: onActionBack,
actionAutofocus: actionAutofocus, actionAutofocus: actionAutofocus,
actionUseBackgroundFocus: actionUseBackgroundFocus, actionUseBackgroundFocus: actionUseBackgroundFocus,
@@ -216,6 +216,20 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> 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. /// Whether [focusFirstItem] has a real content target to focus.
@protected @protected
bool get hasFocusableContent => _items.isNotEmpty; bool get hasFocusableContent => _items.isNotEmpty;
@@ -41,7 +41,6 @@ import '../../../widgets/media_card_list_layout.dart';
import '../../../widgets/bottom_sheet_page_scaffold.dart'; import '../../../widgets/bottom_sheet_page_scaffold.dart';
import '../../../widgets/overlay_sheet.dart'; import '../../../widgets/overlay_sheet.dart';
import '../../../mixins/library_tab_focus_mixin.dart'; import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../services/plex_client.dart';
import '../folder_tree_view.dart'; import '../folder_tree_view.dart';
import '../filters_bottom_sheet.dart'; import '../filters_bottom_sheet.dart';
import '../sort_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/watch_state_aware.dart';
import '../../../mixins/deletion_aware.dart'; import '../../../mixins/deletion_aware.dart';
import '../../../mixins/paginated_item_loader.dart'; import '../../../mixins/paginated_item_loader.dart';
import '../../../mixins/standard_paginated_view.dart';
import '../../../widgets/card_inflation_budget.dart'; import '../../../widgets/card_inflation_budget.dart';
import '../../../widgets/skeleton_media_card.dart'; import '../../../widgets/skeleton_media_card.dart';
import '../../../widgets/sliver_child_memo.dart'; import '../../../widgets/sliver_child_memo.dart';
@@ -104,13 +104,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
GridFocusNodeMixin, GridFocusNodeMixin,
WatchStateAware, WatchStateAware,
DeletionAware, DeletionAware,
DeletionMirrorsWatchState,
PaginatedItemLoader<MediaItem, LibraryBrowseTab>, PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
PaginatedItemUpdatable<LibraryBrowseTab>,
SkeletonUpgradeScheduler { SkeletonUpgradeScheduler {
String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey); String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey);
@override // DeletionMirrorsWatchState points the deletion filters at these three: the
String? get deletionServerId => widget.library.serverId; // grid shows the same loaded items for both event families.
@override @override
String? get watchStateServerId => widget.library.serverId; String? get watchStateServerId => widget.library.serverId;
@@ -130,22 +131,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return keys; return keys;
} }
@override
Set<String>? get deletionIds => loadedItems.values.map((e) => e.id).toSet();
@override
Set<String>? get deletionGlobalKeys {
if (loadedItems.isEmpty) return <String>{};
final keys = <String>{};
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 @override
void onWatchStateChanged(WatchStateEvent event) { void onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.progressUpdate || if (event.changeType == WatchStateChangeType.progressUpdate ||
@@ -213,16 +198,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
@override @override
int get itemCount => totalSize; int get itemCount => 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) // Browse-specific state (not in base class)
List<MediaFilter> _filters = []; List<MediaFilter> _filters = [];
List<MediaSort> _sortOptions = []; List<MediaSort> _sortOptions = [];
@@ -1061,8 +1036,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
Future<List<MediaFilterValue>> _loadFilterValues(MediaFilter filter) async { Future<List<MediaFilterValue>> _loadFilterValues(MediaFilter filter) async {
if (!mounted) return const []; if (!mounted) return const [];
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId)); final client = context.tryGetPlexClientForServer(serverIdOrNull(widget.library.serverId));
if (client is PlexClient) return client.getFilterValues(filter.key); if (client != null) return client.getFilterValues(filter.key);
// Jellyfin's canonical filter values come from the cached `/Items/Filters` // Jellyfin's canonical filter values come from the cached `/Items/Filters`
// payload. If that payload missed a category, there is no neutral endpoint // payload. If that payload missed a category, there is no neutral endpoint
@@ -1,25 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/library_query.dart'; import '../../../media/library_query.dart';
import '../../../media/media_item.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/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.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 '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart'; import 'base_library_tab.dart';
import 'paginated_card_grid_tab.dart';
/// Collections tab for library screen. /// Collections tab for library screen.
/// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root. /// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root.
@@ -39,23 +26,13 @@ class LibraryCollectionsTab extends BaseLibraryTab<MediaItem> {
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState(); State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
} }
class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab> class _LibraryCollectionsTabState extends PaginatedCardGridTabState<MediaItem, LibraryCollectionsTab> {
with @override
LibraryTabFocusMixin<LibraryCollectionsTab>, int get pageSize => 36;
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
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<MediaItem> _cardMemo = SliverChildMemo<MediaItem>();
@override @override
String get focusNodeDebugLabel => 'collections_first_item'; String get focusNodeDebugLabel => 'collections_first_item';
@override
int get itemCount => totalSize;
@override @override
IconData get emptyIcon => Symbols.collections_rounded; IconData get emptyIcon => Symbols.collections_rounded;
@@ -69,7 +46,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream; Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
@override @override
Future<List<MediaItem>> loadData() async => const []; String idOf(MediaItem item) => item.id;
@override @override
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) { Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
@@ -78,66 +55,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
} }
@override @override
Future<void> loadItems() async { bool get usesSquareCards {
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<MediaItem> 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 {
final loaded = loadedItems.values; final loaded = loadedItems.values;
return loaded.isNotEmpty && loaded.every(_isMusicCollection); return loaded.isNotEmpty && loaded.every(_isMusicCollection);
} }
@@ -146,94 +64,4 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
// safe fallback. Jellyfin BoxSets are server-wide and must opt in per item. // safe fallback. Jellyfin BoxSets are server-wide and must opt in per item.
bool _isMusicCollection(MediaItem item) => bool _isMusicCollection(MediaItem item) =>
item.kind.isMusic || (item is PlexMediaItem && widget.library.kind.isMusic); 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();
}
} }
@@ -1,27 +1,13 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/library_query.dart'; import '../../../media/library_query.dart';
import '../../../media/media_item.dart';
import '../../../media/media_kind.dart'; import '../../../media/media_kind.dart';
import '../../../media/media_playlist.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/library_refresh_notifier.dart';
import '../../../utils/media_server_http_client.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 '../../../i18n/strings.g.dart';
import '../../main_screen.dart';
import 'base_library_tab.dart'; import 'base_library_tab.dart';
import 'paginated_card_grid_tab.dart';
/// Playlists tab for library screen /// Playlists tab for library screen
/// Shows playlists that contain items from the current library /// Shows playlists that contain items from the current library
@@ -41,23 +27,13 @@ class LibraryPlaylistsTab extends BaseLibraryTab<MediaPlaylist> {
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState(); State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
} }
class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, LibraryPlaylistsTab> class _LibraryPlaylistsTabState extends PaginatedCardGridTabState<MediaPlaylist, LibraryPlaylistsTab> {
with @override
LibraryTabFocusMixin<LibraryPlaylistsTab>, int get pageSize => 200;
PaginatedItemLoader<MediaPlaylist, LibraryPlaylistsTab>,
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<MediaPlaylist> _cardMemo = SliverChildMemo<MediaPlaylist>();
@override @override
String get focusNodeDebugLabel => 'playlists_first_item'; String get focusNodeDebugLabel => 'playlists_first_item';
@override
int get itemCount => totalSize;
@override @override
IconData get emptyIcon => Symbols.playlist_play_rounded; IconData get emptyIcon => Symbols.playlist_play_rounded;
@@ -71,7 +47,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream; Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
@override @override
Future<List<MediaPlaylist>> loadData() async => const []; String idOf(MediaPlaylist playlist) => playlist.id;
@override @override
Future<LibraryPage<MediaPlaylist>> fetchPage(int start, int size, AbortController? abort) { Future<LibraryPage<MediaPlaylist>> fetchPage(int start, int size, AbortController? abort) {
@@ -84,154 +60,5 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
} }
@override @override
Future<void> loadItems() async { bool get usesSquareCards => widget.library.kind.isMusic;
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<MediaPlaylist> 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();
}
} }
@@ -15,7 +15,8 @@ import '../../../mixins/item_updatable.dart';
import '../../../mixins/watch_state_aware.dart'; import '../../../mixins/watch_state_aware.dart';
import '../../../services/settings_service.dart'; import '../../../services/settings_service.dart';
import '../../../utils/deletion_notifier.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/platform_detector.dart';
import '../../../utils/provider_extensions.dart'; import '../../../utils/provider_extensions.dart';
import '../../../utils/watch_state_notifier.dart'; import '../../../utils/watch_state_notifier.dart';
@@ -46,7 +47,7 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
} }
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab> class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
with ItemUpdatable, WatchStateAware, DeletionAware { with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState {
/// GlobalKeys for each hub section to enable vertical navigation /// GlobalKeys for each hub section to enable vertical navigation
final List<GlobalKey<HubSectionState>> _hubKeys = []; final List<GlobalKey<HubSectionState>> _hubKeys = [];
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>(); final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
@@ -72,45 +73,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
@override @override
String? get watchStateServerId => widget.library.serverId; String? get watchStateServerId => widget.library.serverId;
@override /// Every item on screen, across all hubs.
String? get deletionServerId => widget.library.serverId; Iterable<MediaItem> get _visibleItems => items.expand((hub) => hub.items);
// Deletion filtering needs the same id sets as watch state: each visible // Deletion mirrors these via DeletionMirrorsWatchState: each visible item
// item plus its parents, so deleting a season/show also matches the // plus its parents, so deleting a season/show also matches the episodes it
// episodes it contains here. // contains here.
@override @override
Set<String>? get deletionIds => watchedIds; Set<String>? get watchedIds => hierarchicalEventIds(_visibleItems);
@override @override
Set<String>? get deletionGlobalKeys => watchedGlobalKeys; Set<String>? get watchedGlobalKeys =>
hierarchicalEventGlobalKeys(_visibleItems, fallbackServerId: widget.library.serverId);
@override
Set<String>? get watchedIds {
final keys = <String>{};
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<String>? get watchedGlobalKeys {
final keys = <String>{};
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;
}
@override @override
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
@@ -316,7 +290,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
key: index < _hubKeys.length ? _hubKeys[index] : null, key: index < _hubKeys.length ? _hubKeys[index] : null,
hub: hub, hub: hub,
focusMemory: _hubFocusMemory, focusMemory: _hubFocusMemory,
icon: _getHubIcon(hub), icon: hubIconFor(hub),
isInContinueWatching: isContinueWatching, isInContinueWatching: isContinueWatching,
usesContinueWatchingAction: usesContinueWatchingAction, usesContinueWatchingAction: usesContinueWatchingAction,
onRefresh: updateItem, onRefresh: updateItem,
@@ -351,7 +325,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
key: _tvBrowseRailKey, key: _tvBrowseRailKey,
hubs: tvHubs, hubs: tvHubs,
focusMemory: _hubFocusMemory, focusMemory: _hubFocusMemory,
iconForHub: (hub, _) => _getHubIcon(hub), iconForHub: (hub, _) => hubIconFor(hub),
onFocusedItemChanged: _setSpotlightItem, onFocusedItemChanged: _setSpotlightItem,
onRefresh: updateItem, onRefresh: updateItem,
onRemoveFromContinueWatching: _refreshContinueWatching, onRemoveFromContinueWatching: _refreshContinueWatching,
@@ -371,24 +345,4 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
// Reload all data to refresh the continue watching section // Reload all data to refresh the continue watching section
loadItems(); loadItems();
} }
IconData _getHubIcon(MediaHub hub) {
final title = hub.title.toLowerCase();
if (title.contains('continue watching') || title.contains('on deck')) {
return Symbols.play_circle_rounded;
} else if (title.contains('recently') || title.contains('new')) {
return Symbols.fiber_new_rounded;
} else if (title.contains('popular') || title.contains('trending')) {
return Symbols.trending_up_rounded;
} else if (title.contains('top') || title.contains('rated')) {
return Symbols.star_rounded;
} else if (title.contains('recommended')) {
return Symbols.thumb_up_rounded;
} else if (title.contains('unwatched')) {
return Symbols.visibility_off_rounded;
} else if (title.contains('genre')) {
return Symbols.category_rounded;
}
return Symbols.movie_rounded;
}
} }
@@ -0,0 +1,177 @@
import 'package:flutter/material.dart';
import '../../../focus/input_mode_tracker.dart';
import '../../../media/media_item.dart';
import '../../../mixins/library_tab_focus_mixin.dart';
import '../../../mixins/paginated_item_loader.dart';
import '../../../mixins/standard_paginated_view.dart';
import '../../../services/settings_service.dart';
import '../../../utils/error_message_utils.dart';
import '../../../utils/layout_constants.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 '../../main_screen.dart';
import 'base_library_tab.dart';
/// Library tabs whose whole body is one paginated grid of media cards.
///
/// Owns the grid: sparse page loading, the card widget memo, the inflation
/// budget and skeleton-upgrade handshake, and first-item/sidebar focus wiring.
/// Subclasses supply only what differs per tab — [pageSize], [fetchPage],
/// [usesSquareCards], [idOf], and the empty/error chrome from
/// [BaseLibraryTabState].
abstract class PaginatedCardGridTabState<T extends Object, W extends BaseLibraryTab<T>>
extends BaseLibraryTabState<T, W>
with
LibraryTabFocusMixin<W>,
PaginatedItemLoader<T, W>,
StandardPaginatedView<T, W>,
SkeletonUpgradeScheduler<W> {
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<T> _cardMemo = SliverChildMemo<T>();
/// 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<List<T>> loadData() async => <T>[];
@override
Future<void> loadItems() {
return loadStandardPaginatedItems(
pageSize: pageSize,
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
onLoaded: (_, _) => markItemsLoaded(),
);
}
@override
Widget buildContent(List<T> 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();
}
}
@@ -50,6 +50,7 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
required LiveTvChannel? channel, required LiveTvChannel? channel,
required String? posterThumb, required String? posterThumb,
required String? posterServerId, required String? posterServerId,
ValueChanged<bool>? onRecordingStateChanged,
}) { }) {
final effectiveContext = sheetContext ?? context; final effectiveContext = sheetContext ?? context;
final multiServer = effectiveContext.read<MultiServerProvider>(); final multiServer = effectiveContext.read<MultiServerProvider>();
@@ -74,6 +75,7 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
posterUrl: posterUrl, posterUrl: posterUrl,
onTuneChannel: channel != null ? () => tuneChannel(channel) : null, onTuneChannel: channel != null ? () => tuneChannel(channel) : null,
client: client, client: client,
onRecordingStateChanged: onRecordingStateChanged,
); );
} }
} }
+78 -104
View File
@@ -20,6 +20,7 @@ import '../../widgets/settings_builder.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/error_message_utils.dart'; import '../../utils/error_message_utils.dart';
import '../../utils/desktop_window_padding.dart'; import '../../utils/desktop_window_padding.dart';
import '../../utils/live_tv_matching.dart';
import '../../utils/platform_detector.dart'; import '../../utils/platform_detector.dart';
import '../../utils/serial_future_queue.dart'; import '../../utils/serial_future_queue.dart';
import '../../utils/snackbar_helper.dart'; import '../../utils/snackbar_helper.dart';
@@ -31,6 +32,8 @@ import 'tabs/guide_tab.dart';
import 'tabs/recordings_tab.dart'; import 'tabs/recordings_tab.dart';
import 'tabs/whats_on_tab.dart'; import 'tabs/whats_on_tab.dart';
typedef _FavoriteScope = ({String source, String storeKey, FavoriteChannelPersistenceMode mode});
enum LiveTvTab { guide, whatsOn, recordings } enum LiveTvTab { guide, whatsOn, recordings }
class LiveTvScreen extends StatefulWidget { class LiveTvScreen extends StatefulWidget {
@@ -66,13 +69,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
Set<String> _favoriteKeys = {}; Set<String> _favoriteKeys = {};
List<FavoriteChannel> _favoriteChannels = []; List<FavoriteChannel> _favoriteChannels = [];
/// Source URI per Live TV server/DVR, built from machineIdentifier + EPG provider identifier. /// Favorite source URI, store key and persistence mode per Live TV server/DVR.
final Map<String, String> _favoriteSourceByLiveServer = {}; /// The source is built from machineIdentifier + EPG provider identifier.
final Map<String, String> _favoriteSourceByChannel = {}; final Map<String, _FavoriteScope> _favoriteScopeByLiveServer = {};
final Map<String, String> _favoriteStoreByLiveServer = {}; final Map<String, String> _liveServerKeyByChannel = {};
final Map<String, String> _favoriteStoreByChannel = {};
/// 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<String, String> _favoriteStoreBySource = {}; final Map<String, String> _favoriteStoreBySource = {};
final Map<String, FavoriteChannelPersistenceMode> _favoriteModeByStore = {};
Future<void>? _channelsLoadFuture; Future<void>? _channelsLoadFuture;
int _favoritesLoadGeneration = 0; int _favoritesLoadGeneration = 0;
Future<void>? _favoritesLoadFuture; Future<void>? _favoritesLoadFuture;
@@ -90,8 +95,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
String _liveServerScopeKey(LiveTvServerInfo serverInfo) => '${serverInfo.serverId}\u0000${serverInfo.dvrKey}'; 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) { 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); String _favoriteKeyForChannel(LiveTvChannel channel) => favoriteChannelKey(_sourceForChannel(channel), channel.key);
@@ -166,61 +176,56 @@ class _LiveTvScreenState extends State<LiveTvScreen>
await _recordingsTabKey.currentState?.reload(); await _recordingsTabKey.currentState?.reload();
return; return;
} }
await _serverReloadGuide(); await _broadcastToDvrs(
actionLabel: 'Reload guide',
successMessage: t.liveTv.guideReloadRequested,
action: (dvr, serverInfo) => dvr.reloadGuide(serverInfo.dvrKey),
);
await _loadChannels(); await _loadChannels();
} }
Future<void> _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<bool> _broadcastToDvrs({
required String actionLabel,
required String successMessage,
required Future<void> Function(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) action,
}) async {
final multiServer = context.read<MultiServerProvider>(); final multiServer = context.read<MultiServerProvider>();
final futures = <Future<void>>[]; Future<void> runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async {
for (final serverInfo in multiServer.liveTvServers) { try {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); await action(dvr, serverInfo);
if (client == null || client.liveTvDvr == null) continue; } catch (e) {
futures.add(_reloadGuideSafe(client, serverInfo.dvrKey)); appLogger.d('$actionLabel failed for DVR ${serverInfo.dvrKey}: $e');
} }
if (futures.isEmpty) return;
await Future.wait(futures);
if (!mounted) return;
showSnackBar(context, t.liveTv.guideReloadRequested);
} }
Future<void> _reloadGuideSafe(MediaServerClient client, String dvrId) async { final futures = <Future<void>>[];
try { for (final serverInfo in multiServer.liveTvServers) {
final dvr = client.liveTvDvr; final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr;
if (dvr == null) return; if (dvr == null) continue;
await dvr.reloadGuide(dvrId); futures.add(runSafely(dvr, serverInfo));
} 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 (futures.isEmpty) return false;
await Future.wait(futures);
if (!mounted) return false;
showSnackBar(context, successMessage);
return true;
} }
Future<void> _processRecordingRules() async { Future<void> _processRecordingRules() async {
final multiServer = context.read<MultiServerProvider>(); final reached = await _broadcastToDvrs(
final futures = <Future<void>>[]; actionLabel: 'processRecordingRules',
for (final serverInfo in multiServer.liveTvServers) { successMessage: t.liveTv.rulesProcessRequested,
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); action: (dvr, _) => dvr.processRecordingRules(),
if (client == null || client.liveTvDvr == null) continue; );
futures.add(_processRulesSafe(client)); if (!reached) return;
}
if (futures.isEmpty) return;
await Future.wait(futures);
if (!mounted) return;
showSnackBar(context, t.liveTv.rulesProcessRequested);
await _recordingsTabKey.currentState?.reload(); await _recordingsTabKey.currentState?.reload();
} }
Future<void> _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. /// Recompute visible tabs from the current MultiServerProvider state.
/// Re-inits the tab controller when the visible set changes (matches the /// Re-inits the tab controller when the visible set changes (matches the
/// libraries-screen pattern at libraries_screen.dart:365). /// libraries-screen pattern at libraries_screen.dart:365).
@@ -265,15 +270,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
String? _sourceTitleForServerInfo(LiveTvServerInfo serverInfo) { String? _sourceTitleForServerInfo(LiveTvServerInfo serverInfo) {
for (final dvr in serverInfo.dvrs) { for (final dvr in serverInfo.dvrs) {
if (dvr.key == serverInfo.dvrKey) { 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); return liveTvNonEmpty(serverInfo.lineup);
}
String? _nonEmpty(String? value) {
final trimmed = value?.trim();
return trimmed == null || trimmed.isEmpty ? null : trimmed;
} }
Future<void> _loadChannels() { Future<void> _loadChannels() {
@@ -308,12 +308,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final allChannels = <LiveTvChannel>[]; final allChannels = <LiveTvChannel>[];
final seenChannels = <String>{}; final seenChannels = <String>{};
final favoriteSourceByLiveServer = <String, String>{}; final favoriteScopeByLiveServer = <String, _FavoriteScope>{};
final favoriteSourceByChannel = <String, String>{}; final liveServerKeyByChannel = <String, String>{};
final favoriteStoreByLiveServer = <String, String>{};
final favoriteStoreByChannel = <String, String>{};
final favoriteStoreBySource = <String, String>{}; final favoriteStoreBySource = <String, String>{};
final favoriteModeByStore = <String, FavoriteChannelPersistenceMode>{};
appLogger.d( appLogger.d(
'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}', 'Live TV DVRs: ${liveTvServers.map((s) => '${s.serverId}/${s.dvrKey} lineup=${s.lineup}').join(', ')}',
@@ -338,10 +335,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final sourceTitle = _sourceTitleForServerInfo(serverInfo); final sourceTitle = _sourceTitleForServerInfo(serverInfo);
final storeKey = liveTv.favoriteStoreKey; final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo); final liveServerKey = _liveServerScopeKey(serverInfo);
favoriteSourceByLiveServer[liveServerKey] = source; favoriteScopeByLiveServer[liveServerKey] = (
favoriteStoreByLiveServer[liveServerKey] = storeKey; source: source,
storeKey: storeKey,
mode: liveTv.favoritePersistenceMode,
);
favoriteStoreBySource[source] = storeKey; favoriteStoreBySource[source] = storeKey;
favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode;
final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup); final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup);
// Plex's DVR exposes a separate enabled-channel mapping; Jellyfin // Plex's DVR exposes a separate enabled-channel mapping; Jellyfin
@@ -360,9 +359,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
); );
final dedupKey = liveTvChannelScopeKey(scopedChannel); final dedupKey = liveTvChannelScopeKey(scopedChannel);
if (seenChannels.add(dedupKey)) { if (seenChannels.add(dedupKey)) {
final scopeKey = liveTvChannelScopeKey(scopedChannel); liveServerKeyByChannel[dedupKey] = liveServerKey;
favoriteSourceByChannel[scopeKey] = source;
favoriteStoreByChannel[scopeKey] = storeKey;
allChannels.add(scopedChannel); allChannels.add(scopedChannel);
} }
} }
@@ -383,24 +380,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
setState(() { setState(() {
_channels = allChannels; _channels = allChannels;
_favoriteSourceByLiveServer _favoriteScopeByLiveServer
..clear() ..clear()
..addAll(favoriteSourceByLiveServer); ..addAll(favoriteScopeByLiveServer);
_favoriteSourceByChannel _liveServerKeyByChannel
..clear() ..clear()
..addAll(favoriteSourceByChannel); ..addAll(liveServerKeyByChannel);
_favoriteStoreByLiveServer
..clear()
..addAll(favoriteStoreByLiveServer);
_favoriteStoreByChannel
..clear()
..addAll(favoriteStoreByChannel);
_favoriteStoreBySource _favoriteStoreBySource
..clear() ..clear()
..addAll(favoriteStoreBySource); ..addAll(favoriteStoreBySource);
_favoriteModeByStore
..clear()
..addAll(favoriteModeByStore);
_isLoading = false; _isLoading = false;
}); });
@@ -438,10 +426,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_favoritesLoaded = false; _favoritesLoaded = false;
_favoritesWritable = false; _favoritesWritable = false;
final previousStoreBySource = Map<String, String>.of(_favoriteStoreBySource); final previousStoreBySource = Map<String, String>.of(_favoriteStoreBySource);
final sourceByLiveServer = Map<String, String>.of(_favoriteSourceByLiveServer); final scopeByLiveServer = Map<String, _FavoriteScope>.of(_favoriteScopeByLiveServer);
final storeByLiveServer = Map<String, String>.of(_favoriteStoreByLiveServer);
final storeBySource = Map<String, String>.of(_favoriteStoreBySource); final storeBySource = Map<String, String>.of(_favoriteStoreBySource);
final modeByStore = Map<String, FavoriteChannelPersistenceMode>.of(_favoriteModeByStore);
final merged = <FavoriteChannel>[]; final merged = <FavoriteChannel>[];
final successfulStores = <String>{}; final successfulStores = <String>{};
final failedStores = <String>{}; final failedStores = <String>{};
@@ -453,12 +439,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
final liveTv = client.liveTv; final liveTv = client.liveTv;
final storeKey = liveTv.favoriteStoreKey; final storeKey = liveTv.favoriteStoreKey;
final liveServerKey = _liveServerScopeKey(serverInfo); final liveServerKey = _liveServerScopeKey(serverInfo);
storeByLiveServer[liveServerKey] = storeKey;
modeByStore[storeKey] = liveTv.favoritePersistenceMode;
try { try {
final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup); final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup);
sourceByLiveServer[liveServerKey] = source; scopeByLiveServer[liveServerKey] = (source: source, storeKey: storeKey, mode: liveTv.favoritePersistenceMode);
storeBySource[source] = storeKey; storeBySource[source] = storeKey;
if (successfulStores.contains(storeKey)) continue; if (successfulStores.contains(storeKey)) continue;
@@ -487,18 +471,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
if (!mounted || loadGeneration != _favoritesLoadGeneration) return; if (!mounted || loadGeneration != _favoritesLoadGeneration) return;
setState(() { setState(() {
_favoriteSourceByLiveServer _favoriteScopeByLiveServer
..clear() ..clear()
..addAll(sourceByLiveServer); ..addAll(scopeByLiveServer);
_favoriteStoreByLiveServer
..clear()
..addAll(storeByLiveServer);
_favoriteStoreBySource _favoriteStoreBySource
..clear() ..clear()
..addAll(storeBySource); ..addAll(storeBySource);
_favoriteModeByStore
..clear()
..addAll(modeByStore);
_favoriteChannels = merged; _favoriteChannels = merged;
_refreshFavoriteKeys(); _refreshFavoriteKeys();
_favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty; _favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty;
@@ -520,8 +498,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
_enqueueFavoriteMutation(() { _enqueueFavoriteMutation(() {
final source = _sourceForChannel(channel); final source = _sourceForChannel(channel);
final favoriteKey = favoriteChannelKey(source, channel.key); final favoriteKey = favoriteChannelKey(source, channel.key);
final scopeKey = liveTvChannelScopeKey(channel); final storeKey = channel.favoriteStoreKey ?? _favoriteScopeForChannel(channel)?.storeKey;
final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey];
if (storeKey != null) _favoriteStoreBySource[source] = storeKey; if (storeKey != null) _favoriteStoreBySource[source] = storeKey;
setState(() { setState(() {
@@ -601,16 +578,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
for (final serverInfo in multiServer.liveTvServers) { for (final serverInfo in multiServer.liveTvServers) {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
if (client == null) continue; if (client == null) continue;
final liveServerKey = _liveServerScopeKey(serverInfo); final scope = _favoriteScopeByLiveServer[_liveServerScopeKey(serverInfo)];
final storeKey = _favoriteStoreByLiveServer[liveServerKey]; if (scope == null || !writtenStores.add(scope.storeKey)) continue;
if (storeKey == null || !writtenStores.add(storeKey)) continue; final storeChannels = byStore[scope.storeKey] ?? const <FavoriteChannel>[];
final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode; final channels = switch (scope.mode) {
final source = _favoriteSourceByLiveServer[liveServerKey]; FavoriteChannelPersistenceMode.sharedFullList => storeChannels,
if (source == null) continue;
final channels = switch (mode) {
FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const <FavoriteChannel>[],
FavoriteChannelPersistenceMode.serverSlice => FavoriteChannelPersistenceMode.serverSlice =>
(byStore[storeKey] ?? const <FavoriteChannel>[]).where((favorite) => favorite.source == source).toList(), storeChannels.where((favorite) => favorite.source == scope.source).toList(),
}; };
writes.add(client.liveTv.setFavoriteChannels(channels)); writes.add(client.liveTv.setFavoriteChannels(channels));
} }
+57 -101
View File
@@ -419,9 +419,23 @@ class _SettingRow extends StatelessWidget {
return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged); return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
} }
if (type == 'int') { 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; 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 { class _BoolSettingRow extends StatelessWidget {
final SubscriptionSetting setting; final SubscriptionSetting setting;
final Object? currentValue; final Object? currentValue;
@@ -450,7 +486,6 @@ class _BoolSettingRow extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
final value = _coerceBool(currentValue); final value = _coerceBool(currentValue);
void toggle() => onChanged(!value); void toggle() => onChanged(!value);
return FocusableWrapper( return FocusableWrapper(
@@ -466,17 +501,7 @@ class _BoolSettingRow extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: _SettingLabel(label: setting.label ?? setting.id, summary: setting.summary),
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),
),
],
),
), ),
IgnorePointer( IgnorePointer(
child: Switch(value: value, onChanged: (v) => onChanged(v)), child: Switch(value: value, onChanged: (v) => onChanged(v)),
@@ -549,14 +574,7 @@ class _PickerRow extends StatelessWidget {
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
child: Column( child: _SettingLabel(label: label, summary: summary),
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)),
],
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Text(value, style: theme.textTheme.bodyMedium), 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 SubscriptionSetting setting;
final Object? currentValue; final Object? currentValue;
final bool autofocus; final bool autofocus;
final TextInputType? keyboardType;
final List<TextInputFormatter>? inputFormatters;
final Object? Function(String) parseValue;
final void Function(Object?) onChanged; final void Function(Object?) onChanged;
const _IntSettingRow({ const _TextFieldSettingRow({
required this.setting, required this.setting,
required this.currentValue, required this.currentValue,
required this.autofocus, required this.autofocus,
required this.parseValue,
required this.onChanged, required this.onChanged,
this.keyboardType,
this.inputFormatters,
}); });
@override @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; late final TextEditingController _controller;
@override @override
@@ -602,7 +628,7 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
} }
@override @override
void didUpdateWidget(covariant _IntSettingRow oldWidget) { void didUpdateWidget(covariant _TextFieldSettingRow oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
final next = widget.currentValue?.toString() ?? ''; final next = widget.currentValue?.toString() ?? '';
if (next != _controller.text) _controller.text = next; if (next != _controller.text) _controller.text = next;
@@ -610,91 +636,21 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column( child: Column(
crossAxisAlignment: .start, crossAxisAlignment: .start,
children: [ children: [
Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium), _SettingLabel(label: widget.setting.label ?? widget.setting.id, summary: widget.setting.summary),
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), const SizedBox(height: 4),
FocusableTextField( FocusableTextField(
controller: _controller, controller: _controller,
autofocus: widget.autofocus, autofocus: widget.autofocus,
keyboardType: TextInputType.number, keyboardType: widget.keyboardType,
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))], inputFormatters: widget.inputFormatters,
onNavigateUp: () => FocusScope.of(context).previousFocus(), onNavigateUp: () => FocusScope.of(context).previousFocus(),
onNavigateDown: () => FocusScope.of(context).nextFocus(), onNavigateDown: () => FocusScope.of(context).nextFocus(),
onChanged: (text) { onChanged: (text) => widget.onChanged(widget.parseValue(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),
), ),
], ],
), ),

Some files were not shown because too many files have changed in this diff Show More