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:
@@ -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
@@ -25,10 +25,10 @@ on:
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }}
|
||||
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }}
|
||||
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github
|
||||
DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
# Shared by every release build command; SENTRY_DIST stays per-platform.
|
||||
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
|
||||
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
|
||||
LINUX_APT_PACKAGES: >
|
||||
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev
|
||||
@@ -76,7 +76,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
EOF
|
||||
|
||||
- name: Build APKs
|
||||
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
|
||||
run: flutter build apk --release --split-per-abi ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-android-apk --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -163,7 +163,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build iOS (no codesign)
|
||||
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios
|
||||
run: flutter build ios --release --no-codesign ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-ios --split-debug-info=debug-info/ios
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -231,7 +231,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
@@ -256,7 +256,7 @@ jobs:
|
||||
run: flutter pub get --enforce-lockfile --no-example
|
||||
|
||||
- name: Build macOS
|
||||
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos
|
||||
run: flutter build macos --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-macos --split-debug-info=debug-info/macos
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -436,45 +436,14 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
|
||||
- name: Set up Flutter 3.44.0 from its immutable commit
|
||||
- name: Set up Flutter from its pinned commit
|
||||
if: matrix.flutter_setup == 'git'
|
||||
# Flutter publishes no windows-arm64 SDK archive, so fetch the release tag for
|
||||
# version discovery and verify it resolves to the pinned commit. That tag uses
|
||||
# engine revision 4c525dac, which install-patched-engine.ps1 asserts before swapping.
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0
|
||||
git -C $root checkout --detach refs/tags/3.44.0
|
||||
$actualCommit = git -C $root rev-parse HEAD
|
||||
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) {
|
||||
throw "Flutter 3.44.0 resolved to $actualCommit, expected $expectedCommit"
|
||||
}
|
||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
& "$root\bin\flutter.bat" --version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to bootstrap the Flutter SDK"
|
||||
}
|
||||
$versionOutput = & "$root\bin\flutter.bat" --version --machine
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to resolve the Flutter SDK version"
|
||||
}
|
||||
$versionJson = $versionOutput -join "`n"
|
||||
if ([string]::IsNullOrWhiteSpace($versionJson)) {
|
||||
throw "Flutter did not report machine-readable version JSON"
|
||||
}
|
||||
$version = $versionJson | ConvertFrom-Json
|
||||
if ($version.frameworkVersion -ne "3.44.0") {
|
||||
throw "Flutter reported version $($version.frameworkVersion), expected 3.44.0"
|
||||
}
|
||||
uses: ./.github/actions/setup-flutter-git
|
||||
|
||||
- name: Cache Pub dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
@@ -495,7 +464,7 @@ jobs:
|
||||
|
||||
- name: Build Windows ${{ matrix.arch }}
|
||||
shell: pwsh
|
||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
|
||||
run: flutter build windows --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
|
||||
|
||||
- name: Upload symbols to bugs.plezy.app
|
||||
if: github.repository == 'edde746/plezy'
|
||||
@@ -528,7 +497,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
@@ -641,7 +610,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: ${{ matrix.flutter_channel }}
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||
pub-cache: false
|
||||
@@ -698,7 +667,7 @@ jobs:
|
||||
|
||||
- name: Build Linux ${{ matrix.arch }}
|
||||
shell: bash
|
||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
|
||||
run: flutter build linux --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
|
||||
env:
|
||||
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig
|
||||
|
||||
|
||||
+14
-62
@@ -10,6 +10,10 @@ on:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||
FLUTTER_VERSION: "3.44.0"
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Code Analysis
|
||||
@@ -26,7 +30,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
@@ -53,31 +57,7 @@ jobs:
|
||||
run: python3 scripts/clean_translations.py --check --strict
|
||||
|
||||
- name: Verify workflow and script guards
|
||||
run: |
|
||||
python3 scripts/check_build_workflow.py
|
||||
python3 scripts/test_check_build_workflow.py
|
||||
python3 scripts/check_apple_spm_locks.py
|
||||
python3 scripts/test_check_apple_spm_locks.py
|
||||
python3 scripts/verify_runtime_inputs.py
|
||||
python3 scripts/test_verify_runtime_inputs.py
|
||||
python3 scripts/check_workflow_security.py
|
||||
python3 scripts/test_check_workflow_security.py
|
||||
python3 scripts/check_workflow_action_pins.py
|
||||
python3 scripts/test_check_workflow_action_pins.py
|
||||
python3 scripts/check_container_image_pins.py
|
||||
python3 scripts/test_check_container_image_pins.py
|
||||
python3 scripts/test_fetch_tvos_engine.py
|
||||
python3 scripts/test_check_codegen.py
|
||||
python3 scripts/test_generate_relay_protocol.py
|
||||
python3 scripts/test_format_native.py
|
||||
python3 scripts/test_run_maestro.py
|
||||
python3 scripts/test_maestro_flow_contracts.py
|
||||
python3 scripts/test_maestro_jellyfin_proxy.py
|
||||
python3 scripts/test_maestro_real_jellyfin.py
|
||||
python3 scripts/check_update_packages_workflow.py
|
||||
python3 scripts/test_pubspec_version.py
|
||||
python3 scripts/test_clean_translations.py
|
||||
python3 scripts/test_check_icon_consistency.py
|
||||
run: bash scripts/ci_guard_checks.sh
|
||||
|
||||
- name: Verify formatting
|
||||
run: |
|
||||
@@ -131,7 +111,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
@@ -188,7 +168,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
@@ -282,7 +262,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
@@ -356,7 +336,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
@@ -479,41 +459,13 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
- name: Setup Flutter 3.44.0 from its immutable commit
|
||||
- name: Set up Flutter from its pinned commit
|
||||
if: matrix.flutter_setup == 'git'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$root = "$env:RUNNER_TEMP\flutter"
|
||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
git init $root
|
||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
||||
git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0
|
||||
git -C $root checkout --detach refs/tags/3.44.0
|
||||
$actualCommit = git -C $root rev-parse HEAD
|
||||
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) {
|
||||
throw "Flutter 3.44.0 resolved to $actualCommit, expected $expectedCommit"
|
||||
}
|
||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
||||
& "$root\bin\flutter.bat" --version
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to bootstrap the Flutter SDK"
|
||||
}
|
||||
$versionOutput = & "$root\bin\flutter.bat" --version --machine
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "Unable to resolve the Flutter SDK version"
|
||||
}
|
||||
$versionJson = $versionOutput -join "`n"
|
||||
if ([string]::IsNullOrWhiteSpace($versionJson)) {
|
||||
throw "Flutter did not report machine-readable version JSON"
|
||||
}
|
||||
$version = $versionJson | ConvertFrom-Json
|
||||
if ($version.frameworkVersion -ne "3.44.0") {
|
||||
throw "Flutter reported version $($version.frameworkVersion), expected 3.44.0"
|
||||
}
|
||||
uses: ./.github/actions/setup-flutter-git
|
||||
|
||||
- name: Install locked Dart dependencies
|
||||
shell: pwsh
|
||||
@@ -567,7 +519,7 @@ jobs:
|
||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||
with:
|
||||
channel: "stable"
|
||||
flutter-version: "3.44.0"
|
||||
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||
cache: true
|
||||
pub-cache: false
|
||||
|
||||
|
||||
@@ -243,7 +243,7 @@ jobs:
|
||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||
with:
|
||||
path: ~/.pub-cache
|
||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
||||
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Gradle cache
|
||||
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
path: |
|
||||
~/.gradle/caches
|
||||
~/.gradle/wrapper
|
||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
||||
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Maestro CLI cache
|
||||
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
|
||||
@@ -261,7 +261,7 @@ jobs:
|
||||
path: |
|
||||
~/.maestro/bin
|
||||
~/.maestro/lib
|
||||
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
|
||||
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 15 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||
@@ -270,7 +270,7 @@ jobs:
|
||||
path: |
|
||||
~/.android/avd/maestro-api35.avd
|
||||
~/.android/avd/maestro-api35.ini
|
||||
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
|
||||
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Save Android 9 AVD cache
|
||||
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||
@@ -279,7 +279,7 @@ jobs:
|
||||
path: |
|
||||
~/.android/avd/maestro-api28.avd
|
||||
~/.android/avd/maestro-api28.ini
|
||||
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
|
||||
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Upload Maestro diagnostics
|
||||
if: always()
|
||||
|
||||
@@ -77,6 +77,7 @@ import com.edde746.plezy.shared.FlutterOverlayHelper
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.MediaCodecQuery
|
||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import org.chromium.net.CronetEngine
|
||||
@@ -102,7 +103,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
|
||||
internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null
|
||||
|
||||
@OptIn(UnstableApi::class)
|
||||
class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
class ExoPlayerCore(private val activity: Activity) : Player.Listener, SurfacePlayerCore {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "ExoPlayerCore"
|
||||
@@ -3501,7 +3502,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun setVisible(visible: Boolean) {
|
||||
override fun setVisible(visible: Boolean) {
|
||||
if (disposing) return
|
||||
currentVisible = visible
|
||||
activity.runOnUiThread {
|
||||
@@ -3594,7 +3595,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
fun onPipModeChanged(isInPipMode: Boolean) {
|
||||
override fun onPipModeChanged(isInPipMode: Boolean) {
|
||||
if (disposing) return
|
||||
activity.runOnUiThread {
|
||||
if (disposing) return@runOnUiThread
|
||||
@@ -3610,7 +3611,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
fun updateFrame() {
|
||||
override fun updateFrame() {
|
||||
if (disposing) return
|
||||
activity.runOnUiThread {
|
||||
if (disposing) return@runOnUiThread
|
||||
@@ -3625,15 +3626,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
|
||||
// Audio Focus
|
||||
|
||||
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
|
||||
override fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
|
||||
|
||||
fun abandonAudioFocus() {
|
||||
override fun abandonAudioFocus() {
|
||||
audioFocusManager?.abandonAudioFocus()
|
||||
}
|
||||
|
||||
// Frame Rate Matching
|
||||
|
||||
fun setVideoFrameRate(
|
||||
override fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
@@ -3649,7 +3650,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import com.edde746.plezy.shared.MpvContentUriResolver
|
||||
import com.edde746.plezy.shared.PlayerChannelBinding
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import com.edde746.plezy.shared.ResolvedMpvUri
|
||||
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||
@@ -46,6 +47,10 @@ class ExoPlayerPlugin :
|
||||
private var activity: Activity? = null
|
||||
private var activityBinding: ActivityPluginBinding? = null
|
||||
|
||||
/** Whichever core currently owns the surface; both expose [SurfacePlayerCore] identically. */
|
||||
private val activeSurfaceCore: SurfacePlayerCore?
|
||||
get() = if (usingMpvFallback) mpvCore else playerCore
|
||||
|
||||
// Every Dart observeProperty registration, kept so an ExoPlayer→MPV
|
||||
// fallback can re-observe exactly what Dart asked for instead of
|
||||
// maintaining a parallel hard-coded list.
|
||||
@@ -951,20 +956,12 @@ class ExoPlayerPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.setVisible(visible)
|
||||
} else {
|
||||
playerCore?.setVisible(visible)
|
||||
}
|
||||
activeSurfaceCore?.setVisible(visible)
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleUpdateFrame(result: MethodChannel.Result) {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.updateFrame()
|
||||
} else {
|
||||
playerCore?.updateFrame()
|
||||
}
|
||||
activeSurfaceCore?.updateFrame()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
@@ -976,51 +973,31 @@ class ExoPlayerPlugin :
|
||||
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
|
||||
|
||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
|
||||
val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) }
|
||||
if (usingMpvFallback) {
|
||||
val core = mpvCore
|
||||
val core = activeSurfaceCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete)
|
||||
}
|
||||
} else {
|
||||
val core = playerCore
|
||||
if (core == null) {
|
||||
result.success(false)
|
||||
} else {
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete)
|
||||
return
|
||||
}
|
||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||
result.success(switched)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "clearVideoFrameRate")
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.clearVideoFrameRate()
|
||||
} else {
|
||||
playerCore?.clearVideoFrameRate()
|
||||
}
|
||||
activeSurfaceCore?.clearVideoFrameRate()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "requestAudioFocus")
|
||||
val granted = if (usingMpvFallback) {
|
||||
mpvCore?.requestAudioFocus() ?: false
|
||||
} else {
|
||||
playerCore?.requestAudioFocus() ?: false
|
||||
}
|
||||
val granted = activeSurfaceCore?.requestAudioFocus() ?: false
|
||||
result.success(granted)
|
||||
}
|
||||
|
||||
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
|
||||
Log.d(TAG, "abandonAudioFocus")
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.abandonAudioFocus()
|
||||
} else {
|
||||
playerCore?.abandonAudioFocus()
|
||||
}
|
||||
activeSurfaceCore?.abandonAudioFocus()
|
||||
result.success(null)
|
||||
}
|
||||
|
||||
@@ -1230,11 +1207,7 @@ class ExoPlayerPlugin :
|
||||
|
||||
fun onPipModeChanged(isInPipMode: Boolean) {
|
||||
activity?.runOnUiThread {
|
||||
if (usingMpvFallback) {
|
||||
mpvCore?.onPipModeChanged(isInPipMode)
|
||||
} else {
|
||||
playerCore?.onPipModeChanged(isInPipMode)
|
||||
}
|
||||
activeSurfaceCore?.onPipModeChanged(isInPipMode)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.edde746.plezy.shared.AudioFocusManager
|
||||
import com.edde746.plezy.shared.FrameRateManager
|
||||
import com.edde746.plezy.shared.PlayerDelegate
|
||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||
import dev.jdtech.mpv.*
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
@@ -40,7 +41,7 @@ class MpvPlayerCore private constructor(
|
||||
private val audioOnly: Boolean,
|
||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||
initializedForTesting: Boolean
|
||||
) : SurfaceHolder.Callback {
|
||||
) : SurfaceHolder.Callback, SurfacePlayerCore {
|
||||
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
||||
|
||||
internal constructor(
|
||||
@@ -405,7 +406,7 @@ class MpvPlayerCore private constructor(
|
||||
|
||||
// Audio Focus
|
||||
|
||||
fun requestAudioFocus(): Boolean {
|
||||
override fun requestAudioFocus(): Boolean {
|
||||
val granted = audioFocusManager?.requestAudioFocus() ?: false
|
||||
if (granted && pausedForAudioFocusLoss) {
|
||||
resumeAfterAudioFocusGain("audio focus request granted")
|
||||
@@ -413,7 +414,7 @@ class MpvPlayerCore private constructor(
|
||||
return granted
|
||||
}
|
||||
|
||||
fun abandonAudioFocus() {
|
||||
override fun abandonAudioFocus() {
|
||||
audioFocusManager?.abandonAudioFocus()
|
||||
}
|
||||
|
||||
@@ -1096,7 +1097,7 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun setVisible(visible: Boolean) {
|
||||
override fun setVisible(visible: Boolean) {
|
||||
// Audio-only: no render layer to show or hide — tolerated no-op.
|
||||
if (audioOnly || disposing) return
|
||||
runOnMain {
|
||||
@@ -1121,11 +1122,11 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun onPipModeChanged(isInPipMode: Boolean) {
|
||||
override fun onPipModeChanged(isInPipMode: Boolean) {
|
||||
// MPV handles aspect ratio internally via its own surface management
|
||||
}
|
||||
|
||||
fun updateFrame() {
|
||||
override fun updateFrame() {
|
||||
// Audio-only: no surface to refresh — tolerated no-op.
|
||||
if (audioOnly || disposing) return
|
||||
runOnMain {
|
||||
@@ -1160,7 +1161,7 @@ class MpvPlayerCore private constructor(
|
||||
|
||||
// Frame Rate Matching
|
||||
|
||||
fun setVideoFrameRate(
|
||||
override fun setVideoFrameRate(
|
||||
fps: Float,
|
||||
videoDurationMs: Long,
|
||||
extraDelayMs: Long,
|
||||
@@ -1182,7 +1183,7 @@ class MpvPlayerCore private constructor(
|
||||
}
|
||||
}
|
||||
|
||||
fun clearVideoFrameRate() {
|
||||
override fun clearVideoFrameRate() {
|
||||
frameRateManager?.clearVideoFrameRate()
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -29,7 +29,7 @@ class ConnectionBootstrap {
|
||||
required this.profileRegistry,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers,
|
||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
|
||||
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||
|
||||
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 {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
|
||||
+140
-299
@@ -246,197 +246,56 @@ class AppDatabase extends _$AppDatabase {
|
||||
final joinRows = await (select(
|
||||
profileConnections,
|
||||
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get();
|
||||
// Drift's generated serializer is the recovery image's column schema:
|
||||
// `toJson`/`fromJson` use these camelCase keys, so the read and restore
|
||||
// sides can never drift apart when a column is added or renamed.
|
||||
return {
|
||||
'connections': [
|
||||
for (final row in connectionRows)
|
||||
{
|
||||
'id': row.id,
|
||||
'kind': row.kind,
|
||||
'displayName': row.displayName,
|
||||
'configJson': row.configJson,
|
||||
'isDefault': row.isDefault,
|
||||
'createdAt': row.createdAt,
|
||||
'lastAuthenticatedAt': row.lastAuthenticatedAt,
|
||||
},
|
||||
],
|
||||
'profiles': [
|
||||
for (final row in profileRows)
|
||||
{
|
||||
'id': row.id,
|
||||
'kind': row.kind,
|
||||
'displayName': row.displayName,
|
||||
'avatarThumbUrl': row.avatarThumbUrl,
|
||||
'configJson': row.configJson,
|
||||
'sortOrder': row.sortOrder,
|
||||
'createdAt': row.createdAt,
|
||||
'lastUsedAt': row.lastUsedAt,
|
||||
},
|
||||
],
|
||||
'profileConnections': [
|
||||
for (final row in joinRows)
|
||||
{
|
||||
'profileId': row.profileId,
|
||||
'connectionId': row.connectionId,
|
||||
'userToken': row.userToken,
|
||||
'userIdentifier': row.userIdentifier,
|
||||
'isDefault': row.isDefault,
|
||||
'tokenAcquiredAt': row.tokenAcquiredAt,
|
||||
'lastUsedAt': row.lastUsedAt,
|
||||
},
|
||||
],
|
||||
'connections': [for (final row in connectionRows) row.toJson()],
|
||||
'profiles': [for (final row in profileRows) row.toJson()],
|
||||
'profileConnections': [for (final row in joinRows) row.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
Future<Map<String, Object?>> _readPendingRecoveryRows() async {
|
||||
final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
|
||||
return {
|
||||
'offlineWatchProgress': [
|
||||
for (final row in rows)
|
||||
{
|
||||
'id': row.id,
|
||||
'profileId': row.profileId,
|
||||
'serverId': row.serverId,
|
||||
'clientScopeId': row.clientScopeId,
|
||||
'ratingKey': row.ratingKey,
|
||||
'globalKey': row.globalKey,
|
||||
'actionType': row.actionType,
|
||||
'viewOffset': row.viewOffset,
|
||||
'duration': row.duration,
|
||||
'shouldMarkWatched': row.shouldMarkWatched,
|
||||
'createdAt': row.createdAt,
|
||||
'updatedAt': row.updatedAt,
|
||||
'syncAttempts': row.syncAttempts,
|
||||
'lastError': row.lastError,
|
||||
},
|
||||
],
|
||||
'offlineWatchProgress': [for (final row in rows) row.toJson()],
|
||||
};
|
||||
}
|
||||
|
||||
Future<void> _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async {
|
||||
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', const {
|
||||
'id',
|
||||
'kind',
|
||||
'displayName',
|
||||
'configJson',
|
||||
'isDefault',
|
||||
'createdAt',
|
||||
'lastAuthenticatedAt',
|
||||
});
|
||||
final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', const {
|
||||
'id',
|
||||
'kind',
|
||||
'displayName',
|
||||
'avatarThumbUrl',
|
||||
'configJson',
|
||||
'sortOrder',
|
||||
'createdAt',
|
||||
'lastUsedAt',
|
||||
});
|
||||
final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', const {
|
||||
'profileId',
|
||||
'connectionId',
|
||||
'userToken',
|
||||
'userIdentifier',
|
||||
'isDefault',
|
||||
'tokenAcquiredAt',
|
||||
'lastUsedAt',
|
||||
});
|
||||
final pendingRows = _decodeRecoveryRows(snapshot.pending, 'offlineWatchProgress', const {
|
||||
'id',
|
||||
'profileId',
|
||||
'serverId',
|
||||
'clientScopeId',
|
||||
'ratingKey',
|
||||
'globalKey',
|
||||
'actionType',
|
||||
'viewOffset',
|
||||
'duration',
|
||||
'shouldMarkWatched',
|
||||
'createdAt',
|
||||
'updatedAt',
|
||||
'syncAttempts',
|
||||
'lastError',
|
||||
});
|
||||
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', ConnectionRow.fromJson);
|
||||
final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', ProfileRow.fromJson);
|
||||
final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', ProfileConnectionRow.fromJson);
|
||||
final pendingRows = _decodeRecoveryRows(
|
||||
snapshot.pending,
|
||||
'offlineWatchProgress',
|
||||
OfflineWatchProgressItem.fromJson,
|
||||
);
|
||||
|
||||
// Recovery images from releases before the credential vault may contain
|
||||
// plaintext secrets. Protect them before they cross into Drift; already
|
||||
// protected values remain byte-identical because vault protection is
|
||||
// idempotent.
|
||||
for (final row in connectionRows) {
|
||||
final kind = _requiredRecoveryValue<String>(row, 'kind');
|
||||
final configJson = _requiredRecoveryValue<String>(row, 'configJson');
|
||||
final decoded = jsonDecode(configJson);
|
||||
for (var index = 0; index < connectionRows.length; index++) {
|
||||
final row = connectionRows[index];
|
||||
final decoded = jsonDecode(row.configJson);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw const FormatException('Invalid connection configuration');
|
||||
}
|
||||
if (_containsPlaintextConnectionCredential(kind, decoded)) {
|
||||
row['configJson'] = jsonEncode(await CredentialVault.protectConnectionConfig(kind, decoded));
|
||||
if (_containsPlaintextConnectionCredential(row.kind, decoded)) {
|
||||
connectionRows[index] = row.copyWith(
|
||||
configJson: jsonEncode(await CredentialVault.protectConnectionConfig(row.kind, decoded)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (final row in joinRows) {
|
||||
final token = _requiredRecoveryValue<String>(row, 'userToken');
|
||||
if (token.isNotEmpty && !CredentialVault.isProtected(token)) {
|
||||
row['userToken'] = await CredentialVault.protect(token);
|
||||
for (var index = 0; index < joinRows.length; index++) {
|
||||
final row = joinRows[index];
|
||||
if (row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken)) {
|
||||
joinRows[index] = row.copyWith(userToken: await CredentialVault.protect(row.userToken));
|
||||
}
|
||||
}
|
||||
|
||||
final connectionCompanions = [
|
||||
for (final row in connectionRows)
|
||||
ConnectionsCompanion(
|
||||
id: Value(_requiredRecoveryValue<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 {
|
||||
// Recovery completion (the durable marker removal) is deliberately
|
||||
// separate from this transaction. Replace the snapshot-owned rows so a
|
||||
@@ -446,54 +305,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
await delete(profiles).go();
|
||||
await delete(connections).go();
|
||||
await delete(offlineWatchProgress).go();
|
||||
for (final row in connectionCompanions) {
|
||||
await into(connections).insert(row);
|
||||
// `toCompanion(false)` writes every column explicitly, including the
|
||||
// nulls, so a restored row is byte-identical to the captured one rather
|
||||
// than picking up column defaults.
|
||||
for (final row in connectionRows) {
|
||||
await into(connections).insert(row.toCompanion(false));
|
||||
}
|
||||
for (final row in profileCompanions) {
|
||||
await into(profiles).insert(row);
|
||||
for (final row in profileRows) {
|
||||
await into(profiles).insert(row.toCompanion(false));
|
||||
}
|
||||
for (final row in joinCompanions) {
|
||||
await into(profileConnections).insert(row);
|
||||
for (final row in joinRows) {
|
||||
await into(profileConnections).insert(row.toCompanion(false));
|
||||
}
|
||||
for (final row in pendingCompanions) {
|
||||
await into(offlineWatchProgress).insert(row);
|
||||
for (final row in pendingRows) {
|
||||
await into(offlineWatchProgress).insert(row.toCompanion(false));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static List<Map<String, Object?>> _decodeRecoveryRows(
|
||||
static List<T> _decodeRecoveryRows<T extends DataClass>(
|
||||
Map<String, Object?> group,
|
||||
String key,
|
||||
Set<String> expectedKeys,
|
||||
T Function(Map<String, dynamic> json) fromJson,
|
||||
) {
|
||||
final value = group[key];
|
||||
if (value is! List) throw const FormatException('Invalid tvOS database recovery image');
|
||||
if (value is! List) throw _invalidRecoveryImage;
|
||||
return [
|
||||
for (final value in value)
|
||||
if (value is Map<String, Object?> &&
|
||||
value.keys.toSet().containsAll(expectedKeys) &&
|
||||
value.length == expectedKeys.length)
|
||||
value
|
||||
else
|
||||
throw const FormatException('Invalid tvOS database recovery image'),
|
||||
for (final row in value)
|
||||
if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
|
||||
];
|
||||
}
|
||||
|
||||
static T _requiredRecoveryValue<T>(Map<String, Object?> row, String key) {
|
||||
final value = row[key];
|
||||
if (!row.containsKey(key) || value is! T) {
|
||||
throw const FormatException('Invalid tvOS database recovery image');
|
||||
/// Reads one row through drift's generated deserializer and rejects anything
|
||||
/// that does not round-trip back to the exact same map. Drift already throws
|
||||
/// on a missing or mistyped required column; the round-trip additionally
|
||||
/// rejects unknown and missing-but-nullable columns, which the serializer
|
||||
/// would otherwise accept silently.
|
||||
static T _decodeRecoveryRow<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) {
|
||||
if (!row.containsKey(key)) throw const FormatException('Invalid tvOS database recovery image');
|
||||
final value = row[key];
|
||||
if (value == null) return null;
|
||||
if (value is! T) throw const FormatException('Invalid tvOS database recovery image');
|
||||
return value as T;
|
||||
}
|
||||
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
|
||||
|
||||
@override
|
||||
int get schemaVersion => 20;
|
||||
@@ -658,89 +520,27 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
if (from < 17) {
|
||||
appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)');
|
||||
await customStatement('''
|
||||
WITH download_metadata_ids AS (
|
||||
SELECT global_key, server_id, rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
UNION
|
||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE parent_rating_key IS NOT NULL
|
||||
AND parent_rating_key != ''
|
||||
UNION
|
||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE grandparent_rating_key IS NOT NULL
|
||||
AND grandparent_rating_key != ''
|
||||
)
|
||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||
SELECT DISTINCT
|
||||
metadata.server_id
|
||||
|| '/~plex-profile/'
|
||||
|| owner.profile_id
|
||||
|| ':'
|
||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||
source.data,
|
||||
source.pinned,
|
||||
source.cached_at
|
||||
FROM download_metadata_ids AS metadata
|
||||
JOIN download_owners AS owner
|
||||
ON owner.global_key = metadata.global_key
|
||||
JOIN api_cache AS source
|
||||
ON source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
||||
OR source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
||||
WHERE source.pinned = 1
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
pinned = excluded.pinned,
|
||||
cached_at = excluded.cached_at
|
||||
''');
|
||||
await customStatement(
|
||||
_rescopePinnedPlexMetadataStatement(
|
||||
namespaceExpression: "'/~plex-profile/' || owner.profile_id || ':'",
|
||||
ownerJoin: '''JOIN download_owners AS owner
|
||||
ON owner.global_key = metadata.global_key''',
|
||||
),
|
||||
);
|
||||
// A direct pre-v14 upgrade has no owners yet: profiles and owner
|
||||
// adoption are bootstrapped only after the database opens. Preserve
|
||||
// those downloads in the neutral Plex transfer namespace so the
|
||||
// first profile can adopt them without inheriting legacy watch data.
|
||||
await customStatement('''
|
||||
WITH download_metadata_ids AS (
|
||||
SELECT global_key, server_id, rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
UNION
|
||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE parent_rating_key IS NOT NULL
|
||||
AND parent_rating_key != ''
|
||||
UNION
|
||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE grandparent_rating_key IS NOT NULL
|
||||
AND grandparent_rating_key != ''
|
||||
)
|
||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||
SELECT DISTINCT
|
||||
metadata.server_id
|
||||
|| '/~plex-transfer:'
|
||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||
source.data,
|
||||
source.pinned,
|
||||
source.cached_at
|
||||
FROM download_metadata_ids AS metadata
|
||||
JOIN api_cache AS source
|
||||
ON source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
||||
OR source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
||||
WHERE source.pinned = 1
|
||||
AND NOT EXISTS (
|
||||
await customStatement(
|
||||
_rescopePinnedPlexMetadataStatement(
|
||||
namespaceExpression: "'/~plex-transfer:'",
|
||||
ownerFilter: '''AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM download_owners AS owner
|
||||
WHERE owner.global_key = metadata.global_key
|
||||
)
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
pinned = excluded.pinned,
|
||||
cached_at = excluded.cached_at
|
||||
''');
|
||||
)''',
|
||||
),
|
||||
);
|
||||
|
||||
final transferRows = await customSelect('''
|
||||
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) {
|
||||
return value == null ? column.isNull() : column.equals(value);
|
||||
}
|
||||
@@ -971,6 +767,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
|
||||
/// Get pending watch actions for a specific server
|
||||
@visibleForTesting
|
||||
Future<List<OfflineWatchProgressItem>> getPendingWatchActionsForServer(ServerId serverId, {String? profileId}) {
|
||||
return (select(offlineWatchProgress)
|
||||
..where(
|
||||
@@ -994,12 +791,13 @@ class AppDatabase extends _$AppDatabase {
|
||||
(t) =>
|
||||
matchesKey(t) &
|
||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
|
||||
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||
(filterClientScope ? _nullableTextPredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]);
|
||||
}
|
||||
|
||||
/// Get the latest action for a specific item
|
||||
@visibleForTesting
|
||||
Future<OfflineWatchProgressItem?> getLatestWatchAction(
|
||||
String globalKey, {
|
||||
String? profileId,
|
||||
@@ -1102,7 +900,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId) &
|
||||
_nullableTextPredicate(t.clientScopeId, clientScopeId) &
|
||||
t.actionType.equals(OfflineActionType.progress.id),
|
||||
)
|
||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||
@@ -1164,7 +962,7 @@ class AppDatabase extends _$AppDatabase {
|
||||
(t) =>
|
||||
t.globalKey.equals(globalKey) &
|
||||
_nullableTextPredicate(t.profileId, profileId) &
|
||||
_clientScopePredicate(t.clientScopeId, clientScopeId),
|
||||
_nullableTextPredicate(t.clientScopeId, clientScopeId),
|
||||
))
|
||||
.go();
|
||||
|
||||
@@ -1384,29 +1182,21 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
|
||||
Future<void> _writeSyncRule(String globalKey, SyncRulesCompanion values) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values);
|
||||
}
|
||||
|
||||
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
|
||||
}
|
||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount)));
|
||||
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
|
||||
await (update(
|
||||
syncRules,
|
||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
|
||||
}
|
||||
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) async {
|
||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
|
||||
);
|
||||
}
|
||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
|
||||
|
||||
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
|
||||
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
|
||||
|
||||
Future<void> completeSyncRuleExecution(String globalKey) {
|
||||
return (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
||||
@@ -1437,6 +1227,57 @@ class AppDatabase extends _$AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the v17 statement that re-keys pinned legacy Plex metadata rows into
|
||||
/// a scoped cache namespace.
|
||||
///
|
||||
/// The owned and the ownerless branch run the same operation over the same
|
||||
/// `download_metadata_ids` set and differ only in three spots: the expression
|
||||
/// spliced into the new `cache_key` ([namespaceExpression]), an optional join
|
||||
/// that exposes the owning profile ([ownerJoin]), and an optional extra
|
||||
/// predicate that keeps each branch to its own rows ([ownerFilter]).
|
||||
String _rescopePinnedPlexMetadataStatement({
|
||||
required String namespaceExpression,
|
||||
String ownerJoin = '',
|
||||
String ownerFilter = '',
|
||||
}) =>
|
||||
'''
|
||||
WITH download_metadata_ids AS (
|
||||
SELECT global_key, server_id, rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
UNION
|
||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE parent_rating_key IS NOT NULL
|
||||
AND parent_rating_key != ''
|
||||
UNION
|
||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
||||
FROM downloaded_media
|
||||
WHERE grandparent_rating_key IS NOT NULL
|
||||
AND grandparent_rating_key != ''
|
||||
)
|
||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||
SELECT DISTINCT
|
||||
metadata.server_id
|
||||
|| $namespaceExpression
|
||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||
source.data,
|
||||
source.pinned,
|
||||
source.cached_at
|
||||
FROM download_metadata_ids AS metadata
|
||||
$ownerJoin
|
||||
JOIN api_cache AS source
|
||||
ON source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
||||
OR source.cache_key =
|
||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
||||
WHERE source.pinned = 1
|
||||
$ownerFilter
|
||||
ON CONFLICT(cache_key) DO UPDATE SET
|
||||
data = excluded.data,
|
||||
pinned = excluded.pinned,
|
||||
cached_at = excluded.cached_at
|
||||
''';
|
||||
|
||||
Future<File> _resolveProductionDatabaseFile() async {
|
||||
final dbFolder = (Platform.isAndroid || Platform.isIOS)
|
||||
? await getApplicationDocumentsDirectory()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:drift/drift.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../media/ids.dart';
|
||||
|
||||
import 'app_database.dart';
|
||||
@@ -147,6 +148,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return (await _validDownloadOwnerRows(globalKey)).length;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<bool> hasDownloadOwner(String globalKey, {String? excludingProfileId}) async {
|
||||
final rows = await _validDownloadOwnerRows(globalKey, excludingProfileId: excludingProfileId);
|
||||
return rows.isNotEmpty;
|
||||
@@ -278,64 +280,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
}
|
||||
}
|
||||
|
||||
Future<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({
|
||||
required String mediaGlobalKey,
|
||||
int priority = 0,
|
||||
@@ -582,6 +526,7 @@ extension DownloadDatabaseOperations on AppDatabase {
|
||||
return (await query.map((row) => row.read(count) ?? 0).getSingle());
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
Future<Set<String>> getReferencedDownloadSafRoots() async {
|
||||
final rows =
|
||||
await (selectOnly(downloadedMedia)
|
||||
|
||||
@@ -218,7 +218,7 @@ class ProfileConnections extends Table {
|
||||
// Profile.virtualPlexHome from PlexHomeService's live cache, never
|
||||
// persisted in `profiles`), so an FK here would reject every join row
|
||||
// they need. Profile deletion instead cleans up join rows explicitly
|
||||
// (removeAllProfileConnectionsAndCleanup in profile_connection_cleanup)
|
||||
// (ProfileConnectionCleanup.removeAllProfileConnections)
|
||||
// before calling ProfileRegistry.remove.
|
||||
TextColumn get profileId => text()();
|
||||
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
|
||||
|
||||
@@ -5,8 +5,8 @@ import 'focus_theme.dart';
|
||||
/// Exposes the focus state of an enclosing focus wrapper to a descendant
|
||||
/// [CardFocusBorder] that draws the focus border itself.
|
||||
///
|
||||
/// Wrappers ([FocusableWrapper]/[FocusBuilders.buildFocusableCard]) insert this
|
||||
/// instead of painting a border when `delegateFocusBorder` is set, so cards can
|
||||
/// The shared focus chrome ([buildFocusChrome]) inserts this instead of painting
|
||||
/// a border when `delegateFocusBorder` is set, so cards can
|
||||
/// put the border on the exact rect the design highlights (the poster image,
|
||||
/// not the card-plus-captions rect — issue #1278). Only the [CardFocusBorder]
|
||||
/// element registers a dependency, so a focus flip rebuilds just that border
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -32,22 +32,18 @@ class ChipKeyCallbacks {
|
||||
/// This mixin handles:
|
||||
/// - Internal/external FocusNode pattern
|
||||
/// - `_isFocused` state tracking
|
||||
/// - Listener setup in `initState`
|
||||
/// - Listener handoff in `didUpdateWidget`
|
||||
/// - Cleanup in `dispose`
|
||||
/// - Listener setup, handoff and cleanup across the State lifecycle
|
||||
///
|
||||
/// To use this mixin:
|
||||
/// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class
|
||||
/// 2. Implement [widgetFocusNode] to return the widget's optional focusNode
|
||||
/// 3. Implement [debugLabel] to return a debug label for the internal node
|
||||
/// 4. Call [initFocusNode] in your `initState`
|
||||
/// 5. Call [updateFocusNode] in your `didUpdateWidget`
|
||||
/// 6. Call [disposeFocusNode] in your `dispose`
|
||||
/// 7. Use [focusNode] and [isFocused] in your build method
|
||||
/// 4. Use [focusNode] and [isFocused] in your build method
|
||||
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
final _focusNodeBinding = OwnedFocusNodeBinding();
|
||||
bool _isFocused = false;
|
||||
final _selectLongPress = DpadSelectLongPressController();
|
||||
FocusNode? _boundExternalNode;
|
||||
|
||||
/// Override to return the widget's optional external focus node.
|
||||
FocusNode? get widgetFocusNode;
|
||||
@@ -61,22 +57,30 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
/// Whether this widget is currently focused.
|
||||
bool get isFocused => _isFocused;
|
||||
|
||||
/// Call this in your `initState` to set up the focus listener.
|
||||
void initFocusNode() {
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bindFocusNode();
|
||||
}
|
||||
|
||||
/// Call this in your `didUpdateWidget` with the old widget's focusNode.
|
||||
void updateFocusNode(FocusNode? oldFocusNode) {
|
||||
if (oldFocusNode != widgetFocusNode) {
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
||||
@override
|
||||
void didUpdateWidget(T oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_boundExternalNode != widgetFocusNode) {
|
||||
_bindFocusNode();
|
||||
}
|
||||
}
|
||||
|
||||
/// Call this in your `dispose` to clean up the focus listener.
|
||||
void disposeFocusNode() {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNodeBinding.dispose();
|
||||
_selectLongPress.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _bindFocusNode() {
|
||||
_boundExternalNode = widgetFocusNode;
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
@@ -102,6 +106,12 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||
///
|
||||
/// Returns [KeyEventResult.handled] if the event was consumed,
|
||||
/// [KeyEventResult.ignored] otherwise.
|
||||
///
|
||||
/// Runs the same activation sequence as `_FocusableWrapperState._handleKeyEvent`
|
||||
/// but is deliberately kept separate: a chip leaves the context-menu key
|
||||
/// unconsumed when [ChipKeyCallbacks.onLongPress] is null and traps RIGHT/DOWN
|
||||
/// so focus cannot escape the strip, where a wrapper does the opposite on both
|
||||
/// counts.
|
||||
KeyEventResult handleChipKeyEvent(FocusNode _, KeyEvent event, ChipKeyCallbacks callbacks) {
|
||||
final key = event.logicalKey;
|
||||
|
||||
|
||||
@@ -7,23 +7,35 @@ import 'owned_focus_node_binding.dart';
|
||||
/// auto-scrolls the tile into view when it gains focus.
|
||||
mixin FocusableTileStateMixin<T extends StatefulWidget> on State<T> {
|
||||
final _focusNodeBinding = OwnedFocusNodeBinding();
|
||||
FocusNode? _boundExternalNode;
|
||||
|
||||
FocusNode? get widgetFocusNode;
|
||||
|
||||
FocusNode get effectiveFocusNode => _focusNodeBinding.node;
|
||||
|
||||
void initFocusNode() {
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange);
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_bindFocusNode();
|
||||
}
|
||||
|
||||
void updateFocusNode(FocusNode? oldFocusNode) {
|
||||
if (oldFocusNode != widgetFocusNode) {
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange);
|
||||
@override
|
||||
void didUpdateWidget(T oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (_boundExternalNode != widgetFocusNode) {
|
||||
_bindFocusNode();
|
||||
}
|
||||
}
|
||||
|
||||
void disposeFocusNode() {
|
||||
@override
|
||||
void dispose() {
|
||||
_focusNodeBinding.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _bindFocusNode() {
|
||||
_boundExternalNode = widgetFocusNode;
|
||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange);
|
||||
}
|
||||
|
||||
void _onFocusChange() {
|
||||
|
||||
@@ -4,10 +4,9 @@ import 'package:flutter/rendering.dart';
|
||||
|
||||
import '../widgets/clickable_cursor.dart';
|
||||
import '../utils/text_input_diagnostics.dart';
|
||||
import 'card_focus_scope.dart';
|
||||
import 'dpad_navigator.dart';
|
||||
import 'dpad_select_long_press_controller.dart';
|
||||
import 'focus_glow_overlay.dart';
|
||||
import 'focus_chrome.dart';
|
||||
import 'focus_theme.dart';
|
||||
import 'input_mode_tracker.dart';
|
||||
import 'owned_focus_node_binding.dart';
|
||||
@@ -426,6 +425,11 @@ class _FocusableWrapperState extends State<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) {
|
||||
final key = event.logicalKey;
|
||||
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
|
||||
// semantics on every animation tick is substantially more expensive than
|
||||
// changing the paint transform alone on dense TV grids.
|
||||
Widget card;
|
||||
if (widget.delegateFocusBorder) {
|
||||
card = CardFocusScope(showFocus: showFocus, child: widget.child);
|
||||
} else {
|
||||
final focusDecoration = widget.useBackgroundFocus
|
||||
? FocusTheme.focusBackgroundDecoration(
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
radii: widget.borderRadii,
|
||||
)
|
||||
: FocusTheme.focusDecoration(
|
||||
context,
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
radii: widget.borderRadii,
|
||||
color: widget.focusColor,
|
||||
);
|
||||
card = AnimatedContainer(
|
||||
duration: duration,
|
||||
curve: Curves.easeOutCubic,
|
||||
decoration: focusDecoration,
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
if (widget.useFocusGlow) {
|
||||
card = FocusGlowOverlay(
|
||||
isFocused: showFocus,
|
||||
borderRadius: widget.borderRadius,
|
||||
color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
inner = AnimatedBuilder(
|
||||
animation: _scaleAnimation!,
|
||||
child: card,
|
||||
child: buildFocusChrome(
|
||||
context,
|
||||
showFocus: showFocus,
|
||||
duration: duration,
|
||||
borderRadius: widget.borderRadius,
|
||||
borderRadii: widget.borderRadii,
|
||||
focusColor: widget.focusColor,
|
||||
useBackgroundFocus: widget.useBackgroundFocus,
|
||||
useFocusGlow: widget.useFocusGlow,
|
||||
delegateFocusBorder: widget.delegateFocusBorder,
|
||||
child: widget.child,
|
||||
),
|
||||
builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!),
|
||||
);
|
||||
}
|
||||
|
||||
+20
-41
@@ -24,6 +24,7 @@ import 'profiles/profile.dart';
|
||||
import 'profiles/profile_connection_cleanup.dart';
|
||||
import 'profiles/profile_connection_registry.dart';
|
||||
import 'profiles/profile_registry.dart';
|
||||
import 'profiles/profile_selection_policy.dart';
|
||||
import 'mixins/mounted_set_state_mixin.dart';
|
||||
import 'theme/mono_theme.dart';
|
||||
import 'profiles/plex_home_service.dart';
|
||||
@@ -1043,7 +1044,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
return provider;
|
||||
},
|
||||
update: (_, multiServerProvider, previous) {
|
||||
final provider = previous ?? OfflineModeProvider(_serverManager, multiServerProvider: multiServerProvider);
|
||||
final provider = previous!;
|
||||
provider.updateMultiServerProvider(multiServerProvider);
|
||||
provider.initialize(); // Idempotent - safe to call again
|
||||
return provider;
|
||||
@@ -1069,8 +1070,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
pinPrompt: _rootPinPrompt,
|
||||
shouldDeferInitialBind: (_) async {
|
||||
final settings = await SettingsService.getInstance();
|
||||
return settings.read(SettingsService.requireProfileSelectionOnOpen) &&
|
||||
activeProfile.hasMultipleProfiles;
|
||||
return activeProfile.requiresSelectionOnOpen(settings);
|
||||
},
|
||||
);
|
||||
},
|
||||
@@ -1081,7 +1081,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
ChangeNotifierProxyProvider<ActiveProfileProvider, DownloadProvider>(
|
||||
create: (context) => DownloadProvider(downloadManager: _downloadManager, database: _appDatabase),
|
||||
update: (context, activeProfile, previous) {
|
||||
final provider = previous ?? DownloadProvider(downloadManager: _downloadManager, database: _appDatabase);
|
||||
final provider = previous!;
|
||||
provider.setActiveProfileId(activeProfile.activeId);
|
||||
return provider;
|
||||
},
|
||||
@@ -1134,7 +1134,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
return _offlineWatchSyncService;
|
||||
},
|
||||
update: (_, activeProfile, previous) {
|
||||
final provider = previous ?? _offlineWatchSyncService;
|
||||
final provider = previous!;
|
||||
provider.setActiveProfileId(
|
||||
activeProfile.activeId,
|
||||
availableProfileCount: activeProfile.isInitialized ? activeProfile.profiles.length : null,
|
||||
@@ -1147,14 +1147,12 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
||||
syncService: context.read<OfflineWatchSyncService>(),
|
||||
downloadProvider: context.read<DownloadProvider>(),
|
||||
),
|
||||
update: (_, syncService, downloadProvider, previous) {
|
||||
return previous ?? OfflineWatchProvider(syncService: syncService, downloadProvider: downloadProvider);
|
||||
},
|
||||
update: (_, syncService, downloadProvider, previous) => previous!,
|
||||
),
|
||||
ChangeNotifierProxyProvider2<ActiveProfileProvider, ConnectionRegistry, UserProfileProvider>(
|
||||
create: (context) => UserProfileProvider(storageService: context.read<StorageService>()),
|
||||
update: (context, activeProfile, connections, previous) {
|
||||
final provider = previous ?? UserProfileProvider(storageService: context.read<StorageService>());
|
||||
final provider = previous!;
|
||||
provider.attach(
|
||||
connections: connections,
|
||||
activeProfile: activeProfile,
|
||||
@@ -1213,7 +1211,7 @@ class _AppShell extends StatelessWidget {
|
||||
themeMode: themeProvider.materialThemeMode,
|
||||
navigatorKey: rootNavigatorKey,
|
||||
navigatorObservers: [BackKeySuppressorObserver()],
|
||||
home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome),
|
||||
home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome),
|
||||
// Siri Remote select + gamepad A report as
|
||||
// LogicalKeyboardKey.{select,gameButtonA} which aren't
|
||||
// in Flutter's default shortcut set — Material-level
|
||||
@@ -1300,32 +1298,6 @@ bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
|
||||
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
|
||||
}
|
||||
|
||||
class OrientationAwareSetup extends StatefulWidget {
|
||||
const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome});
|
||||
|
||||
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
|
||||
|
||||
@override
|
||||
State<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 {
|
||||
const SetupScreen({
|
||||
super.key,
|
||||
@@ -1356,6 +1328,15 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
_loadSavedCredentials();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
// The app's first screen: undo any orientation lock a previous run's
|
||||
// full-screen player left behind, and re-apply it whenever the form
|
||||
// factor signals (Theme.platform / MediaQuery size) change.
|
||||
OrientationHelper.restoreDefaultOrientations(context);
|
||||
}
|
||||
|
||||
void _setStatus(String message) {
|
||||
setStateIfMounted(() => _statusMessage = message);
|
||||
}
|
||||
@@ -1418,12 +1399,12 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
profileRegistry: profileRegistry,
|
||||
);
|
||||
await bootstrap.run();
|
||||
final pruned = await pruneUnreferencedJellyfinConnections(
|
||||
final pruned = await ProfileConnectionCleanup(
|
||||
profileConnections: profileConnections,
|
||||
connections: connRegistry,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
).pruneUnreferencedJellyfinConnections();
|
||||
if (pruned > 0) {
|
||||
appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}');
|
||||
}
|
||||
@@ -1561,9 +1542,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (!mounted) return;
|
||||
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
||||
final requireOnOpen =
|
||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
||||
final shouldPrompt = hasNoActive || requireOnOpen;
|
||||
final shouldPrompt = hasNoActive || activeProfile.requiresSelectionOnOpen(settings);
|
||||
|
||||
var bindingSucceeded = activeProfile.lastBindingSucceeded;
|
||||
if (shouldPrompt) {
|
||||
|
||||
@@ -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.
|
||||
/// Use this for representative lookups and immediate "play first" actions.
|
||||
Future<MediaItem?> fetchFirstEpisodeForSeason(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// ignore_for_file: invalid_annotation_target
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
|
||||
import '../utils/media_server_http_client.dart' show AbortController;
|
||||
import 'media_kind.dart';
|
||||
|
||||
part 'library_query.freezed.dart';
|
||||
@@ -88,3 +89,33 @@ int fallbackPageTotal({required int offset, required int itemCount, int? request
|
||||
final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
|
||||
return offset + itemCount + (fullPage ? 1 : 0);
|
||||
}
|
||||
|
||||
/// Walk every page of a paginated endpoint and concatenate the results.
|
||||
///
|
||||
/// [fetchPage] receives a zero-based offset and [pageSize] and is called until
|
||||
/// a page comes back empty, the accumulated count reaches the page's
|
||||
/// [LibraryPage.totalCount], or — when [stopOnShortPage] is set — a page comes
|
||||
/// back shorter than [pageSize]. The short-page break is for backends whose
|
||||
/// total is unreliable; leave it off when the total is authoritative.
|
||||
///
|
||||
/// [abort] is checked before and after every request. Errors propagate.
|
||||
Future<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;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import '../models/livetv_capture_buffer.dart';
|
||||
import '../models/livetv_channel.dart';
|
||||
import '../models/livetv_dvr.dart';
|
||||
import '../models/livetv_lineup.dart';
|
||||
import '../models/livetv_program.dart';
|
||||
import '../models/livetv_server_status.dart';
|
||||
import '../models/livetv_session.dart';
|
||||
import '../models/media_grab_operation.dart';
|
||||
import '../models/media_grabber_device.dart';
|
||||
import '../models/media_provider_info.dart';
|
||||
import '../models/media_subscription.dart';
|
||||
|
||||
class LiveTvActivityResult<T> {
|
||||
@@ -202,67 +197,13 @@ abstract class LiveTvSupport {
|
||||
/// recording APIs.
|
||||
abstract class LiveTvDvrSupport {
|
||||
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<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<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?> updateRecordingRule(String subscriptionId, Map<String, Object?> prefs);
|
||||
Future<void> deleteRecordingRule(String subscriptionId);
|
||||
Future<MediaSubscription?> moveRecordingRule(String subscriptionId, {String? afterSubscriptionId});
|
||||
Future<void> processRecordingRules();
|
||||
Future<List<MediaGrabOperation>> fetchScheduledRecordings();
|
||||
Future<void> cancelGrab(String operationId);
|
||||
@@ -271,13 +212,4 @@ abstract class LiveTvDvrSupport {
|
||||
required List<String> ratingKeys,
|
||||
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});
|
||||
}
|
||||
|
||||
@@ -676,13 +676,6 @@ sealed class MediaItem with _$MediaItem {
|
||||
return resolvedBackdropPaths;
|
||||
}
|
||||
|
||||
/// Returns the best hero art path based on the container's aspect ratio.
|
||||
String? heroArt({required double containerAspectRatio}) {
|
||||
final candidates = heroArtCandidates(containerAspectRatio: containerAspectRatio);
|
||||
if (candidates.isEmpty) return null;
|
||||
return candidates.first;
|
||||
}
|
||||
|
||||
/// Returns hero art candidates in display-preference order.
|
||||
List<String> heroArtCandidates({required double containerAspectRatio}) {
|
||||
final own = resolvedBackdropPaths;
|
||||
|
||||
@@ -26,8 +26,6 @@ enum MediaKind {
|
||||
|
||||
bool get isVideo => this == movie || this == episode || this == clip;
|
||||
|
||||
bool get isShowRelated => this == show || this == season || this == episode;
|
||||
|
||||
bool get isMusic => this == artist || this == album || this == track;
|
||||
|
||||
bool get isPlayable => isVideo || this == track;
|
||||
|
||||
@@ -61,11 +61,6 @@ class MediaPlaylist {
|
||||
/// Display-friendly title (alias of [title] for parity with [MediaItem]).
|
||||
String get displayTitle => title;
|
||||
|
||||
/// Whether this playlist's contents can be reordered/edited by the client.
|
||||
/// Plex smart playlists are read-only; manual playlists and Jellyfin
|
||||
/// playlists are editable.
|
||||
bool get isEditable => !smart;
|
||||
|
||||
String get globalKey => serverId != null ? buildGlobalKey(ServerId(serverId!), id) : id;
|
||||
|
||||
MediaPlaylist copyWith({
|
||||
|
||||
@@ -265,7 +265,7 @@ abstract class MediaServerClient {
|
||||
/// `/Audio/{id}/Lyrics` (per-line tick offsets when synced); Plex: a
|
||||
/// sidecar-lyrics track stream (`streamType 4`) fetched from
|
||||
/// `/library/streams/{id}` and parsed from LRC. Synced-ness is per
|
||||
/// [Lyrics.synced]; gated by [ServerCapabilities.lyrics].
|
||||
/// [Lyrics.synced]; per-track absence is the runtime gate.
|
||||
Future<Lyrics?> fetchLyrics(MediaItem track);
|
||||
|
||||
/// Free-text search across the user's libraries. [limit] is a per-request
|
||||
@@ -274,9 +274,6 @@ abstract class MediaServerClient {
|
||||
/// backend request owned by this search pass.
|
||||
Future<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"
|
||||
/// internally; the neutral name matches the Continue Watching UI surface.
|
||||
Future<List<MediaItem>> fetchContinueWatching({int? count = 20});
|
||||
|
||||
@@ -275,7 +275,6 @@ class MediaMarker {
|
||||
Duration get startTime => Duration(milliseconds: startTimeOffset);
|
||||
Duration get endTime => Duration(milliseconds: endTimeOffset);
|
||||
|
||||
bool get isIntro => type == 'intro';
|
||||
bool get isCredits => type == 'credits';
|
||||
|
||||
bool containsPosition(Duration position) {
|
||||
|
||||
@@ -1,17 +1,3 @@
|
||||
/// How the alpha-jump bar behaves for libraries on this backend.
|
||||
enum AlphaBarMode {
|
||||
/// No alpha bar — hide entirely.
|
||||
none,
|
||||
|
||||
/// Plex: server reports per-letter cumulative offsets via `/firstCharacter`,
|
||||
/// taps scroll the grid to the offset.
|
||||
scrollSnap,
|
||||
|
||||
/// Jellyfin: bar acts as a filter button — taps set `NameStartsWith` query
|
||||
/// param, results re-fetch.
|
||||
nameStartsWithFilter,
|
||||
}
|
||||
|
||||
/// Static capability flags advertised by a [MediaServerClient]. UI consults
|
||||
/// these to gate feature affordances per server (e.g. hide Live TV when no
|
||||
/// connected server supports it).
|
||||
@@ -21,14 +7,6 @@ enum AlphaBarMode {
|
||||
/// Jellyfin features are wired in over time, the corresponding flags flip
|
||||
/// without changing call sites.
|
||||
class ServerCapabilities {
|
||||
/// Server-side `PlayQueue` resource (Plex `/playQueues`) — enables shared
|
||||
/// queue state across devices and Watch Together coordination.
|
||||
final bool serverSidePlayQueue;
|
||||
|
||||
/// Server-side editable playlists (Plex `/playlists`, Jellyfin
|
||||
/// `/Playlists`).
|
||||
final bool serverSidePlaylists;
|
||||
|
||||
/// This backend kind has a Live TV / DVR API the app can talk to. Whether
|
||||
/// a *specific* server has Live TV configured is a runtime concern —
|
||||
/// [MultiServerProvider.checkLiveTvAvailability] probes each server and
|
||||
@@ -41,17 +19,9 @@ class ServerCapabilities {
|
||||
/// when [liveTv] is true.
|
||||
final bool liveTvDvr;
|
||||
|
||||
/// Server proxies subtitle search (e.g. OpenSubtitles).
|
||||
final bool subtitleSearch;
|
||||
|
||||
/// Server can transcode video.
|
||||
final bool videoTranscoding;
|
||||
|
||||
/// Server supports server-side downloads / "sync" (the queued-from-server
|
||||
/// model). Both Plex and Jellyfin support client-driven downloads, which
|
||||
/// is a separate concept.
|
||||
final bool serverSideSync;
|
||||
|
||||
/// Server provides curated recommendation hubs (Plex Discover). Jellyfin
|
||||
/// returns synthesized hubs but with sparser categorisation.
|
||||
final bool richHubs;
|
||||
@@ -72,32 +42,9 @@ class ServerCapabilities {
|
||||
/// Hides the "Search subtitles" affordance when false.
|
||||
final bool externalSubtitleSearch;
|
||||
|
||||
/// Persisting per-track audio/subtitle preferences server-side. Plex uses
|
||||
/// `/library/metadata/{id}/prefs` + `selectStream`; Jellyfin saves selected
|
||||
/// stream indexes from `/Sessions/Playing/Progress` when the user's Jellyfin
|
||||
/// remember-selection settings are enabled. When false, in-player switching
|
||||
/// still works but choices don't follow the user across devices.
|
||||
final bool trackPreferencePersistence;
|
||||
|
||||
/// Multi-endpoint connection model with endpoint racing/failover. Plex gets
|
||||
/// local/remote/relay candidates from plex.tv; Jellyfin uses user-entered
|
||||
/// URLs for the same server.
|
||||
final bool endpointFailover;
|
||||
|
||||
/// Watch progress can be queued offline and replayed when reconnected
|
||||
/// ([OfflineWatchSyncService]). Jellyfin reports inline only today.
|
||||
final bool offlineWatchQueue;
|
||||
|
||||
/// Discord rich-presence integration. Plex-only because the RPC payload
|
||||
/// uses Plex-shaped session/metadata.
|
||||
final bool discordRpc;
|
||||
|
||||
/// Server exposes metadata edit endpoints. Hides edit affordances when false.
|
||||
final bool richMetadataEdit;
|
||||
|
||||
/// How the alpha-jump bar should behave for this backend's libraries.
|
||||
final AlphaBarMode alphaBar;
|
||||
|
||||
/// Server can supply thumbnails for the player's seek-bar scrub preview.
|
||||
/// Plex serves them as a `.bif` asset; Jellyfin uses `/Trickplay` sprite
|
||||
/// sheets. Both backends are wired through [ScrubPreviewSource]; the flag
|
||||
@@ -109,73 +56,40 @@ class ServerCapabilities {
|
||||
/// `/Items?ParentId=...&Recursive=false` queries.
|
||||
final bool folderGrouping;
|
||||
|
||||
/// Server can supply track lyrics. Jellyfin exposes `/Audio/{id}/Lyrics`;
|
||||
/// Plex surfaces sidecar `.lrc`/`.txt` files as track streams
|
||||
/// (`streamType 4`) fetched via `/library/streams/{id}`. Gates the lyrics
|
||||
/// affordance in the music player; per-track absence is the runtime gate.
|
||||
final bool lyrics;
|
||||
|
||||
/// Server can build an "instant mix" / radio track list from a seed item.
|
||||
/// Jellyfin: `/Items/{id}/InstantMix`; Plex: station play queues
|
||||
/// (`POST /playQueues?type=audio&uri=...station...`).
|
||||
final bool instantMix;
|
||||
|
||||
/// Server can transcode audio to a capped bitrate. Plex:
|
||||
/// `/music/:/transcode/universal`; Jellyfin: `PlaybackInfo` with an audio
|
||||
/// `TranscodingProfile`. Gates the music quality picker (vs original-only).
|
||||
final bool audioTranscoding;
|
||||
|
||||
const ServerCapabilities({
|
||||
this.serverSidePlayQueue = false,
|
||||
this.serverSidePlaylists = false,
|
||||
this.liveTv = false,
|
||||
this.liveTvDvr = false,
|
||||
this.subtitleSearch = false,
|
||||
this.videoTranscoding = true,
|
||||
this.serverSideSync = false,
|
||||
this.richHubs = false,
|
||||
this.numericUserRating = false,
|
||||
this.userFavorites = false,
|
||||
this.continueWatchingRemoval = false,
|
||||
this.externalSubtitleSearch = false,
|
||||
this.trackPreferencePersistence = false,
|
||||
this.endpointFailover = false,
|
||||
this.offlineWatchQueue = false,
|
||||
this.discordRpc = false,
|
||||
this.richMetadataEdit = false,
|
||||
this.alphaBar = AlphaBarMode.none,
|
||||
this.scrubThumbnails = false,
|
||||
this.folderGrouping = false,
|
||||
this.lyrics = false,
|
||||
this.instantMix = false,
|
||||
this.audioTranscoding = false,
|
||||
});
|
||||
|
||||
/// Defaults for a fully-featured Plex server.
|
||||
static const ServerCapabilities plex = ServerCapabilities(
|
||||
serverSidePlayQueue: true,
|
||||
serverSidePlaylists: true,
|
||||
liveTv: true,
|
||||
liveTvDvr: true,
|
||||
subtitleSearch: true,
|
||||
videoTranscoding: true,
|
||||
serverSideSync: true,
|
||||
richHubs: true,
|
||||
numericUserRating: true,
|
||||
userFavorites: false,
|
||||
continueWatchingRemoval: true,
|
||||
externalSubtitleSearch: true,
|
||||
trackPreferencePersistence: true,
|
||||
endpointFailover: true,
|
||||
offlineWatchQueue: true,
|
||||
discordRpc: true,
|
||||
richMetadataEdit: true,
|
||||
alphaBar: AlphaBarMode.scrollSnap,
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
lyrics: true,
|
||||
instantMix: true,
|
||||
audioTranscoding: true,
|
||||
);
|
||||
|
||||
/// Defaults for a Jellyfin server.
|
||||
@@ -188,79 +102,37 @@ class ServerCapabilities {
|
||||
/// `/LiveTv/Programs`. Detection + channel listing are wired today;
|
||||
/// EPG and tuning are follow-ups.
|
||||
static const ServerCapabilities jellyfin = ServerCapabilities(
|
||||
serverSidePlayQueue: false,
|
||||
serverSidePlaylists: true,
|
||||
liveTv: true,
|
||||
liveTvDvr: false,
|
||||
subtitleSearch: false,
|
||||
videoTranscoding: true,
|
||||
serverSideSync: false,
|
||||
richHubs: false,
|
||||
numericUserRating: false,
|
||||
userFavorites: true,
|
||||
externalSubtitleSearch: false,
|
||||
trackPreferencePersistence: true,
|
||||
endpointFailover: true,
|
||||
offlineWatchQueue: false,
|
||||
discordRpc: false,
|
||||
richMetadataEdit: true,
|
||||
alphaBar: AlphaBarMode.nameStartsWithFilter,
|
||||
scrubThumbnails: true,
|
||||
folderGrouping: true,
|
||||
lyrics: true,
|
||||
instantMix: true,
|
||||
audioTranscoding: true,
|
||||
);
|
||||
|
||||
ServerCapabilities copyWith({
|
||||
bool? serverSidePlayQueue,
|
||||
bool? serverSidePlaylists,
|
||||
bool? liveTv,
|
||||
bool? liveTvDvr,
|
||||
bool? subtitleSearch,
|
||||
bool? videoTranscoding,
|
||||
bool? serverSideSync,
|
||||
bool? richHubs,
|
||||
bool? numericUserRating,
|
||||
bool? userFavorites,
|
||||
bool? continueWatchingRemoval,
|
||||
bool? externalSubtitleSearch,
|
||||
bool? trackPreferencePersistence,
|
||||
bool? endpointFailover,
|
||||
bool? offlineWatchQueue,
|
||||
bool? discordRpc,
|
||||
bool? richMetadataEdit,
|
||||
AlphaBarMode? alphaBar,
|
||||
bool? scrubThumbnails,
|
||||
bool? folderGrouping,
|
||||
bool? lyrics,
|
||||
bool? instantMix,
|
||||
bool? audioTranscoding,
|
||||
}) {
|
||||
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
|
||||
/// which Plex probes per server (`PlexClient.capabilities`) — so that is the
|
||||
/// only override this type needs. Widen the parameter list if another flag
|
||||
/// ever becomes a runtime probe.
|
||||
ServerCapabilities copyWith({bool? videoTranscoding}) {
|
||||
return ServerCapabilities(
|
||||
serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue,
|
||||
serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists,
|
||||
liveTv: liveTv ?? this.liveTv,
|
||||
liveTvDvr: liveTvDvr ?? this.liveTvDvr,
|
||||
subtitleSearch: subtitleSearch ?? this.subtitleSearch,
|
||||
liveTv: liveTv,
|
||||
liveTvDvr: liveTvDvr,
|
||||
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
|
||||
serverSideSync: serverSideSync ?? this.serverSideSync,
|
||||
richHubs: richHubs ?? this.richHubs,
|
||||
numericUserRating: numericUserRating ?? this.numericUserRating,
|
||||
userFavorites: userFavorites ?? this.userFavorites,
|
||||
continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval,
|
||||
externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch,
|
||||
trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence,
|
||||
endpointFailover: endpointFailover ?? this.endpointFailover,
|
||||
offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue,
|
||||
discordRpc: discordRpc ?? this.discordRpc,
|
||||
richMetadataEdit: richMetadataEdit ?? this.richMetadataEdit,
|
||||
alphaBar: alphaBar ?? this.alphaBar,
|
||||
scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails,
|
||||
folderGrouping: folderGrouping ?? this.folderGrouping,
|
||||
lyrics: lyrics ?? this.lyrics,
|
||||
instantMix: instantMix ?? this.instantMix,
|
||||
audioTranscoding: audioTranscoding ?? this.audioTranscoding,
|
||||
richHubs: richHubs,
|
||||
numericUserRating: numericUserRating,
|
||||
userFavorites: userFavorites,
|
||||
continueWatchingRemoval: continueWatchingRemoval,
|
||||
externalSubtitleSearch: externalSubtitleSearch,
|
||||
richMetadataEdit: richMetadataEdit,
|
||||
scrubThumbnails: scrubThumbnails,
|
||||
folderGrouping: folderGrouping,
|
||||
instantMix: instantMix,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../services/jellyfin_client.dart';
|
||||
import '../utils/jellyfin_time.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import 'metadata_edit_models.dart';
|
||||
|
||||
class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
@@ -39,7 +38,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
||||
final kind = draft.sourceItem.kind;
|
||||
return [
|
||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)),
|
||||
MetadataEditSection(
|
||||
id: 'basic',
|
||||
title: t.metadataEdit.basicInfo,
|
||||
fields: metadataBasicFields(kind, studioType: MetadataEditFieldType.stringList),
|
||||
),
|
||||
if (_tagFields(kind).isNotEmpty)
|
||||
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
||||
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
||||
@@ -53,11 +56,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
final dto = Map<String, dynamic>.from(raw);
|
||||
|
||||
dto['ProviderIds'] = _stringMap(dto['ProviderIds']);
|
||||
dto['Tags'] = _stringList(dto['Tags']);
|
||||
dto['Genres'] = _stringList(dto['Genres']);
|
||||
dto['Tags'] = metadataStringList(dto['Tags']);
|
||||
dto['Genres'] = metadataStringList(dto['Genres']);
|
||||
dto['People'] = _mapList(dto['People']);
|
||||
dto['Studios'] = _mapList(dto['Studios']);
|
||||
dto['LockedFields'] = _stringList(dto['LockedFields']);
|
||||
dto['LockedFields'] = metadataStringList(dto['LockedFields']);
|
||||
dto['LockData'] = dto['LockData'] == true;
|
||||
dto.remove('Trickplay');
|
||||
|
||||
@@ -71,29 +74,29 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
final value = draft.value<String>('originallyAvailableAt') ?? '';
|
||||
dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']);
|
||||
}
|
||||
if (_fieldChanged(draft, 'studio')) {
|
||||
if (_listFieldChanged(draft, 'studio')) {
|
||||
dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio']));
|
||||
}
|
||||
if (draft.fieldChanged('tagline')) {
|
||||
final tagline = metadataEmptyToNull(draft.value<String>('tagline'));
|
||||
final existing = _stringList(dto['Taglines']);
|
||||
final existing = metadataStringList(dto['Taglines']);
|
||||
dto['Taglines'] = tagline == null ? <String>[] : <String>[tagline, ...existing.skip(1)];
|
||||
}
|
||||
if (_fieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']);
|
||||
if (_fieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']);
|
||||
if (_fieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']);
|
||||
if (_listFieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']);
|
||||
if (_listFieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']);
|
||||
if (_listFieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']);
|
||||
|
||||
var peopleChanged = false;
|
||||
var people = _mapList(dto['People']);
|
||||
if (_fieldChanged(draft, 'director')) {
|
||||
if (_listFieldChanged(draft, 'director')) {
|
||||
people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director']));
|
||||
peopleChanged = true;
|
||||
}
|
||||
if (_fieldChanged(draft, 'writer')) {
|
||||
if (_listFieldChanged(draft, 'writer')) {
|
||||
people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer']));
|
||||
peopleChanged = true;
|
||||
}
|
||||
if (_fieldChanged(draft, 'producer')) {
|
||||
if (_listFieldChanged(draft, 'producer')) {
|
||||
people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer']));
|
||||
peopleChanged = true;
|
||||
}
|
||||
@@ -132,11 +135,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
||||
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
||||
final imageType = field.artwork?.key;
|
||||
@@ -180,12 +178,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
? metadataFirstString(raw['Taglines'])
|
||||
: item.tagline ?? '';
|
||||
values['summary'] = raw['Overview'] as String? ?? item.summary ?? '';
|
||||
values['genre'] = _stringList(raw['Genres']);
|
||||
values['genre'] = metadataStringList(raw['Genres']);
|
||||
values['director'] = _peopleByType(raw['People'], 'Director');
|
||||
values['writer'] = _peopleByType(raw['People'], 'Writer');
|
||||
values['producer'] = _peopleByType(raw['People'], 'Producer');
|
||||
values['country'] = _stringList(raw['ProductionLocations']);
|
||||
values['label'] = _stringList(raw['Tags']);
|
||||
values['country'] = metadataStringList(raw['ProductionLocations']);
|
||||
values['label'] = metadataStringList(raw['Tags']);
|
||||
}
|
||||
|
||||
void _writeArtworkValues(Map<String, Object?> values, MediaItem item) {
|
||||
@@ -194,29 +192,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
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) {
|
||||
MetadataEditField tag(String id, String label) =>
|
||||
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
||||
@@ -234,99 +209,19 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) {
|
||||
final fields = <MetadataEditField>[
|
||||
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
||||
kind == MediaKind.episode
|
||||
? _artworkField(
|
||||
'Primary',
|
||||
t.metadataEdit.poster,
|
||||
t.metadataEdit.selectPoster,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.thumb,
|
||||
)
|
||||
: _artworkField('Primary', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
||||
];
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'Backdrop',
|
||||
t.metadataEdit.background,
|
||||
t.metadataEdit.selectBackground,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.art,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show) {
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'Logo',
|
||||
t.metadataEdit.logo,
|
||||
t.metadataEdit.selectLogo,
|
||||
80,
|
||||
32,
|
||||
2,
|
||||
2.5,
|
||||
fit: MetadataArtworkFit.contain,
|
||||
imageType: ImageType.logo,
|
||||
),
|
||||
);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
MetadataEditField _artworkField(
|
||||
String key,
|
||||
String label,
|
||||
String title,
|
||||
double width,
|
||||
double height,
|
||||
int columns,
|
||||
double aspectRatio, {
|
||||
MetadataArtworkFit fit = MetadataArtworkFit.cover,
|
||||
ImageType imageType = ImageType.poster,
|
||||
}) {
|
||||
return MetadataEditField(
|
||||
id: 'artwork:$key',
|
||||
label: label,
|
||||
type: MetadataEditFieldType.artwork,
|
||||
saveMode: MetadataEditSaveMode.immediate,
|
||||
artwork: MetadataArtworkConfig(
|
||||
key: key,
|
||||
selectTitle: title,
|
||||
previewWidth: width,
|
||||
previewHeight: height,
|
||||
gridColumns: columns,
|
||||
gridAspectRatio: aspectRatio,
|
||||
fit: fit,
|
||||
imageType: imageType,
|
||||
),
|
||||
);
|
||||
}
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) =>
|
||||
metadataArtworkFields(kind, posterKey: 'Primary', backdropKey: 'Backdrop', logoKey: 'Logo');
|
||||
|
||||
void _setChangedString(Map<String, dynamic> dto, MetadataEditDraft draft, String fieldId, String dtoKey) {
|
||||
if (!draft.fieldChanged(fieldId)) return;
|
||||
dto[dtoKey] = metadataEmptyToNull(draft.value<String>(fieldId));
|
||||
}
|
||||
|
||||
bool _fieldChanged(MetadataEditDraft draft, String fieldId) {
|
||||
for (final section in schemaFor(draft)) {
|
||||
for (final field in section.fields) {
|
||||
if (field.id == fieldId) return metadataEditFieldChanged(draft, field);
|
||||
/// Every id passed here names a `stringList` field, so the comparison is
|
||||
/// order-insensitive regardless of which kind's schema is in play.
|
||||
bool _listFieldChanged(MetadataEditDraft draft, String fieldId) =>
|
||||
!metadataEditStringListEquals(draft.values[fieldId], draft.originalValues[fieldId]);
|
||||
}
|
||||
}
|
||||
return draft.fieldChanged(fieldId);
|
||||
}
|
||||
}
|
||||
|
||||
List<String> _stringList(Object? value) => metadataStringList(value);
|
||||
|
||||
Map<String, String> _stringMap(Object? value) {
|
||||
if (value is! Map) return <String, String>{};
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_backend.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
@@ -147,7 +148,9 @@ abstract class MetadataEditAdapter {
|
||||
|
||||
Future<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);
|
||||
|
||||
@@ -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) {
|
||||
if (identical(a, b)) return true;
|
||||
if (a is List && b is List) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import '../media/media_server_client.dart';
|
||||
import '../services/plex_client.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/language_codes.dart';
|
||||
import '../utils/media_image_helper.dart';
|
||||
import 'metadata_edit_models.dart';
|
||||
|
||||
class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
@@ -60,7 +59,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
||||
final kind = draft.sourceItem.kind;
|
||||
return [
|
||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)),
|
||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: metadataBasicFields(kind)),
|
||||
if (_tagFields(kind).isNotEmpty)
|
||||
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
||||
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
||||
@@ -133,11 +132,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
||||
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
||||
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) {
|
||||
MetadataEditField tag(String id, String label) =>
|
||||
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
||||
@@ -267,94 +238,14 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||
};
|
||||
}
|
||||
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) {
|
||||
final fields = <MetadataEditField>[
|
||||
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
||||
kind == MediaKind.episode
|
||||
? _artworkField(
|
||||
'posters',
|
||||
t.metadataEdit.poster,
|
||||
t.metadataEdit.selectPoster,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.thumb,
|
||||
)
|
||||
: _artworkField('posters', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
||||
];
|
||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
||||
fields.add(
|
||||
_artworkField(
|
||||
'arts',
|
||||
t.metadataEdit.background,
|
||||
t.metadataEdit.selectBackground,
|
||||
80,
|
||||
45,
|
||||
2,
|
||||
16 / 9,
|
||||
imageType: ImageType.art,
|
||||
),
|
||||
List<MetadataEditField> _artworkFields(MediaKind kind) => metadataArtworkFields(
|
||||
kind,
|
||||
posterKey: 'posters',
|
||||
backdropKey: 'arts',
|
||||
logoKey: 'clearLogos',
|
||||
squareKey: 'squareArts',
|
||||
logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection},
|
||||
);
|
||||
}
|
||||
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) {
|
||||
final fields = <MetadataEditField>[];
|
||||
|
||||
@@ -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
|
||||
/// pending search to run now.
|
||||
void handleSearchSubmit() {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import 'event_aware.dart';
|
||||
import 'watch_state_aware.dart';
|
||||
|
||||
/// Mixin for screens that need to react to deletion events.
|
||||
///
|
||||
@@ -74,3 +75,21 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -104,43 +104,6 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
||||
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)
|
||||
/// with [buffer] extra indices on each side. Serialized — only one
|
||||
/// range-fetch runs at a time — and re-checks after each success so a
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -34,8 +34,6 @@ sealed class DownloadProgress with _$DownloadProgress {
|
||||
double get progressPercent => progress / 100.0;
|
||||
|
||||
String get speedFormatted => ByteFormatter.formatSpeed(speed);
|
||||
String get downloadedFormatted => ByteFormatter.formatBytes(downloadedBytes);
|
||||
String get totalFormatted => ByteFormatter.formatBytes(totalBytes);
|
||||
|
||||
bool get hasArtworkPaths => thumbPath != null;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
||||
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'mixins/multi_server_fields.dart';
|
||||
|
||||
part 'livetv_channel.g.dart';
|
||||
|
||||
@@ -49,7 +48,7 @@ List<LiveTvChannel> filterLiveTvChannelsForFavorites({
|
||||
}
|
||||
|
||||
@JsonSerializable(createToJson: false)
|
||||
class LiveTvChannel with MultiServerFields {
|
||||
class LiveTvChannel {
|
||||
@JsonKey(readValue: _readChannelKey)
|
||||
final String key;
|
||||
@JsonKey(readValue: _readChannelIdentifier)
|
||||
@@ -68,10 +67,8 @@ class LiveTvChannel with MultiServerFields {
|
||||
@JsonKey(fromJson: flexibleBool)
|
||||
final bool? drm;
|
||||
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverId;
|
||||
@override
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
final String? serverName;
|
||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||
|
||||
@@ -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});
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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?,
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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 {},
|
||||
});
|
||||
}
|
||||
@@ -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']),
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -37,18 +37,4 @@ class PlexHome {
|
||||
Map<String, dynamic> toJson() => _$PlexHomeToJson(this);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -10,8 +10,7 @@ part 'plex_user_profile.g.dart';
|
||||
///
|
||||
/// Every field parses tolerantly: the account API drifts (~July 2026 the
|
||||
/// language-list fields switched from arrays to CSV strings, #1488), and a
|
||||
/// profile blob must never fail to parse — token minting embeds it (see
|
||||
/// UserSwitchResponse.fromJson).
|
||||
/// single drifted field must never sink the whole profile.
|
||||
@JsonSerializable()
|
||||
class PlexUserProfile implements MediaServerUserProfile {
|
||||
@JsonKey(fromJson: _boolOrTrue)
|
||||
@@ -62,19 +61,6 @@ class PlexUserProfile implements MediaServerUserProfile {
|
||||
this.mediaReviewsLanguages,
|
||||
});
|
||||
|
||||
/// Neutral fallback matching the generated defaults — used when the account
|
||||
/// API returns a profile blob that cannot be parsed at all (schema drift
|
||||
/// must never break token minting, see UserSwitchResponse.fromJson).
|
||||
factory PlexUserProfile.defaults() => PlexUserProfile(
|
||||
autoSelectAudio: true,
|
||||
defaultAudioAccessibility: 0,
|
||||
autoSelectSubtitle: 0,
|
||||
defaultSubtitleAccessibility: 0,
|
||||
defaultSubtitleForced: 1,
|
||||
watchedIndicator: 1,
|
||||
mediaReviewsVisibility: 0,
|
||||
);
|
||||
|
||||
factory PlexUserProfile.fromJson(Map<String, dynamic> json) {
|
||||
final envelope = json['profile'];
|
||||
final profile = envelope is Map<String, dynamic> ? envelope : json;
|
||||
|
||||
@@ -26,6 +26,4 @@ class PlexVideoPlaybackData {
|
||||
});
|
||||
|
||||
bool get hasValidVideoUrl => videoUrl != null && videoUrl!.isNotEmpty;
|
||||
|
||||
bool get hasMediaInfo => mediaInfo != null;
|
||||
}
|
||||
|
||||
@@ -4,111 +4,18 @@ import 'seerr_media.dart';
|
||||
|
||||
part 'seerr_details.g.dart';
|
||||
|
||||
/// Full movie detail from `GET /movie/{tmdbId}` — the subset the catalog
|
||||
/// surfaces need (credits, external ids, availability, air status).
|
||||
/// Full detail from `GET /movie/{tmdbId}` and `GET /tv/{tmdbId}` — the subset
|
||||
/// the catalog surfaces need (credits, availability, seasons). `seasons` is
|
||||
/// absent on movies.
|
||||
@JsonSerializable(createToJson: false)
|
||||
class SeerrMovieDetails {
|
||||
final int id;
|
||||
final String? title;
|
||||
final String? overview;
|
||||
final String? posterPath;
|
||||
final String? backdropPath;
|
||||
final String? releaseDate;
|
||||
|
||||
/// Minutes.
|
||||
final int? runtime;
|
||||
|
||||
/// `Released` / `In Production` / `Post Production` / `Planned` /
|
||||
/// `Canceled` / `Rumored`.
|
||||
final String? status;
|
||||
final double? voteAverage;
|
||||
final int? voteCount;
|
||||
final List<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;
|
||||
class SeerrDetails {
|
||||
final List<SeerrSeason>? seasons;
|
||||
final SeerrCredits? credits;
|
||||
final SeerrExternalIds? externalIds;
|
||||
final SeerrMediaInfo? mediaInfo;
|
||||
|
||||
const SeerrTvDetails({
|
||||
required this.id,
|
||||
this.name,
|
||||
this.overview,
|
||||
this.posterPath,
|
||||
this.backdropPath,
|
||||
this.firstAirDate,
|
||||
this.episodeRunTime,
|
||||
this.status,
|
||||
this.voteAverage,
|
||||
this.voteCount,
|
||||
this.genres,
|
||||
this.networks,
|
||||
this.numberOfEpisodes,
|
||||
this.numberOfSeasons,
|
||||
this.seasons,
|
||||
this.credits,
|
||||
this.externalIds,
|
||||
this.mediaInfo,
|
||||
});
|
||||
const SeerrDetails({this.seasons, this.credits, this.mediaInfo});
|
||||
|
||||
factory SeerrTvDetails.fromJson(Map<String, dynamic> json) => _$SeerrTvDetailsFromJson(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);
|
||||
factory SeerrDetails.fromJson(Map<String, dynamic> json) => _$SeerrDetailsFromJson(json);
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
|
||||
@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);
|
||||
}
|
||||
|
||||
@@ -6,78 +6,18 @@ part of 'seerr_details.dart';
|
||||
// JsonSerializableGenerator
|
||||
// **************************************************************************
|
||||
|
||||
SeerrMovieDetails _$SeerrMovieDetailsFromJson(Map<String, dynamic> json) =>
|
||||
SeerrMovieDetails(
|
||||
id: (json['id'] as num).toInt(),
|
||||
title: json['title'] as String?,
|
||||
overview: json['overview'] as String?,
|
||||
posterPath: json['posterPath'] as String?,
|
||||
backdropPath: json['backdropPath'] as String?,
|
||||
releaseDate: json['releaseDate'] as String?,
|
||||
runtime: (json['runtime'] as num?)?.toInt(),
|
||||
status: json['status'] as String?,
|
||||
voteAverage: (json['voteAverage'] as num?)?.toDouble(),
|
||||
voteCount: (json['voteCount'] as num?)?.toInt(),
|
||||
genres: (json['genres'] as List<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(),
|
||||
SeerrDetails _$SeerrDetailsFromJson(Map<String, dynamic> json) => SeerrDetails(
|
||||
seasons: (json['seasons'] as List<dynamic>?)
|
||||
?.map((e) => SeerrSeason.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>),
|
||||
);
|
||||
|
||||
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(
|
||||
seasonNumber: (json['seasonNumber'] as num).toInt(),
|
||||
name: json['name'] as String?,
|
||||
@@ -97,9 +37,3 @@ SeerrCastMember _$SeerrCastMemberFromJson(Map<String, dynamic> json) =>
|
||||
character: json['character'] 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(),
|
||||
);
|
||||
|
||||
@@ -9,22 +9,35 @@ enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom }
|
||||
/// ArtCNN real-time model sizes.
|
||||
enum ArtCNNModel {
|
||||
/// Lightweight real-time model
|
||||
c4f16,
|
||||
c4f16('C4F16'),
|
||||
|
||||
/// Higher-quality real-time model
|
||||
c4f32,
|
||||
c4f32('C4F32');
|
||||
|
||||
const ArtCNNModel(this.label);
|
||||
|
||||
/// Display label for the model.
|
||||
final String label;
|
||||
}
|
||||
|
||||
/// ArtCNN luma doubler variants.
|
||||
enum ArtCNNVariant {
|
||||
/// Neutral luma doubler
|
||||
neutral,
|
||||
neutral('Neutral', 'neutral'),
|
||||
|
||||
/// Denoise and soften
|
||||
denoise,
|
||||
denoise('Denoise', 'dn'),
|
||||
|
||||
/// Denoise and sharpen
|
||||
denoiseSharpen,
|
||||
denoiseSharpen('Denoise + Sharpen', 'ds');
|
||||
|
||||
const ArtCNNVariant(this.label, this.slug);
|
||||
|
||||
/// Display label for the variant.
|
||||
final String label;
|
||||
|
||||
/// Stable slug used in built-in preset ids and shader asset keys.
|
||||
final String slug;
|
||||
}
|
||||
|
||||
/// Quality tiers for Anime4K presets
|
||||
@@ -39,22 +52,27 @@ enum Anime4KQuality {
|
||||
/// Anime4K modes that define shader combinations
|
||||
enum Anime4KMode {
|
||||
/// Mode A: Clamp + Restore
|
||||
modeA,
|
||||
modeA('A'),
|
||||
|
||||
/// Mode B: Clamp + Restore + Upscale + Downscale
|
||||
modeB,
|
||||
modeB('B'),
|
||||
|
||||
/// Mode C: Clamp + Upscale + Downscale
|
||||
modeC,
|
||||
modeC('C'),
|
||||
|
||||
/// Mode A+A: Clamp + Restore + Restore
|
||||
modeAA,
|
||||
modeAA('A+A'),
|
||||
|
||||
/// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale
|
||||
modeBB,
|
||||
modeBB('B+B'),
|
||||
|
||||
/// Mode C+A: Clamp + Upscale + Restore + Downscale
|
||||
modeCA,
|
||||
modeCA('C+A');
|
||||
|
||||
const Anime4KMode(this.label);
|
||||
|
||||
/// Display label for the mode.
|
||||
final String label;
|
||||
}
|
||||
|
||||
@freezed
|
||||
@@ -120,93 +138,28 @@ class ShaderPreset {
|
||||
);
|
||||
|
||||
/// Create an ArtCNN preset with the specified model and variant
|
||||
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) {
|
||||
final modelName = _getArtCNNModelName(model);
|
||||
final variantName = _getArtCNNVariantName(variant);
|
||||
final variantId = _getArtCNNVariantId(variant);
|
||||
|
||||
return ShaderPreset(
|
||||
id: 'artcnn_${model.name}_$variantId',
|
||||
name: variant == ArtCNNVariant.neutral ? 'ArtCNN $modelName' : 'ArtCNN $modelName $variantName',
|
||||
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) => ShaderPreset(
|
||||
id: 'artcnn_${model.name}_${variant.slug}',
|
||||
name: variant == ArtCNNVariant.neutral ? 'ArtCNN ${model.label}' : 'ArtCNN ${model.label} ${variant.label}',
|
||||
type: ShaderPresetType.artcnn,
|
||||
artcnnConfig: ArtCNNConfig(model: model, variant: variant),
|
||||
);
|
||||
}
|
||||
|
||||
/// Create an Anime4K preset with the specified quality and mode
|
||||
static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) {
|
||||
final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ';
|
||||
final modeName = _getModeName(mode);
|
||||
|
||||
return ShaderPreset(
|
||||
id: 'anime4k_${quality.name}_${mode.name}',
|
||||
name: 'Anime4K $qualityName $modeName',
|
||||
name: 'Anime4K $qualityName ${mode.label}',
|
||||
type: ShaderPresetType.anime4k,
|
||||
anime4kConfig: Anime4KConfig(quality: quality, mode: mode),
|
||||
);
|
||||
}
|
||||
|
||||
static String _getModeName(Anime4KMode mode) {
|
||||
switch (mode) {
|
||||
case Anime4KMode.modeA:
|
||||
return 'A';
|
||||
case Anime4KMode.modeB:
|
||||
return 'B';
|
||||
case Anime4KMode.modeC:
|
||||
return 'C';
|
||||
case Anime4KMode.modeAA:
|
||||
return 'A+A';
|
||||
case Anime4KMode.modeBB:
|
||||
return 'B+B';
|
||||
case Anime4KMode.modeCA:
|
||||
return 'C+A';
|
||||
}
|
||||
}
|
||||
String get modeDisplayName => anime4kConfig?.mode.label ?? '';
|
||||
|
||||
static String _getArtCNNModelName(ArtCNNModel model) {
|
||||
switch (model) {
|
||||
case ArtCNNModel.c4f16:
|
||||
return 'C4F16';
|
||||
case ArtCNNModel.c4f32:
|
||||
return 'C4F32';
|
||||
}
|
||||
}
|
||||
|
||||
static String _getArtCNNVariantName(ArtCNNVariant variant) {
|
||||
switch (variant) {
|
||||
case ArtCNNVariant.neutral:
|
||||
return 'Neutral';
|
||||
case ArtCNNVariant.denoise:
|
||||
return 'Denoise';
|
||||
case ArtCNNVariant.denoiseSharpen:
|
||||
return 'Denoise + Sharpen';
|
||||
}
|
||||
}
|
||||
|
||||
static String _getArtCNNVariantId(ArtCNNVariant variant) {
|
||||
switch (variant) {
|
||||
case ArtCNNVariant.neutral:
|
||||
return 'neutral';
|
||||
case ArtCNNVariant.denoise:
|
||||
return 'dn';
|
||||
case ArtCNNVariant.denoiseSharpen:
|
||||
return 'ds';
|
||||
}
|
||||
}
|
||||
|
||||
String get modeDisplayName {
|
||||
if (anime4kConfig != null) {
|
||||
return _getModeName(anime4kConfig!.mode);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
String get artcnnModelDisplayName {
|
||||
if (artcnnConfig != null) {
|
||||
return _getArtCNNModelName(artcnnConfig!.model);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
String get artcnnModelDisplayName => artcnnConfig?.model.label ?? '';
|
||||
|
||||
static final List<ShaderPreset> _builtInPresets = List.unmodifiable([
|
||||
none,
|
||||
|
||||
@@ -41,11 +41,12 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
|
||||
},
|
||||
};
|
||||
|
||||
/// Build a `POST /sync/history` body that adds this item to history.
|
||||
/// Build a `POST /sync/history[/remove]` body for this item. Both endpoints
|
||||
/// take the same shape; only the removal path ignores [watchedAt].
|
||||
///
|
||||
/// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
|
||||
/// to a specific point in time; defaults to "now" on Trakt's side.
|
||||
Map<String, dynamic> toHistoryAddBody({String? watchedAt}) => switch (this) {
|
||||
Map<String, dynamic> toHistoryBody({String? watchedAt}) => switch (this) {
|
||||
TraktScrobbleMovieRequest(:final ids) => {
|
||||
'movies': [
|
||||
{'watched_at': ?watchedAt, 'ids': ids.toJson()},
|
||||
@@ -67,28 +68,4 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
/// Build a `POST /sync/history/remove` body that removes this item from history.
|
||||
Map<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},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
Future<void> open(
|
||||
Media media, {
|
||||
@@ -279,15 +294,10 @@ class PlayerAndroid extends PlayerBase {
|
||||
break;
|
||||
case 'dv-conversion-mode':
|
||||
_dvConversionMode = value;
|
||||
final initFuture = _initFuture;
|
||||
if (initialized) {
|
||||
await invoke('setDvConversionMode', {'mode': value});
|
||||
} else if (initFuture != null) {
|
||||
await initFuture;
|
||||
if (!disposed && initialized && _dvConversionMode == value) {
|
||||
await invoke('setDvConversionMode', {'mode': value});
|
||||
}
|
||||
}
|
||||
await _applyWhenInitialized(
|
||||
() => invoke('setDvConversionMode', {'mode': value}),
|
||||
() => _dvConversionMode == value,
|
||||
);
|
||||
break;
|
||||
case 'sub-visibility':
|
||||
if (value == 'no') {
|
||||
@@ -316,15 +326,10 @@ class PlayerAndroid extends PlayerBase {
|
||||
Future<void> setAudioNormalization(bool enabled) async {
|
||||
if (disposed) return;
|
||||
_audioNormalizationEnabled = enabled;
|
||||
final initFuture = _initFuture;
|
||||
if (initialized) {
|
||||
await invoke('setAudioNormalization', {'enabled': enabled});
|
||||
} else if (initFuture != null) {
|
||||
await initFuture;
|
||||
if (!disposed && initialized && _audioNormalizationEnabled == enabled) {
|
||||
await invoke('setAudioNormalization', {'enabled': enabled});
|
||||
}
|
||||
}
|
||||
await _applyWhenInitialized(
|
||||
() => invoke('setAudioNormalization', {'enabled': enabled}),
|
||||
() => _audioNormalizationEnabled == enabled,
|
||||
);
|
||||
// Keep the mpv af property flowing through setMpvProperty so the plugin's
|
||||
// pendingMpvProperties replay applies loudnorm if exo falls back to mpv.
|
||||
await super.setAudioNormalization(enabled);
|
||||
@@ -336,21 +341,10 @@ class PlayerAndroid extends PlayerBase {
|
||||
_downmixEnabled = enabled;
|
||||
_downmixCenterBoostDb = centerBoostDb;
|
||||
_downmixNormalize = normalize;
|
||||
Future<void> invokeNative() =>
|
||||
invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize});
|
||||
final initFuture = _initFuture;
|
||||
if (initialized) {
|
||||
await invokeNative();
|
||||
} else if (initFuture != null) {
|
||||
await initFuture;
|
||||
if (!disposed &&
|
||||
initialized &&
|
||||
_downmixEnabled == enabled &&
|
||||
_downmixCenterBoostDb == centerBoostDb &&
|
||||
_downmixNormalize == normalize) {
|
||||
await invokeNative();
|
||||
}
|
||||
}
|
||||
await _applyWhenInitialized(
|
||||
() => invoke('setAudioDownmix', {'enabled': enabled, 'centerBoostDb': centerBoostDb, 'normalize': normalize}),
|
||||
() => _downmixEnabled == enabled && _downmixCenterBoostDb == centerBoostDb && _downmixNormalize == normalize,
|
||||
);
|
||||
// Keep the mpv properties flowing through setMpvProperty so the plugin's
|
||||
// pendingMpvProperties replay applies downmix if exo falls back to mpv.
|
||||
await super.setAudioDownmix(enabled: enabled, centerBoostDb: centerBoostDb, normalize: normalize);
|
||||
@@ -360,15 +354,10 @@ class PlayerAndroid extends PlayerBase {
|
||||
Future<void> setAudioPassthrough(bool enabled) async {
|
||||
if (disposed) return;
|
||||
_audioPassthroughEnabled = enabled;
|
||||
final initFuture = _initFuture;
|
||||
if (initialized) {
|
||||
await invoke('setAudioPassthrough', {'enabled': enabled});
|
||||
} else if (initFuture != null) {
|
||||
await initFuture;
|
||||
if (!disposed && initialized && _audioPassthroughEnabled == enabled) {
|
||||
await invoke('setAudioPassthrough', {'enabled': enabled});
|
||||
}
|
||||
}
|
||||
await _applyWhenInitialized(
|
||||
() => invoke('setAudioPassthrough', {'enabled': enabled}),
|
||||
() => _audioPassthroughEnabled == enabled,
|
||||
);
|
||||
await setProperty('audio-spdif', enabled ? _passthroughCodecs : '');
|
||||
}
|
||||
|
||||
|
||||
@@ -21,12 +21,6 @@ class NavigationTab {
|
||||
return NavigationDestination(icon: AppIcon(icon, fill: 1), selectedIcon: AppIcon(icon, fill: 1), label: getLabel());
|
||||
}
|
||||
|
||||
/// Get the index for a tab ID in the visible tabs list
|
||||
static int indexFor(NavigationTabId id, {required bool isOffline, bool hasLiveTv = false, bool hasExplore = false}) {
|
||||
final tabs = getVisibleTabs(isOffline: isOffline, hasLiveTv: hasLiveTv, hasExplore: hasExplore);
|
||||
return tabs.indexWhere((tab) => tab.id == id);
|
||||
}
|
||||
|
||||
/// Get tabs filtered by offline mode and feature availability
|
||||
static List<NavigationTab> getVisibleTabs({
|
||||
required bool isOffline,
|
||||
|
||||
@@ -113,8 +113,6 @@ class ActiveProfileBinder {
|
||||
final Set<String> _plexHomePreVerified = {};
|
||||
final Set<String> _userInitiatedActivations = {};
|
||||
|
||||
bool get isSwitching => _isSwitching;
|
||||
|
||||
@visibleForTesting
|
||||
String? get debugLastBoundProfileId => _lastBoundProfileId;
|
||||
|
||||
@@ -374,6 +372,11 @@ class ActiveProfileBinder {
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Server ids the profile should reach once bound: its join rows plus the
|
||||
/// implicit Plex Home parent, which normally has no row. Not shared with
|
||||
/// `_serverIdsForProfile` (profile_connection_cleanup.dart) — that one is
|
||||
/// join-rows-only and [ServerId]-typed, while this set keeps growing with
|
||||
/// bind results and is compared against the manager's raw string ids.
|
||||
Set<String> _expectedServerIdsForProfile(
|
||||
Profile profile, {
|
||||
required List<ProfileConnection> joinRows,
|
||||
|
||||
@@ -26,7 +26,7 @@ class PlexHomeService {
|
||||
this._storage,
|
||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||
this._refreshInterval = const Duration(hours: 1),
|
||||
}) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher;
|
||||
}) : _fetchHomeUsers = plexHomeUserFetcher ?? fetchPlexHomeUsers;
|
||||
|
||||
final ConnectionRegistry _connections;
|
||||
final ProfileConnectionRegistry _profileConnections;
|
||||
@@ -498,13 +498,3 @@ class PlexHomeService {
|
||||
_started = false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<List<PlexHomeUser>> _defaultHomeUserFetcher(String accountToken) async {
|
||||
final auth = await PlexAuthService.create();
|
||||
try {
|
||||
final home = await auth.getHomeUsers(accountToken);
|
||||
return home.users;
|
||||
} finally {
|
||||
auth.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +51,8 @@ Future<PlexHomeSwitchResult> switchPlexHomeUserWithPin({
|
||||
if (pin == null) return const PlexHomeSwitchResult._(PlexHomeSwitchStatus.cancelled, null);
|
||||
}
|
||||
try {
|
||||
final response = await auth.switchToUser(homeUserUuid, accountToken, pin: pin);
|
||||
return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, response.authToken);
|
||||
final userToken = await auth.switchToUser(homeUserUuid, accountToken, pin: pin);
|
||||
return PlexHomeSwitchResult._(PlexHomeSwitchStatus.success, userToken);
|
||||
} on MediaServerHttpException catch (e) {
|
||||
if (e.statusCode == 403 && _isInvalidPin(e)) {
|
||||
error = t.profiles.incorrectPinTryAgain;
|
||||
|
||||
@@ -234,6 +234,8 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
|
||||
final connections = context.read<ConnectionRegistry>();
|
||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||
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();
|
||||
PlexAccountConnection? account;
|
||||
for (final c in all) {
|
||||
@@ -248,10 +250,7 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
|
||||
account: account,
|
||||
homeUserUuid: homeUuid,
|
||||
requiresPin: true,
|
||||
promptForPin: ({String? errorMessage}) async {
|
||||
if (!context.mounted) return null;
|
||||
return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
|
||||
},
|
||||
promptForPin: promptForPin,
|
||||
persistTo: pcRegistry,
|
||||
persistProfileId: profile.id,
|
||||
logLabel: profile.displayName,
|
||||
|
||||
@@ -9,65 +9,6 @@ import 'profile_connection_registry.dart';
|
||||
import 'profile_merge.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
|
||||
/// callers can finish failure-prone cleanup before committing join/account
|
||||
/// deletion.
|
||||
@@ -96,6 +37,56 @@ Future<PlexAccountRemoval> planPlexAccountConnectionRemoval({
|
||||
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||
}
|
||||
|
||||
/// Where the session should land after a profile or connection removal.
|
||||
enum PostRemovalRoute { signedOut, staySignedIn }
|
||||
|
||||
/// Removal of profile↔connection join rows and everything they leave
|
||||
/// unreferenced, bound to one set of registries. Every flow resolves the same
|
||||
/// instances from the provider tree, so callers construct this once and call
|
||||
/// through it.
|
||||
class ProfileConnectionCleanup {
|
||||
ProfileConnectionCleanup({
|
||||
required this.profileConnections,
|
||||
required this.connections,
|
||||
required this.storage,
|
||||
this.serverManager,
|
||||
});
|
||||
|
||||
final ProfileConnectionRegistry profileConnections;
|
||||
final ConnectionRegistry connections;
|
||||
final StorageService storage;
|
||||
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,
|
||||
@@ -109,12 +100,8 @@ Future<PlexAccountRemoval> planPlexAccountConnectionRemoval({
|
||||
/// 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> removePlexAccountConnectionAndCleanup({
|
||||
required PlexAccountConnection account,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
Future<PlexAccountRemoval> removePlexAccountConnection(
|
||||
PlexAccountConnection account, {
|
||||
PlexAccountRemoval? plannedRemoval,
|
||||
}) async {
|
||||
final removal =
|
||||
@@ -126,14 +113,7 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
|
||||
// Remove direct join rows first so per-profile pref cleanup observes each
|
||||
// row going away; the FK cascade from the connection delete is then a no-op.
|
||||
for (final row in rows.where((r) => r.connectionId == account.id)) {
|
||||
await removeProfileConnectionAndCleanup(
|
||||
profileId: row.profileId,
|
||||
connection: account,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
await removeProfileConnection(profileId: row.profileId, connection: account);
|
||||
}
|
||||
await connections.remove(account.id);
|
||||
await storage.clearPlexHomeUsersCache(account.id);
|
||||
@@ -141,13 +121,7 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
|
||||
// The account's virtual profiles die with the connection; their borrowed
|
||||
// connections and per-profile prefs must go too.
|
||||
for (final profileId in removedVirtualProfileIds) {
|
||||
await removeAllProfileConnectionsAndCleanup(
|
||||
profileId: profileId,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
await removeAllProfileConnections(profileId);
|
||||
await storage.clearProfileLastUsed(profileId);
|
||||
await storage.clearUserScopedPreferencesForProfile(profileId);
|
||||
}
|
||||
@@ -155,9 +129,6 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
|
||||
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||
}
|
||||
|
||||
/// Where the session should land after a profile or connection removal.
|
||||
enum PostRemovalRoute { signedOut, staySignedIn }
|
||||
|
||||
/// In-session mirror of the boot guard (`main.dart`: "stored connections
|
||||
/// exist but no profiles resolved — returning to auth"): prune orphaned
|
||||
/// Jellyfin connections, then decide whether any selectable profile remains.
|
||||
@@ -165,18 +136,9 @@ enum PostRemovalRoute { signedOut, staySignedIn }
|
||||
/// accounts are harmless because the connection map is re-read here.
|
||||
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
|
||||
required ProfileRegistry profileRegistry,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required Map<String, List<PlexHomeUser>> plexHomeUsers,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
await pruneUnreferencedJellyfinConnections(
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
await pruneUnreferencedJellyfinConnections();
|
||||
final conns = await connections.list();
|
||||
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||
|
||||
@@ -190,60 +152,30 @@ Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalSta
|
||||
return (route: PostRemovalRoute.staySignedIn, profiles: merged);
|
||||
}
|
||||
|
||||
Future<int> pruneUnreferencedJellyfinConnections({
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
Future<int> pruneUnreferencedJellyfinConnections() async {
|
||||
final all = await connections.list();
|
||||
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
|
||||
var removed = 0;
|
||||
|
||||
for (final connection in all.whereType<JellyfinConnection>()) {
|
||||
if (referencedConnectionIds.contains(connection.id)) continue;
|
||||
await _removeJellyfinConnection(
|
||||
connection,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
await _removeJellyfinConnection(connection);
|
||||
removed++;
|
||||
}
|
||||
|
||||
return removed;
|
||||
}
|
||||
|
||||
Future<void> _removeUnreferencedJellyfinConnection(
|
||||
JellyfinConnection connection, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
Future<void> _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async {
|
||||
if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return;
|
||||
await _removeJellyfinConnection(
|
||||
connection,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
storage: storage,
|
||||
serverManager: serverManager,
|
||||
);
|
||||
await _removeJellyfinConnection(connection);
|
||||
}
|
||||
|
||||
Future<void> _removeJellyfinConnection(
|
||||
JellyfinConnection connection, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
MultiServerManager? serverManager,
|
||||
}) async {
|
||||
Future<void> _removeJellyfinConnection(JellyfinConnection connection) async {
|
||||
await connections.remove(connection.id);
|
||||
serverManager?.removeJellyfinConnection(connection);
|
||||
final serverId = ServerId.tryParse(connection.serverMachineId);
|
||||
if (serverId != null &&
|
||||
!await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) {
|
||||
if (serverId != null && !await _isServerReferenced(serverId)) {
|
||||
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
||||
}
|
||||
}
|
||||
@@ -251,26 +183,15 @@ Future<void> _removeJellyfinConnection(
|
||||
Future<void> _clearProfileServerPrefsNoLongerReferenced({
|
||||
required String profileId,
|
||||
required Set<ServerId> removedServerIds,
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
required StorageService storage,
|
||||
required bool clearEverywhereWhenUnreferenced,
|
||||
}) async {
|
||||
if (removedServerIds.isEmpty) return;
|
||||
final remainingProfileServerIds = await _serverIdsForProfile(
|
||||
profileId,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
);
|
||||
final remainingProfileServerIds = await _serverIdsForProfile(profileId);
|
||||
final activeProfileId = storage.getActiveProfileId();
|
||||
|
||||
for (final serverId in removedServerIds) {
|
||||
if (remainingProfileServerIds.contains(serverId)) continue;
|
||||
final serverStillReferenced = await _isServerReferenced(
|
||||
serverId,
|
||||
profileConnections: profileConnections,
|
||||
connections: connections,
|
||||
);
|
||||
final serverStillReferenced = await _isServerReferenced(serverId);
|
||||
if (serverStillReferenced || !clearEverywhereWhenUnreferenced) {
|
||||
await storage.clearLibraryPreferencesForServer(
|
||||
serverId,
|
||||
@@ -283,11 +204,11 @@ Future<void> _clearProfileServerPrefsNoLongerReferenced({
|
||||
}
|
||||
}
|
||||
|
||||
Future<Set<ServerId>> _serverIdsForProfile(
|
||||
String profileId, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
}) async {
|
||||
/// Server ids reachable through this profile's join rows. Narrower than
|
||||
/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
|
||||
/// parent is not counted here, so folding the two together would change which
|
||||
/// per-profile prefs survive an unlink.
|
||||
Future<Set<ServerId>> _serverIdsForProfile(String profileId) async {
|
||||
final rows = await profileConnections.listForProfile(profileId);
|
||||
if (rows.isEmpty) return const {};
|
||||
|
||||
@@ -299,11 +220,7 @@ Future<Set<ServerId>> _serverIdsForProfile(
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> _isServerReferenced(
|
||||
ServerId serverId, {
|
||||
required ProfileConnectionRegistry profileConnections,
|
||||
required ConnectionRegistry connections,
|
||||
}) async {
|
||||
Future<bool> _isServerReferenced(ServerId serverId) async {
|
||||
final rows = await profileConnections.listAll();
|
||||
if (rows.isEmpty) return false;
|
||||
|
||||
@@ -315,7 +232,11 @@ Future<bool> _isServerReferenced(
|
||||
}
|
||||
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) {
|
||||
return switch (connection) {
|
||||
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;
|
||||
}
|
||||
@@ -684,18 +684,13 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
|
||||
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
|
||||
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_session = RemoteSession(
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
safeNotifyListeners();
|
||||
|
||||
try {
|
||||
final winner = await candidate.joinSessionRacingWithContexts(
|
||||
String? winner;
|
||||
final connected = await _runRemoteConnect(
|
||||
generation: generation,
|
||||
seedConnectingSession: true,
|
||||
rethrowOnFailure: true,
|
||||
join: (peer) async {
|
||||
winner = await peer.joinSessionRacingWithContexts(
|
||||
_deviceName,
|
||||
_platform,
|
||||
host.addresses,
|
||||
@@ -703,38 +698,18 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
authContextId: authContext.id,
|
||||
expectedHostClientId: host.clientId,
|
||||
);
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastHostAddresses = [winner];
|
||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? host.clientId;
|
||||
},
|
||||
onConnected: (peer) {
|
||||
_lastHostAddresses = [winner!];
|
||||
_lastAuthContextId = peer.selectedAuthContextId ?? authContext.id;
|
||||
_lastHostClientId = peer.selectedHostClientId ?? host.clientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
safeNotifyListeners();
|
||||
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
|
||||
} catch (error, stackTrace) {
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_cleanupSubscriptions();
|
||||
await _disposePeerOnce(candidate);
|
||||
appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace);
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: _localizedRemoteError(
|
||||
error,
|
||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||
),
|
||||
},
|
||||
failureLog: 'CompanionRemote: Failed to connect to host',
|
||||
onFailure: _failRemoteConnectSession,
|
||||
);
|
||||
safeNotifyListeners();
|
||||
rethrow;
|
||||
if (connected) {
|
||||
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');
|
||||
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_session = RemoteSession(
|
||||
role: RemoteSessionRole.remote,
|
||||
status: RemoteSessionStatus.connecting,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
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 ?? '';
|
||||
await _runRemoteConnect(
|
||||
generation: generation,
|
||||
seedConnectingSession: true,
|
||||
rethrowOnFailure: true,
|
||||
join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts),
|
||||
onConnected: (peer) {
|
||||
_lastAuthContextId = peer.selectedAuthContextId;
|
||||
_lastHostClientId = peer.selectedHostClientId ?? '';
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||
safeNotifyListeners();
|
||||
} catch (error, stackTrace) {
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
},
|
||||
failureLog: 'CompanionRemote: Failed to connect to manual host',
|
||||
onFailure: _failRemoteConnectSession,
|
||||
);
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_cleanupSubscriptions();
|
||||
await _disposePeerOnce(candidate);
|
||||
appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace);
|
||||
void _failRemoteConnectSession(Object error) {
|
||||
_session = _session?.copyWith(
|
||||
status: RemoteSessionStatus.error,
|
||||
errorMessage: _localizedRemoteError(
|
||||
@@ -795,7 +753,60 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
||||
),
|
||||
);
|
||||
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;
|
||||
|
||||
final candidate = _peerServiceFactory();
|
||||
_pendingRemotePeer = candidate;
|
||||
_setupPeerServiceListeners(candidate, generation);
|
||||
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
||||
final expectedHostClientId = _lastHostClientId ?? '';
|
||||
|
||||
try {
|
||||
await candidate.joinSessionWithContexts(
|
||||
final reconnected = await _runRemoteConnect(
|
||||
generation: generation,
|
||||
join: (peer) => peer.joinSessionWithContexts(
|
||||
_deviceName,
|
||||
_platform,
|
||||
hostAddresses.first,
|
||||
_authContexts,
|
||||
authContextId: authContextId,
|
||||
expectedHostClientId: expectedHostClientId,
|
||||
);
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_peerService = candidate;
|
||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContextId;
|
||||
_lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId;
|
||||
),
|
||||
onConnected: (peer) {
|
||||
_lastAuthContextId = peer.selectedAuthContextId ?? authContextId;
|
||||
_lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId;
|
||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
||||
_reconnectAttempts = 0;
|
||||
safeNotifyListeners();
|
||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||
} catch (error, stackTrace) {
|
||||
if (!_ownsPeer(candidate, generation)) {
|
||||
await _disposePeerOnce(candidate);
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingRemotePeer = null;
|
||||
_cleanupSubscriptions();
|
||||
await _disposePeerOnce(candidate);
|
||||
appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace);
|
||||
},
|
||||
failureLog: 'CompanionRemote: Reconnect failed',
|
||||
onFailure: (_) {
|
||||
if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
|
||||
_scheduleReconnect(generation);
|
||||
}
|
||||
},
|
||||
);
|
||||
if (reconnected) {
|
||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import '../services/system_shelf_service.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/coalesced_load_coordinator.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/media_event_keys.dart';
|
||||
import '../utils/media_hub_ordering.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
import 'hidden_libraries_provider.dart';
|
||||
@@ -467,28 +467,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
/// Watch on-deck items and their parent shows/seasons (an episode's watch
|
||||
/// flip changes what Continue Watching should show for its series).
|
||||
Set<String>? get _watchedIds {
|
||||
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 _watchedIds => hierarchicalEventIds(_onDeck);
|
||||
|
||||
Set<String>? get _watchedGlobalKeys {
|
||||
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;
|
||||
}
|
||||
Set<String>? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck);
|
||||
|
||||
void _onWatchStateChanged(WatchStateEvent event) {
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
|
||||
@@ -512,46 +493,15 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
unawaited(refreshContinueWatching());
|
||||
}
|
||||
|
||||
/// Everything on screen: the Continue Watching row plus every hub row.
|
||||
Iterable<MediaItem> get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items));
|
||||
|
||||
/// Deletions can affect any visible list, so the filter covers on-deck and
|
||||
/// hub items plus their parents (a deleted season/show takes its visible
|
||||
/// episodes with it).
|
||||
Set<String>? get _deletionIds {
|
||||
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!);
|
||||
}
|
||||
Set<String>? get _deletionIds => hierarchicalEventIds(_visibleItems);
|
||||
|
||||
_onDeck.forEach(addItem);
|
||||
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;
|
||||
}
|
||||
Set<String>? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems);
|
||||
|
||||
void _onDeletion(DeletionEvent event) {
|
||||
// On-deck and hubs are server-backed: a download-only deletion leaves the
|
||||
|
||||
@@ -139,7 +139,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
|
||||
hydrated.add(
|
||||
HydratedWatchStatePatch(
|
||||
globalKey: scopedKey,
|
||||
patch: WatchStatePatch.fromSnapshot(snapshot),
|
||||
patch: snapshot,
|
||||
updatedAt: latest.updatedAt,
|
||||
order: latest.id,
|
||||
),
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import 'dart:async';
|
||||
import '../media/ids.dart';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_backend.dart';
|
||||
@@ -18,6 +17,7 @@ import '../services/download_manager_service.dart';
|
||||
import '../services/api_cache.dart';
|
||||
import '../services/download_artwork_service.dart';
|
||||
import '../services/download_storage_service.dart';
|
||||
import '../services/downloaded_video_source.dart';
|
||||
import '../services/multi_server_manager.dart';
|
||||
import '../services/offline_mode_source.dart';
|
||||
import '../services/watch_state_resolver.dart';
|
||||
@@ -26,7 +26,6 @@ import '../media/media_server_client.dart';
|
||||
import '../services/sync_rule_executor.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/deletion_notifier.dart';
|
||||
import '../utils/downloaded_version_match.dart';
|
||||
import '../media/episode_collection.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
@@ -972,6 +971,7 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
/// Check if an item is in the queue
|
||||
/// For shows/seasons, checks if any episodes are queued
|
||||
@visibleForTesting
|
||||
bool isQueued(String globalKey) {
|
||||
final progress = getProgress(globalKey);
|
||||
return progress?.status == DownloadStatus.queued;
|
||||
@@ -1007,46 +1007,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
appLogger.w('No downloaded item found for globalKey: $globalKey');
|
||||
return null;
|
||||
}
|
||||
if (downloadedItem.status != DownloadStatus.completed.index) {
|
||||
appLogger.w('Download not complete. Status: ${downloadedItem.status}');
|
||||
return null;
|
||||
}
|
||||
if (!downloadedVersionMatches(
|
||||
|
||||
final source = await resolveDownloadedVideoSource(
|
||||
downloadedItem,
|
||||
requestedMediaIndex: mediaIndex,
|
||||
requestedMediaSourceId: mediaSourceId,
|
||||
)) {
|
||||
appLogger.w(
|
||||
'Downloaded version mismatch for $globalKey: have index ${downloadedItem.mediaIndex} '
|
||||
'(source ${downloadedItem.mediaSourceId}), expected index $mediaIndex '
|
||||
'(source ${mediaSourceId?.trim()})',
|
||||
);
|
||||
return null;
|
||||
}
|
||||
if (downloadedItem.videoFilePath == null) {
|
||||
appLogger.w('Video file path is null for globalKey: $globalKey');
|
||||
return null;
|
||||
}
|
||||
|
||||
final storedPath = downloadedItem.videoFilePath!;
|
||||
final storageService = DownloadStorageService.instance;
|
||||
|
||||
// SAF URIs (content://) are already valid - don't transform them
|
||||
if (storageService.isSafUri(storedPath)) {
|
||||
appLogger.d('Found SAF video path: $storedPath');
|
||||
return storedPath;
|
||||
}
|
||||
|
||||
// Convert stored path (may be relative) to absolute path
|
||||
final absolutePath = await storageService.ensureAbsolutePath(storedPath);
|
||||
|
||||
// Verify file exists
|
||||
final file = File(absolutePath);
|
||||
if (!await file.exists()) {
|
||||
appLogger.w('Offline video file not found: $absolutePath');
|
||||
return null;
|
||||
}
|
||||
return absolutePath;
|
||||
return source?.path;
|
||||
}
|
||||
|
||||
/// Queue a download for a media item.
|
||||
@@ -1150,14 +1117,16 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
|
||||
/// Queue every playable item from a collection/playlist for download.
|
||||
///
|
||||
/// Movies, episodes, and tracks are queued directly. Shows and seasons are
|
||||
/// expanded into their episodes and albums/artists into their tracks (when
|
||||
/// [expandShows] is true). Nested collections/playlists and unknown types
|
||||
/// Expansion follows [collectListLeaves] so a one-shot list download queues
|
||||
/// exactly what a sync rule on the same list would.
|
||||
///
|
||||
/// When [syncRule] is given, the rule's membership — the unfiltered leaves of
|
||||
/// the list, not just the ones this pass queues — is linked to the rule so a
|
||||
/// later "delete rule and its downloads" pass can find every associated row.
|
||||
Future<int> queueListDownload(
|
||||
List<MediaItem> items,
|
||||
MediaServerClient client, {
|
||||
DownloadFilter filter = DownloadFilter.all,
|
||||
bool expandShows = true,
|
||||
SyncRuleItem? syncRule,
|
||||
}) async {
|
||||
if (!_downloadManager.downloadsSupported) return 0;
|
||||
@@ -1168,30 +1137,24 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return 0;
|
||||
|
||||
final unwatchedOnly = filter == DownloadFilter.unwatched;
|
||||
final membership = <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) {
|
||||
await _syncRuleExecutor.collectItemsForList(client, items, unwatchedOnly: false, out: membership);
|
||||
}
|
||||
if (filter == DownloadFilter.all && syncRule != null) {
|
||||
candidates.addAll(membership);
|
||||
if (unwatchedOnly) {
|
||||
// Rule membership spans the whole list; only the queue is filtered.
|
||||
await collectListLeaves(client, [item], unwatchedOnly: false, out: membership);
|
||||
} else {
|
||||
await _syncRuleExecutor.collectItemsForList(
|
||||
client,
|
||||
items,
|
||||
unwatchedOnly: filter == DownloadFilter.unwatched,
|
||||
out: candidates,
|
||||
);
|
||||
membership.addAll(leaves);
|
||||
}
|
||||
}
|
||||
} 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;
|
||||
|
||||
|
||||
@@ -76,6 +76,7 @@ class HiddenLibrariesProvider extends ChangeNotifier with DisposableChangeNotifi
|
||||
}
|
||||
|
||||
/// Check if a specific library is hidden
|
||||
@visibleForTesting
|
||||
bool isLibraryHidden(String libraryKey) => _hiddenLibraryKeys.contains(libraryKey);
|
||||
|
||||
/// Refresh hidden libraries from storage
|
||||
|
||||
@@ -65,9 +65,11 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
bool get isLoading => _loadState == LibrariesLoadState.loading;
|
||||
|
||||
/// Whether libraries have been loaded at least once
|
||||
@visibleForTesting
|
||||
bool get hasLoaded => _loadState == LibrariesLoadState.loaded;
|
||||
|
||||
/// Current load state
|
||||
@visibleForTesting
|
||||
LibrariesLoadState get loadState => _loadState;
|
||||
|
||||
/// Error message if loading failed
|
||||
|
||||
@@ -108,24 +108,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
/// filter to a one-element set when no filter is currently set.
|
||||
void addToVisibleServerIds(ServerId serverId) {
|
||||
final current = _visibleServerIds;
|
||||
if (current == null) {
|
||||
_serverManager.setVisibleServerIds({serverId});
|
||||
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
||||
safeNotifyListeners();
|
||||
_refreshLiveTvAvailabilitySoon();
|
||||
return;
|
||||
}
|
||||
if (current.contains(serverId)) return;
|
||||
_serverManager.setVisibleServerIds({...current, serverId});
|
||||
if (current != null && current.contains(serverId)) return;
|
||||
_serverManager.setVisibleServerIds({...?current, serverId});
|
||||
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
||||
safeNotifyListeners();
|
||||
_refreshLiveTvAvailabilitySoon();
|
||||
}
|
||||
|
||||
/// Keep only ids the manager considers visible under the active filter.
|
||||
List<String> _visible(List<String> ids) =>
|
||||
ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList();
|
||||
|
||||
void _pruneLiveTvServersForVisibility() {
|
||||
final filter = _visibleServerIds;
|
||||
if (filter == null) return;
|
||||
_liveTvServers.removeWhere((s) => !filter.contains(s.serverId));
|
||||
if (_visibleServerIds == null) return;
|
||||
_liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId)));
|
||||
_hasLiveTv = _liveTvServers.isNotEmpty;
|
||||
}
|
||||
|
||||
@@ -199,20 +195,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
|
||||
/// Get all online server IDs (visibility-filtered).
|
||||
List<String> get onlineServerIds {
|
||||
final all = _serverManager.onlineServerIds;
|
||||
final filter = _visibleServerIds;
|
||||
if (filter == null) return all;
|
||||
return all.where(filter.contains).toList();
|
||||
}
|
||||
List<String> get onlineServerIds => _visible(_serverManager.onlineServerIds);
|
||||
|
||||
/// Get all server IDs (visibility-filtered).
|
||||
List<String> get serverIds {
|
||||
final all = _serverManager.serverIds;
|
||||
final filter = _visibleServerIds;
|
||||
if (filter == null) return all;
|
||||
return all.where(filter.contains).toList();
|
||||
}
|
||||
List<String> get serverIds => _visible(_serverManager.serverIds);
|
||||
|
||||
/// Server ids the active profile is expected to have, including unreachable
|
||||
/// Plex servers that have no live client yet.
|
||||
@@ -223,11 +209,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
|
||||
/// Check if a server is online (and visible under the active profile).
|
||||
bool isServerOnline(ServerId serverId) {
|
||||
final filter = _visibleServerIds;
|
||||
if (filter != null && !filter.contains(serverId)) return false;
|
||||
return _serverManager.isServerOnline(serverId);
|
||||
}
|
||||
bool isServerOnline(ServerId serverId) =>
|
||||
_serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId);
|
||||
|
||||
/// Get number of online servers
|
||||
int get onlineServerCount => onlineServerIds.length;
|
||||
@@ -312,10 +295,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
}
|
||||
|
||||
final filter = _visibleServerIds;
|
||||
final visibleLiveTvServers = filter == null
|
||||
? newLiveTvServers
|
||||
: newLiveTvServers.where((s) => filter.contains(s.serverId)).toList();
|
||||
final visibleLiveTvServers = newLiveTvServers
|
||||
.where((s) => _serverManager.isServerVisible(ServerId(s.serverId)))
|
||||
.toList();
|
||||
|
||||
final hadLiveTv = _hasLiveTv;
|
||||
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
|
||||
|
||||
@@ -75,9 +75,11 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
}
|
||||
|
||||
/// Whether there is network connectivity (WiFi, mobile data, etc.)
|
||||
@visibleForTesting
|
||||
bool get hasNetworkConnection => _hasNetworkConnection;
|
||||
|
||||
/// Whether at least one media server (Plex or Jellyfin) is reachable
|
||||
@visibleForTesting
|
||||
bool get hasServerConnection => _hasServerConnection;
|
||||
|
||||
bool get _hasKnownVisibleServers =>
|
||||
|
||||
@@ -71,6 +71,7 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
/// 2. Metadata from download provider
|
||||
///
|
||||
/// Returns null if no position is available.
|
||||
@visibleForTesting
|
||||
Future<int?> getViewOffset(String globalKey) async {
|
||||
// First check local offline progress
|
||||
final localOffset = await _syncService.getLocalViewOffset(globalKey);
|
||||
|
||||
@@ -306,7 +306,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
|
||||
var anchor = current;
|
||||
// Bounded so a pathological all-same-file queue cannot spin.
|
||||
for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
|
||||
final result = await _itemAfter(anchor);
|
||||
final result = await _itemAtOffset(anchor, 1);
|
||||
final candidate = result.item;
|
||||
if (result.status != QueueNavigationStatus.found || candidate == null) {
|
||||
return result;
|
||||
@@ -337,7 +337,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
|
||||
final current = _loadedItems[indexResult.index!];
|
||||
MediaItem candidate = current;
|
||||
for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
|
||||
final result = await _itemBefore(candidate);
|
||||
final result = await _itemAtOffset(candidate, -1);
|
||||
final before = result.item;
|
||||
if (result.status != QueueNavigationStatus.found || before == null) {
|
||||
return result;
|
||||
@@ -348,7 +348,7 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
|
||||
|
||||
// Collapse to the first episode of the candidate's same-file group.
|
||||
for (var steps = 0; steps <= _playQueueTotalCount; steps++) {
|
||||
final result = await _itemBefore(candidate);
|
||||
final result = await _itemAtOffset(candidate, -1);
|
||||
final before = result.item;
|
||||
if (result.status == QueueNavigationStatus.failed) return result;
|
||||
if (result.status != QueueNavigationStatus.found || before == null || !candidate.sharesFileWith(before)) {
|
||||
@@ -359,18 +359,19 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
|
||||
return QueueNavigationResult.found(candidate);
|
||||
}
|
||||
|
||||
/// The queue item immediately after [anchor], extending a server-backed
|
||||
/// The queue item [delta] steps from [anchor], extending a server-backed
|
||||
/// window when needed. The centered response proves whether [anchor] is at
|
||||
/// the global boundary; a window-local index is never compared with the
|
||||
/// queue's global item count.
|
||||
Future<QueueNavigationResult> _itemAfter(MediaItem anchor) async {
|
||||
Future<QueueNavigationResult> _itemAtOffset(MediaItem anchor, int delta) async {
|
||||
final anchorId = playQueueItemIdFor(anchor);
|
||||
if (anchorId == null) return const QueueNavigationResult.unavailable();
|
||||
var anchorIndex = _findLoadedIndex(anchorId);
|
||||
if (anchorIndex == -1) return const QueueNavigationResult.unavailable();
|
||||
|
||||
if (anchorIndex + 1 < _loadedItems.length) {
|
||||
return QueueNavigationResult.found(_loadedItems[anchorIndex + 1]);
|
||||
var target = anchorIndex + delta;
|
||||
if (target >= 0 && target < _loadedItems.length) {
|
||||
return QueueNavigationResult.found(_loadedItems[target]);
|
||||
}
|
||||
|
||||
// Local queues are fully resident, so their window edge is the queue edge.
|
||||
@@ -382,43 +383,15 @@ class PlaybackStateProvider with ChangeNotifier, DisposableChangeNotifierMixin {
|
||||
}
|
||||
|
||||
// Refresh around the actual anchor. Queue ids are opaque and need not be
|
||||
// consecutive, so never guess `anchorId + 1`.
|
||||
// consecutive, so never guess the neighbour's id.
|
||||
if (!await _loadServerWindow(anchorId)) {
|
||||
return const QueueNavigationResult.failed();
|
||||
}
|
||||
anchorIndex = _findLoadedIndex(anchorId);
|
||||
if (anchorIndex == -1) return const QueueNavigationResult.failed();
|
||||
return anchorIndex + 1 < _loadedItems.length
|
||||
? QueueNavigationResult.found(_loadedItems[anchorIndex + 1])
|
||||
: const QueueNavigationResult.boundary();
|
||||
}
|
||||
|
||||
/// The queue item immediately before [anchor], extending a server-backed
|
||||
/// window when needed.
|
||||
Future<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])
|
||||
target = anchorIndex + delta;
|
||||
return target >= 0 && target < _loadedItems.length
|
||||
? QueueNavigationResult.found(_loadedItems[target])
|
||||
: const QueueNavigationResult.boundary();
|
||||
}
|
||||
|
||||
|
||||
@@ -88,6 +88,7 @@ class ThemeProvider extends ChangeNotifier with DisposableChangeNotifierMixin, W
|
||||
|
||||
static const _themeChannel = MethodChannel('com.plezy/theme');
|
||||
|
||||
@visibleForTesting
|
||||
Future<void> setThemeMode(settings.ThemeMode mode) async {
|
||||
if (_themeMode == mode) return;
|
||||
final service = _settingsBinding.settings ?? await settings.SettingsService.getInstance();
|
||||
|
||||
@@ -62,13 +62,23 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
final MalAuthService _malAuth;
|
||||
final AnilistAuthService _anilistAuth;
|
||||
final SimklAuthService _simklAuth;
|
||||
final TrackerAccountStore _malStore = trackerAccountStore(TrackerService.mal);
|
||||
final TrackerAccountStore _anilistStore = trackerAccountStore(TrackerService.anilist);
|
||||
final TrackerAccountStore _simklStore = trackerAccountStore(TrackerService.simkl);
|
||||
|
||||
TrackerSession? _mal;
|
||||
TrackerSession? _anilist;
|
||||
TrackerSession? _simkl;
|
||||
final _TrackerSlot _mal = _TrackerSlot(
|
||||
TrackerService.mal,
|
||||
(session, {required onInvalidated, onUpdated}) =>
|
||||
MalTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated, onSessionUpdated: onUpdated),
|
||||
);
|
||||
final _TrackerSlot _anilist = _TrackerSlot(
|
||||
TrackerService.anilist,
|
||||
(session, {required onInvalidated, onUpdated}) =>
|
||||
AnilistTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated),
|
||||
);
|
||||
final _TrackerSlot _simkl = _TrackerSlot(
|
||||
TrackerService.simkl,
|
||||
(session, {required onInvalidated, onUpdated}) =>
|
||||
SimklTracker.instance.rebindSession(session, onSessionInvalidated: onInvalidated),
|
||||
);
|
||||
late final List<_TrackerSlot> _slots = [_mal, _anilist, _simkl];
|
||||
|
||||
String _activeUserUuid = '';
|
||||
int _profileBindingGeneration = 0;
|
||||
@@ -76,22 +86,13 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
Completer<void>? _cancelCompleter;
|
||||
int _connectGeneration = 0;
|
||||
|
||||
// Bumped on every rebind so a late callback from a disposed client (e.g. an
|
||||
// in-flight MAL token refresh that resolves after a profile switch) can't
|
||||
// persist or clear a session under the wrong profile, and so a disconnect
|
||||
// racing an in-flight profile load only suppresses its own service. Mirrors
|
||||
// TraktAccountProvider's binding-generation guard, but per service.
|
||||
final _RebindGeneration _malRebind = _RebindGeneration();
|
||||
final _RebindGeneration _anilistRebind = _RebindGeneration();
|
||||
final _RebindGeneration _simklRebind = _RebindGeneration();
|
||||
TrackerSession? get mal => _mal.session;
|
||||
TrackerSession? get anilist => _anilist.session;
|
||||
TrackerSession? get simkl => _simkl.session;
|
||||
|
||||
TrackerSession? get mal => _mal;
|
||||
TrackerSession? get anilist => _anilist;
|
||||
TrackerSession? get simkl => _simkl;
|
||||
|
||||
bool get isMalConnected => _mal != null;
|
||||
bool get isAnilistConnected => _anilist != null;
|
||||
bool get isSimklConnected => _simkl != null;
|
||||
bool get isMalConnected => _mal.session != null;
|
||||
bool get isAnilistConnected => _anilist.session != null;
|
||||
bool get isSimklConnected => _simkl.session != null;
|
||||
|
||||
/// The live MAL client for the Explore catalog, shared with the scrobble
|
||||
/// tracker so both ride one session (MAL rotates refresh tokens — a second
|
||||
@@ -99,17 +100,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// provider's own session so a freshly-mounted profile subtree never sees
|
||||
/// the previous profile's client while its sessions are still loading;
|
||||
/// every rebind is followed by a notify, so proxy consumers track identity.
|
||||
MalClient? get malCatalogClient => _mal == null ? null : MalTracker.instance.client;
|
||||
MalClient? get malCatalogClient => _mal.session == null ? null : MalTracker.instance.client;
|
||||
|
||||
/// Live AniList and Simkl clients for Explore. Like [malCatalogClient],
|
||||
/// these are gated on this provider's profile-bound sessions so a fresh
|
||||
/// profile subtree cannot observe clients still bound to the prior profile.
|
||||
AnilistClient? get anilistCatalogClient => _anilist == null ? null : AnilistTracker.instance.client;
|
||||
SimklClient? get simklCatalogClient => _simkl == null ? null : SimklTracker.instance.client;
|
||||
AnilistClient? get anilistCatalogClient => _anilist.session == null ? null : AnilistTracker.instance.client;
|
||||
SimklClient? get simklCatalogClient => _simkl.session == null ? null : SimklTracker.instance.client;
|
||||
|
||||
String? get malUsername => _mal?.username;
|
||||
String? get anilistUsername => _anilist?.username;
|
||||
String? get simklUsername => _simkl?.username;
|
||||
String? get malUsername => _mal.session?.username;
|
||||
String? get anilistUsername => _anilist.session?.username;
|
||||
String? get simklUsername => _simkl.session?.username;
|
||||
|
||||
bool isConnecting(TrackerService service) => _connecting == service;
|
||||
|
||||
@@ -131,26 +132,14 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// Snapshot each service's rebind generation before the await so a disconnect
|
||||
// that races this load only suppresses its own service (whose generation
|
||||
// moves) rather than dropping the freshly-loaded sessions for the others.
|
||||
final malRebind = _malRebind.value;
|
||||
final anilistRebind = _anilistRebind.value;
|
||||
final simklRebind = _simklRebind.value;
|
||||
final results = await Future.wait<TrackerSession?>([
|
||||
_malStore.load(userUuid),
|
||||
_anilistStore.load(userUuid),
|
||||
_simklStore.load(userUuid),
|
||||
]);
|
||||
final rebinds = [for (final slot in _slots) slot.rebindGeneration];
|
||||
final results = await Future.wait<TrackerSession?>([for (final slot in _slots) slot.store.load(userUuid)]);
|
||||
if (!_isCurrentProfileBinding(userUuid, generation)) return;
|
||||
if (_malRebind.value == malRebind) {
|
||||
_mal = results.first;
|
||||
_rebindMal();
|
||||
}
|
||||
if (_anilistRebind.value == anilistRebind) {
|
||||
_anilist = results[1];
|
||||
_rebindAnilist();
|
||||
}
|
||||
if (_simklRebind.value == simklRebind) {
|
||||
_simkl = results[2];
|
||||
_rebindSimkl();
|
||||
for (var i = 0; i < _slots.length; i++) {
|
||||
final slot = _slots[i];
|
||||
if (slot.rebindGeneration != rebinds[i]) continue;
|
||||
slot.session = results[i];
|
||||
_rebind(slot);
|
||||
}
|
||||
// Connect/disconnect may flip `needsFribb` — drop cached resolver IDs so
|
||||
// the next lookup re-evaluates whether to consult Fribb.
|
||||
@@ -159,78 +148,51 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
|
||||
Future<bool> connectMal({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect(
|
||||
service: TrackerService.mal,
|
||||
alreadyConnected: isMalConnected,
|
||||
_mal,
|
||||
authorize: () => _malAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
||||
shouldCancel: _isConnectCancelled,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichMal,
|
||||
store: _malStore,
|
||||
assign: (s) {
|
||||
_mal = s;
|
||||
_rebindMal();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> disconnectMal() => _clearAndRebind(TrackerService.mal, _malStore, () {
|
||||
_mal = null;
|
||||
_rebindMal();
|
||||
});
|
||||
Future<void> disconnectMal() => _clearAndRebind(_mal);
|
||||
|
||||
Future<bool> connectAnilist({required void Function(OAuthProxyStart) onCodeReady}) => _runConnect(
|
||||
service: TrackerService.anilist,
|
||||
alreadyConnected: isAnilistConnected,
|
||||
_anilist,
|
||||
authorize: () => _anilistAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
||||
shouldCancel: _isConnectCancelled,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichAnilist,
|
||||
store: _anilistStore,
|
||||
assign: (s) {
|
||||
_anilist = s;
|
||||
_rebindAnilist();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> disconnectAnilist() => _clearAndRebind(TrackerService.anilist, _anilistStore, () {
|
||||
_anilist = null;
|
||||
_rebindAnilist();
|
||||
});
|
||||
Future<void> disconnectAnilist() => _clearAndRebind(_anilist);
|
||||
|
||||
Future<bool> connectSimkl({required void Function(DeviceCode code) onCodeReady}) => _runConnect(
|
||||
service: TrackerService.simkl,
|
||||
alreadyConnected: isSimklConnected,
|
||||
_simkl,
|
||||
authorize: () => _simklAuth.authorize(
|
||||
onCodeReady: onCodeReady,
|
||||
shouldCancel: () => _cancelCompleter?.isCompleted ?? false,
|
||||
shouldCancel: _isConnectCancelled,
|
||||
onCancel: _cancelCompleter!.future,
|
||||
),
|
||||
enrich: _enrichSimkl,
|
||||
store: _simklStore,
|
||||
assign: (s) {
|
||||
_simkl = s;
|
||||
_rebindSimkl();
|
||||
},
|
||||
);
|
||||
|
||||
Future<void> disconnectSimkl() => _clearAndRebind(TrackerService.simkl, _simklStore, () {
|
||||
_simkl = null;
|
||||
_rebindSimkl();
|
||||
});
|
||||
Future<void> disconnectSimkl() => _clearAndRebind(_simkl);
|
||||
|
||||
Future<bool> _runConnect({
|
||||
required TrackerService service,
|
||||
required bool alreadyConnected,
|
||||
bool _isConnectCancelled() => _cancelCompleter?.isCompleted ?? false;
|
||||
|
||||
Future<bool> _runConnect(
|
||||
_TrackerSlot slot, {
|
||||
required Future<TrackerSession?> Function() authorize,
|
||||
required Future<TrackerSession> Function(TrackerSession raw) enrich,
|
||||
required TrackerAccountStore store,
|
||||
required void Function(TrackerSession session) assign,
|
||||
}) async {
|
||||
if (isDisposed || _connecting != null || alreadyConnected) return false;
|
||||
if (isDisposed || _connecting != null || slot.session != null) return false;
|
||||
|
||||
final service = slot.service;
|
||||
final userUuid = _activeUserUuid;
|
||||
final generation = ++_connectGeneration;
|
||||
_connecting = service;
|
||||
@@ -248,11 +210,12 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
enrich: enrich,
|
||||
save: (session) async {
|
||||
if (!_isCurrentConnect(service, userUuid, generation)) return;
|
||||
await store.save(userUuid, session);
|
||||
await slot.store.save(userUuid, session);
|
||||
},
|
||||
assign: (session) {
|
||||
if (!_isCurrentConnect(service, userUuid, generation)) return;
|
||||
assign(session);
|
||||
slot.session = session;
|
||||
_rebind(slot);
|
||||
TrackerCoordinator.instance.invalidateResolverCache();
|
||||
assigned = true;
|
||||
},
|
||||
@@ -267,20 +230,17 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _clearAndRebind(
|
||||
TrackerService service,
|
||||
TrackerAccountStore store,
|
||||
void Function() clearAndRebind,
|
||||
) async {
|
||||
_invalidateConnect(service);
|
||||
Future<void> _clearAndRebind(_TrackerSlot slot) async {
|
||||
_invalidateConnect(slot.service);
|
||||
final userUuid = _activeUserUuid;
|
||||
// `clearAndRebind` bumps the affected service's rebind generation, which is
|
||||
// what stops an in-flight profile load from resurrecting the cleared
|
||||
// session — so we no longer touch the shared profile-binding generation
|
||||
// (which would also abort that load for the other two services).
|
||||
clearAndRebind();
|
||||
// The rebind bumps the affected service's generation, which is what stops
|
||||
// an in-flight profile load from resurrecting the cleared session — so we
|
||||
// no longer touch the shared profile-binding generation (which would also
|
||||
// abort that load for the other two services).
|
||||
slot.session = null;
|
||||
_rebind(slot);
|
||||
safeNotifyListeners();
|
||||
await store.clear(userUuid);
|
||||
await slot.store.clear(userUuid);
|
||||
}
|
||||
|
||||
void _invalidateConnect([TrackerService? service]) {
|
||||
@@ -322,68 +282,33 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
},
|
||||
);
|
||||
|
||||
/// Snapshot the active profile + bump this service's rebind generation,
|
||||
/// returning the bound uuid and an `isCurrent` predicate. Bumping here is what
|
||||
/// lets a stale client callback — or a racing profile load — detect that it
|
||||
/// has been superseded for this service.
|
||||
(String, bool Function()) _beginRebind(_RebindGeneration gen) {
|
||||
/// Push a slot's session to its tracker, snapshotting the active profile and
|
||||
/// bumping the slot's rebind generation first. Bumping here is what lets a
|
||||
/// stale client callback — or a racing profile load — detect that it has been
|
||||
/// superseded for this service.
|
||||
void _rebind(_TrackerSlot slot) {
|
||||
if (isDisposed) return;
|
||||
final boundUuid = _activeUserUuid;
|
||||
final generation = gen.bump();
|
||||
bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == gen.value;
|
||||
return (boundUuid, isCurrent);
|
||||
}
|
||||
|
||||
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) {
|
||||
final generation = ++slot.rebindGeneration;
|
||||
bool isCurrent() => !isDisposed && boundUuid == _activeUserUuid && generation == slot.rebindGeneration;
|
||||
slot.bind(
|
||||
slot.session,
|
||||
onInvalidated: () {
|
||||
if (!isCurrent()) return;
|
||||
_mal = next;
|
||||
_malStore.save(boundUuid, next);
|
||||
slot.store.clear(boundUuid);
|
||||
slot.session = null;
|
||||
_rebind(slot);
|
||||
safeNotifyListeners();
|
||||
},
|
||||
onUpdated: (next) {
|
||||
if (!isCurrent()) return;
|
||||
slot.session = next;
|
||||
slot.store.save(boundUuid, next);
|
||||
safeNotifyListeners();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _rebindAnilist() {
|
||||
if (isDisposed) return;
|
||||
final (boundUuid, isCurrent) = _beginRebind(_anilistRebind);
|
||||
AnilistTracker.instance.rebindSession(
|
||||
_anilist,
|
||||
onSessionInvalidated: () {
|
||||
if (isCurrent()) _handleInvalidated(_anilistStore, boundUuid, () => _anilist = null, _rebindAnilist);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _rebindSimkl() {
|
||||
if (isDisposed) return;
|
||||
final (boundUuid, isCurrent) = _beginRebind(_simklRebind);
|
||||
SimklTracker.instance.rebindSession(
|
||||
_simkl,
|
||||
onSessionInvalidated: () {
|
||||
if (isCurrent()) _handleInvalidated(_simklStore, boundUuid, () => _simkl = null, _rebindSimkl);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _handleInvalidated(
|
||||
TrackerAccountStore store,
|
||||
String userUuid,
|
||||
void Function() clearSession,
|
||||
void Function() rebind,
|
||||
) {
|
||||
store.clear(userUuid);
|
||||
clearSession();
|
||||
rebind();
|
||||
safeNotifyListeners();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_invalidateConnect();
|
||||
@@ -394,12 +319,29 @@ class TrackersProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
|
||||
/// A monotonic per-service rebind counter. Each rebind bumps it so a stale
|
||||
/// client callback — or a profile load that started earlier — can tell it has
|
||||
/// been superseded for that service.
|
||||
class _RebindGeneration {
|
||||
int _value = 0;
|
||||
/// Pushes a session to one service's tracker singleton. `onUpdated` is only
|
||||
/// wired for MAL, the one service that rotates its refresh token.
|
||||
typedef _TrackerBind =
|
||||
void Function(
|
||||
TrackerSession? session, {
|
||||
required void Function() onInvalidated,
|
||||
void Function(TrackerSession session)? onUpdated,
|
||||
});
|
||||
|
||||
int bump() => ++_value;
|
||||
int get value => _value;
|
||||
/// Owns one service's session, the generation guarding its rebinds, and the
|
||||
/// adapter that pushes that session to the service's tracker singleton.
|
||||
class _TrackerSlot {
|
||||
_TrackerSlot(this.service, this.bind) : store = trackerAccountStore(service);
|
||||
|
||||
final TrackerService service;
|
||||
final TrackerAccountStore store;
|
||||
final _TrackerBind bind;
|
||||
TrackerSession? session;
|
||||
|
||||
/// Bumped on every rebind so a late callback from a disposed client (e.g. an
|
||||
/// in-flight MAL token refresh that resolves after a profile switch) can't
|
||||
/// persist or clear a session under the wrong profile, and so a disconnect
|
||||
/// racing an in-flight profile load only suppresses its own service. Mirrors
|
||||
/// TraktAccountProvider's binding-generation guard, but per service.
|
||||
int rebindGeneration = 0;
|
||||
}
|
||||
|
||||
@@ -11,36 +11,10 @@ import '../services/watch_state_resolver.dart';
|
||||
import '../utils/global_key_utils.dart';
|
||||
import '../utils/watch_state_notifier.dart';
|
||||
|
||||
@immutable
|
||||
class WatchStatePatch {
|
||||
final bool? isWatched;
|
||||
final bool hasViewOffsetMs;
|
||||
final int? viewOffsetMs;
|
||||
|
||||
const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
|
||||
|
||||
factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch(
|
||||
isWatched: snapshot.isWatched,
|
||||
hasViewOffsetMs: snapshot.hasViewOffsetMs,
|
||||
viewOffsetMs: snapshot.viewOffsetMs,
|
||||
);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is WatchStatePatch &&
|
||||
other.isWatched == isWatched &&
|
||||
other.hasViewOffsetMs == hasViewOffsetMs &&
|
||||
other.viewOffsetMs == viewOffsetMs;
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs);
|
||||
}
|
||||
|
||||
@immutable
|
||||
class HydratedWatchStatePatch {
|
||||
final String globalKey;
|
||||
final WatchStatePatch patch;
|
||||
final WatchStateSnapshot patch;
|
||||
final int updatedAt;
|
||||
final int order;
|
||||
|
||||
@@ -53,7 +27,7 @@ class HydratedWatchStatePatch {
|
||||
}
|
||||
|
||||
class _WatchStatePatchEntry {
|
||||
final WatchStatePatch patch;
|
||||
final WatchStateSnapshot patch;
|
||||
final int updatedAt;
|
||||
final int sequence;
|
||||
final bool isSessionEvent;
|
||||
@@ -123,9 +97,10 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return _exactEntryFor(globalKey);
|
||||
}
|
||||
|
||||
WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
|
||||
@visibleForTesting
|
||||
WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
|
||||
|
||||
WatchStatePatch? patchForItem(MediaItem item) {
|
||||
WatchStateSnapshot? patchForItem(MediaItem item) {
|
||||
var best = _entryFor(item.globalKey);
|
||||
if (item.parentChain.isNotEmpty) {
|
||||
final serverId = serverIdOrNull(item.serverId);
|
||||
@@ -147,14 +122,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return [for (final item in items) apply(item)];
|
||||
}
|
||||
|
||||
static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) {
|
||||
if (patch == null) return item;
|
||||
return WatchStateSnapshot(
|
||||
isWatched: patch.isWatched,
|
||||
hasViewOffsetMs: patch.hasViewOffsetMs,
|
||||
viewOffsetMs: patch.viewOffsetMs,
|
||||
).apply(item);
|
||||
}
|
||||
static MediaItem applyPatch(MediaItem item, WatchStateSnapshot? patch) => patch == null ? item : patch.apply(item);
|
||||
|
||||
void setActiveProfileId(String? profileId) {
|
||||
if (_activeProfileId == profileId) return;
|
||||
@@ -216,7 +184,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
? buildGlobalKey(ServerId(resolvedScope), event.itemId)
|
||||
: event.globalKey;
|
||||
_patches[key] = _WatchStatePatchEntry(
|
||||
WatchStatePatch.fromSnapshot(snapshot),
|
||||
snapshot,
|
||||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||||
sequence: ++_sequence,
|
||||
isSessionEvent: true,
|
||||
@@ -240,7 +208,7 @@ extension WatchStateResolution on BuildContext {
|
||||
/// ancestor). Use in `build`.
|
||||
MediaItem withFreshWatchState(MediaItem item) {
|
||||
try {
|
||||
final patch = select<WatchStateStore, WatchStatePatch?>((store) => store.patchForItem(item));
|
||||
final patch = select<WatchStateStore, WatchStateSnapshot?>((store) => store.patchForItem(item));
|
||||
return WatchStateStore.applyPatch(item, patch);
|
||||
} on ProviderNotFoundException {
|
||||
return item;
|
||||
|
||||
@@ -7,6 +7,7 @@ import '../media/media_item.dart';
|
||||
import '../media/media_kind.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
import '../mixins/standard_paginated_view.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
@@ -48,7 +49,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
with
|
||||
GridFocusNodeMixin<ActorMediaScreen>,
|
||||
FocusableDetailScreenMixin<ActorMediaScreen>,
|
||||
PaginatedItemLoader<MediaItem, ActorMediaScreen> {
|
||||
PaginatedItemLoader<MediaItem, ActorMediaScreen>,
|
||||
PaginatedItemUpdatable<ActorMediaScreen>,
|
||||
StandardPaginatedView<MediaItem, ActorMediaScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
@@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||
for (final entry in loadedItems.entries) {
|
||||
if (entry.value.globalKey == sourceGlobalKey) {
|
||||
loadedItems[entry.key] = updatedItem;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
await loadInitialPaginatedItems(
|
||||
Future<void> loadItems() {
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, _) {
|
||||
errorMessage = t.messages.errorLoading(error: error.toString());
|
||||
isLoading = false;
|
||||
errorMessageFor: (error, stackTrace) {
|
||||
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
|
||||
return t.messages.errorLoading(error: error.toString());
|
||||
},
|
||||
onLoaded: (loadedCount, totalCount) {
|
||||
appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}');
|
||||
autoFocusFirstItemAfterLoad();
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile.dart';
|
||||
import '../profiles/profile_connection_registry.dart';
|
||||
import '../profiles/profile_selection_policy.dart';
|
||||
import '../services/plex_auth_service.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../services/storage_service.dart';
|
||||
@@ -196,8 +197,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
||||
activeProfile: activeProfiles.active,
|
||||
hasProfiles: activeProfiles.profiles.isNotEmpty,
|
||||
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
|
||||
requireProfileSelectionOnOpen:
|
||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles,
|
||||
requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings),
|
||||
);
|
||||
if (promptHandled) {
|
||||
final selected = await Navigator.of(
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../media/ids.dart';
|
||||
import 'package:plezy/widgets/app_icon.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../services/media_list_playback_launcher.dart';
|
||||
@@ -43,19 +42,34 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
|
||||
/// Optional icon to show when list is empty
|
||||
IconData? get emptyIcon => null;
|
||||
|
||||
/// Server the displayed item was tagged with, if any.
|
||||
String? get _mediaItemServerId => switch (mediaItem) {
|
||||
MediaItem(:final serverId) => serverId,
|
||||
MediaPlaylist(:final serverId) => serverId,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Sync-rule global key for the displayed collection/playlist, keyed to the
|
||||
/// item's own server when it has one and to the resolved client's otherwise.
|
||||
String get syncRuleKey {
|
||||
final client = mediaClient;
|
||||
final id = switch (mediaItem) {
|
||||
MediaItem(:final id) => id,
|
||||
MediaPlaylist(:final id) => id,
|
||||
_ => '',
|
||||
};
|
||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
||||
client,
|
||||
id,
|
||||
serverId: ServerId(_mediaItemServerId ?? client.serverId),
|
||||
);
|
||||
}
|
||||
|
||||
String? _resolveMediaItemServerId() {
|
||||
final item = mediaItem;
|
||||
String? serverId;
|
||||
if (item is MediaItem) {
|
||||
serverId = item.serverId;
|
||||
} else if (item is MediaPlaylist) {
|
||||
serverId = item.serverId;
|
||||
}
|
||||
if (serverId == null) {
|
||||
final serverId = _mediaItemServerId;
|
||||
if (serverId != null) return serverId;
|
||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||
serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
||||
}
|
||||
return serverId;
|
||||
return multiServerProvider.onlineServerIds.firstOrNull;
|
||||
}
|
||||
|
||||
MediaServerClient _getMediaClientForMediaItem() {
|
||||
@@ -133,40 +147,6 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
|
||||
|
||||
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
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
import '../focus/focusable_text_field.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../mixins/debounced_media_search.dart';
|
||||
import '../services/catalog/catalog_source.dart';
|
||||
import '../utils/focus_utils.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../widgets/app_icon.dart';
|
||||
import '../widgets/focusable_media_card.dart';
|
||||
import '../widgets/focused_scroll_scaffold.dart';
|
||||
import '../widgets/loading_indicator_box.dart';
|
||||
import '../widgets/pill_input_decoration.dart';
|
||||
import '../widgets/search_input_field.dart';
|
||||
import 'libraries/state_messages.dart';
|
||||
|
||||
/// Free-text search of one catalog source (the Explore tab's active source),
|
||||
@@ -30,7 +27,6 @@ class CatalogSearchScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CatalogSearchScreenState extends State<CatalogSearchScreen> with DebouncedMediaSearch {
|
||||
final _clearFocusNode = FocusNode(debugLabel: 'CatalogSearch.clear');
|
||||
@override
|
||||
String get searchDebugLabel => 'CatalogSearch';
|
||||
|
||||
@@ -46,17 +42,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
|
||||
FocusUtils.requestFocusAfterBuild(this, searchFocusNode);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_clearFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _clearSearch() {
|
||||
searchController.clear();
|
||||
searchFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final sourceName = widget.source.displayName;
|
||||
@@ -64,36 +49,13 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
|
||||
title: Text(t.explore.searchHint(source: sourceName)),
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 16, right: 16, bottom: 16),
|
||||
child: Stack(
|
||||
alignment: Alignment.centerRight,
|
||||
children: [
|
||||
FocusableTextField(
|
||||
child: SearchInputField(
|
||||
controller: searchController,
|
||||
focusNode: searchFocusNode,
|
||||
textInputAction: TextInputAction.search,
|
||||
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
||||
onNavigateRight: searchController.text.isNotEmpty ? _clearFocusNode.requestFocus : null,
|
||||
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
||||
decoration: pillInputDecoration(
|
||||
context,
|
||||
debugLabel: searchDebugLabel,
|
||||
hintText: t.explore.searchHint(source: sourceName),
|
||||
prefixIcon: const AppIcon(Symbols.search_rounded, fill: 1),
|
||||
suffixIcon: searchController.text.isNotEmpty ? const SizedBox(width: 48) : null,
|
||||
),
|
||||
),
|
||||
if (searchController.text.isNotEmpty)
|
||||
FocusableButton(
|
||||
focusNode: _clearFocusNode,
|
||||
onPressed: _clearSearch,
|
||||
onNavigateLeft: searchFocusNode.requestFocus,
|
||||
onNavigateDown: searchResults.isNotEmpty && !isSearching ? firstResultFocusNode.requestFocus : null,
|
||||
autoScroll: false,
|
||||
child: IconButton(icon: const AppIcon(Symbols.clear_rounded, fill: 1), onPressed: _clearSearch),
|
||||
),
|
||||
],
|
||||
),
|
||||
onEditingComplete: PlatformDetector.isTV() ? handleSearchSubmit : null,
|
||||
),
|
||||
),
|
||||
if (isSearching)
|
||||
@@ -125,11 +87,7 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
|
||||
}
|
||||
|
||||
Widget _buildResultsList() {
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
return buildResultsSliver((context, index) {
|
||||
final item = searchResults[index];
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.globalKey),
|
||||
@@ -139,12 +97,6 @@ class _CatalogSearchScreenState extends State<CatalogSearchScreen> with Debounce
|
||||
focusNode: index == 0 ? firstResultFocusNode : null,
|
||||
onNavigateUp: index == 0 ? searchFocusNode.requestFocus : null,
|
||||
);
|
||||
},
|
||||
childCount: searchResults.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
addSemanticIndexes: false,
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../media/ids.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../media/library_query.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
import '../mixins/standard_paginated_view.dart';
|
||||
import '../providers/download_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/error_message_utils.dart';
|
||||
import '../utils/download_utils.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
import '../utils/media_server_http_client.dart';
|
||||
import '../utils/snackbar_helper.dart';
|
||||
import '../widgets/desktop_app_bar.dart';
|
||||
@@ -35,7 +35,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
with
|
||||
GridFocusNodeMixin<CollectionDetailScreen>,
|
||||
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
||||
PaginatedItemLoader<MediaItem, CollectionDetailScreen> {
|
||||
PaginatedItemLoader<MediaItem, CollectionDetailScreen>,
|
||||
PaginatedItemUpdatable<CollectionDetailScreen>,
|
||||
StandardPaginatedView<MediaItem, CollectionDetailScreen> {
|
||||
static const int _pageSize = 200;
|
||||
|
||||
@override
|
||||
@@ -75,49 +77,21 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
}
|
||||
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||
// Search [loadedItems] (not the flat [items] snapshot, which only has
|
||||
// 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(
|
||||
Future<void> loadItems() {
|
||||
return loadStandardPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, stackTrace) {
|
||||
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: t.collections.collection);
|
||||
isLoading = false;
|
||||
},
|
||||
errorMessageFor: (error, stackTrace) =>
|
||||
localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection),
|
||||
onLoaded: (loadedCount, totalCount) {
|
||||
appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}');
|
||||
autoFocusFirstItemAfterLoad();
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
List<FocusableAction> getAppBarActions() {
|
||||
final ruleKey = _collectionSyncRuleKey();
|
||||
final ruleKey = syncRuleKey;
|
||||
// Select the specific bool we care about so unrelated DownloadProvider
|
||||
// ticks (e.g. active download progress) don't rebuild the app bar.
|
||||
final hasRule = context.select<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.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
||||
],
|
||||
if (!PlatformDetector.isAppleTV())
|
||||
FocusableAction(
|
||||
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
|
||||
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
|
||||
onPressed: hasRule ? _manageCollectionSyncRule : _downloadCollection,
|
||||
iconColor: hasRule ? Colors.teal : null,
|
||||
),
|
||||
if (!PlatformDetector.isAppleTV() && hasRule)
|
||||
FocusableAction(
|
||||
icon: Symbols.sync_disabled_rounded,
|
||||
tooltip: t.downloads.removeSyncRule,
|
||||
onPressed: _removeCollectionSyncRule,
|
||||
// Emptiness is handled inside [_downloadCollection], so the download
|
||||
// entry stays visible for empty collections.
|
||||
...buildSyncRuleActions(
|
||||
context,
|
||||
ruleKey: ruleKey,
|
||||
displayTitle: widget.collection.displayTitle,
|
||||
hasRule: hasRule,
|
||||
showDownload: true,
|
||||
onDownload: _downloadCollection,
|
||||
),
|
||||
FocusableAction(
|
||||
icon: Symbols.delete_rounded,
|
||||
@@ -164,9 +135,10 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
||||
libraryTitle: widget.collection.libraryTitle,
|
||||
);
|
||||
if (!mounted) return;
|
||||
final result = await showCollectionDownloadOptionsAndQueue(
|
||||
final result = await showListDownloadOptionsAndQueue(
|
||||
context,
|
||||
collectionMetadata: widget.collection,
|
||||
rootMetadata: widget.collection,
|
||||
targetType: ContentTypes.collection,
|
||||
items: allItems,
|
||||
client: mediaClient,
|
||||
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 {
|
||||
final confirmed = await showDeleteConfirmation(
|
||||
context,
|
||||
|
||||
@@ -21,7 +21,7 @@ import '../utils/media_image_helper.dart';
|
||||
import '../utils/content_utils.dart';
|
||||
import '../widgets/cycling_media_backdrop.dart';
|
||||
import '../widgets/optimized_media_image.dart' show ClearLogoImage, blurArtwork;
|
||||
import '../widgets/rasterized_gradient.dart';
|
||||
import '../widgets/toolbar_scrim.dart';
|
||||
import '../providers/discover_provider.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../providers/watch_state_store.dart';
|
||||
@@ -47,6 +47,7 @@ import '../i18n/strings.g.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/formatters.dart';
|
||||
import '../utils/hub_icons.dart';
|
||||
import '../utils/media_navigation_helper.dart';
|
||||
import '../utils/provider_extensions.dart';
|
||||
import '../utils/video_player_navigation.dart';
|
||||
@@ -176,8 +177,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!;
|
||||
final hubs = <MediaHub>[];
|
||||
if (_onDeck.isNotEmpty) {
|
||||
hubs.add(
|
||||
MediaHub(
|
||||
hubs.add(_continueWatchingHub);
|
||||
}
|
||||
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',
|
||||
title: t.discover.continueWatching,
|
||||
type: 'mixed',
|
||||
@@ -185,14 +195,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
||||
more: _hasMoreContinueWatching,
|
||||
items: _onDeck,
|
||||
),
|
||||
);
|
||||
}
|
||||
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
|
||||
_tvBrowseHubsCache = hubs;
|
||||
_tvBrowseHubsCacheKey = key;
|
||||
return hubs;
|
||||
}
|
||||
|
||||
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
|
||||
|
||||
@@ -597,101 +600,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
unawaited(_discover.load());
|
||||
}
|
||||
|
||||
/// Get icon for hub based on its title
|
||||
IconData _getHubIcon(String title) {
|
||||
final lowerTitle = title.toLowerCase();
|
||||
|
||||
// Trending/Popular content
|
||||
if (lowerTitle.contains('trending')) {
|
||||
return Symbols.trending_up_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) {
|
||||
return Symbols.whatshot_rounded;
|
||||
}
|
||||
|
||||
// Seasonal/Time-based
|
||||
if (lowerTitle.contains('seasonal')) {
|
||||
return Symbols.calendar_month_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) {
|
||||
return Symbols.new_releases_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('recently released') || lowerTitle.contains('recent')) {
|
||||
return Symbols.schedule_rounded;
|
||||
}
|
||||
|
||||
// Top/Rated content
|
||||
if (lowerTitle.contains('top rated') || lowerTitle.contains('highest rated')) {
|
||||
return Symbols.star_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('top ')) {
|
||||
return Symbols.military_tech_rounded;
|
||||
}
|
||||
|
||||
// Genre-specific
|
||||
if (lowerTitle.contains('thriller')) {
|
||||
return Symbols.warning_amber_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) {
|
||||
return Symbols.mood_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('action')) {
|
||||
return Symbols.flash_on_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('drama')) {
|
||||
return Symbols.theater_comedy_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('fantasy')) {
|
||||
return Symbols.auto_fix_high_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) {
|
||||
return Symbols.rocket_launch_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) {
|
||||
return Symbols.nights_stay_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) {
|
||||
return Symbols.favorite_border_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) {
|
||||
return Symbols.explore_rounded;
|
||||
}
|
||||
|
||||
// Watchlist/Playlists
|
||||
if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) {
|
||||
return Symbols.playlist_play_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) {
|
||||
return Symbols.visibility_off_rounded;
|
||||
}
|
||||
if (lowerTitle.contains('watched') || lowerTitle.contains('played')) {
|
||||
return Symbols.visibility_rounded;
|
||||
}
|
||||
|
||||
// Network/Studio
|
||||
if (lowerTitle.contains('network') || lowerTitle.contains('more from')) {
|
||||
return Symbols.tv_rounded;
|
||||
}
|
||||
|
||||
// Actor/Director
|
||||
if (lowerTitle.contains('actor') || lowerTitle.contains('director')) {
|
||||
return Symbols.person_rounded;
|
||||
}
|
||||
|
||||
// Year-based (80s, 90s, etc.)
|
||||
if (lowerTitle.contains('80') || lowerTitle.contains('90') || lowerTitle.contains('00')) {
|
||||
return Symbols.history_rounded;
|
||||
}
|
||||
|
||||
// Rediscover/Start Watching
|
||||
if (lowerTitle.contains('rediscover') || lowerTitle.contains('start watching')) {
|
||||
return Symbols.play_arrow_rounded;
|
||||
}
|
||||
|
||||
// Default icon for other hubs
|
||||
return Symbols.auto_awesome_rounded;
|
||||
}
|
||||
|
||||
/// Whether the loaded hubs span more than one connected server.
|
||||
bool _hubsSpanMultipleServers() {
|
||||
final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet();
|
||||
@@ -815,26 +723,9 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
}
|
||||
|
||||
Widget _buildOverlaidAppBar() {
|
||||
final statusBarHeight = MediaQuery.paddingOf(context).top;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
|
||||
final foregroundColor = colorScheme.onSurface;
|
||||
return RasterizedGradient(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
overlayColor.withValues(alpha: 0.7),
|
||||
overlayColor.withValues(alpha: 0.5),
|
||||
overlayColor.withValues(alpha: 0.3),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.3, 0.6, 1.0],
|
||||
),
|
||||
child: Padding(
|
||||
padding: .only(top: statusBarHeight, left: 16, right: 16, bottom: 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
return ToolbarScrim(
|
||||
child: Row(
|
||||
children: [
|
||||
if (!PlatformDetector.isTV())
|
||||
@@ -852,11 +743,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
onNavigateLeft: _navigateToSidebar,
|
||||
onNavigateDown: _focusContentFromAppBar,
|
||||
actions: [
|
||||
FocusableAction(
|
||||
icon: Symbols.refresh_rounded,
|
||||
iconColor: foregroundColor,
|
||||
onPressed: _discover.load,
|
||||
),
|
||||
FocusableAction(icon: Symbols.refresh_rounded, iconColor: foregroundColor, onPressed: _discover.load),
|
||||
// Watch Together
|
||||
FocusableAction(
|
||||
onPressed: () =>
|
||||
@@ -869,10 +756,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
fill: watchTogether.isInSession ? 1 : 0,
|
||||
color: watchTogether.isInSession ? colorScheme.primary : foregroundColor,
|
||||
),
|
||||
onPressed: () => Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => const WatchTogetherScreen()),
|
||||
),
|
||||
onPressed: () =>
|
||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())),
|
||||
tooltip: t.watchTogether.title,
|
||||
),
|
||||
if (watchTogether.isInSession && watchTogether.participantCount > 1)
|
||||
@@ -900,10 +785,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (isDesktop) {
|
||||
RemoteSessionDialog.show(context);
|
||||
} else {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (context) => const MobileRemoteScreen()),
|
||||
);
|
||||
Navigator.push(context, MaterialPageRoute(builder: (context) => const MobileRemoteScreen()));
|
||||
}
|
||||
},
|
||||
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 theme = Theme.of(context);
|
||||
final continueWatchingHub = _onDeck.isEmpty ? null : _continueWatchingHub;
|
||||
return Material(
|
||||
color: theme.scaffoldBackgroundColor,
|
||||
child: Stack(
|
||||
@@ -1016,21 +897,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
||||
if (!_isLoading && _errorMessage == null) ...[
|
||||
// On Deck / Continue Watching
|
||||
if (_onDeck.isNotEmpty)
|
||||
if (continueWatchingHub != null)
|
||||
SliverToBoxAdapter(
|
||||
child: HubSection(
|
||||
key: _continueWatchingHubKey,
|
||||
hub: MediaHub(
|
||||
id: 'continue_watching',
|
||||
title: t.discover.continueWatching,
|
||||
type: 'mixed',
|
||||
identifier: '_continue_watching_',
|
||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
||||
more: _hasMoreContinueWatching,
|
||||
items: _onDeck,
|
||||
),
|
||||
hub: continueWatchingHub,
|
||||
focusMemory: _hubFocusMemory,
|
||||
icon: Symbols.play_circle_rounded,
|
||||
icon: hubIconFor(continueWatchingHub),
|
||||
onRefresh: _discover.updateItem,
|
||||
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
||||
isInContinueWatching: true,
|
||||
@@ -1048,7 +921,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null,
|
||||
hub: _hubs[i],
|
||||
focusMemory: _hubFocusMemory,
|
||||
icon: _getHubIcon(_hubs[i].title),
|
||||
icon: hubIconFor(_hubs[i]),
|
||||
showServerName: showServerNameOnHubs || hubsSpanMultipleServers,
|
||||
onRefresh: _discover.updateItem,
|
||||
// Hub index is i + 1 if continue watching exists, otherwise i
|
||||
@@ -1135,7 +1008,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
initialHubId: 'continue_watching',
|
||||
focusMemory: _hubFocusMemory,
|
||||
showServerName: showServerName,
|
||||
iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title),
|
||||
iconForHub: (hub, _) => hubIconFor(hub),
|
||||
onFocusedItemChanged: _setSpotlightItem,
|
||||
onRefresh: _discover.updateItem,
|
||||
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
||||
@@ -1155,7 +1028,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs);
|
||||
final hubsSpanMultipleServers = _hubsSpanMultipleServers();
|
||||
final browseHubs = _tvBrowseHubs;
|
||||
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
|
||||
|
||||
return TvSpotlightScaffold(
|
||||
hubs: browseHubs,
|
||||
@@ -1189,14 +1061,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
bottom: 0,
|
||||
child: _cachedTvBrowseRail(browseHubs, showServerName: showServerNameOnHubs || hubsSpanMultipleServers),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) => SideNavigationBleedBuilder(
|
||||
targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context),
|
||||
child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()),
|
||||
builder: (context, animatedBleed, child) =>
|
||||
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||
),
|
||||
),
|
||||
TvToolbarOverlay(child: _buildOverlaidAppBar()),
|
||||
if (_switchingProfile) const ProfileSwitchingOverlay(),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -28,7 +28,7 @@ import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/hub_section.dart';
|
||||
import '../widgets/focusable_popup_menu_button.dart';
|
||||
import '../widgets/settings_builder.dart';
|
||||
import '../widgets/rasterized_gradient.dart';
|
||||
import '../widgets/toolbar_scrim.dart';
|
||||
import '../widgets/tv_browse_rail.dart';
|
||||
import '../widgets/tv_spotlight_scaffold.dart';
|
||||
import 'catalog_search_screen.dart';
|
||||
@@ -332,25 +332,9 @@ class ExploreScreenState extends State<ExploreScreen>
|
||||
|
||||
Widget _buildTvToolbar(CatalogSourcesProvider sources) {
|
||||
final active = sources.activeSource;
|
||||
final statusBarHeight = MediaQuery.paddingOf(context).top;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface;
|
||||
final foregroundColor = colorScheme.onSurface;
|
||||
final foregroundColor = Theme.of(context).colorScheme.onSurface;
|
||||
|
||||
return RasterizedGradient(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
overlayColor.withValues(alpha: 0.7),
|
||||
overlayColor.withValues(alpha: 0.5),
|
||||
overlayColor.withValues(alpha: 0.3),
|
||||
Colors.transparent,
|
||||
],
|
||||
stops: const [0.0, 0.3, 0.6, 1.0],
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(top: statusBarHeight + 8, left: 16, right: 16, bottom: 16),
|
||||
return ToolbarScrim(
|
||||
child: Row(
|
||||
children: [
|
||||
const Spacer(),
|
||||
@@ -394,13 +378,11 @@ class ExploreScreenState extends State<ExploreScreen>
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTvContent(List<ExploreRowHub> rowHubs, CatalogSourcesProvider sources) {
|
||||
final tvHubs = [for (final rowHub in rowHubs) rowHub.hub];
|
||||
final fullBleedWidth = MainScreenFocusScope.fullBleedWidthOf(context);
|
||||
return TvSpotlightScaffold(
|
||||
hubs: tvHubs,
|
||||
spotlightListenable: _spotlight,
|
||||
@@ -448,14 +430,7 @@ class ExploreScreenState extends State<ExploreScreen>
|
||||
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
|
||||
),
|
||||
),
|
||||
Builder(
|
||||
builder: (context) => SideNavigationBleedBuilder(
|
||||
targetBleed: MainScreenFocusScope.sideNavigationBleedOf(context),
|
||||
child: ExcludeFocusTraversal(child: _buildTvToolbar(sources)),
|
||||
builder: (context, animatedBleed, child) =>
|
||||
Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!),
|
||||
),
|
||||
),
|
||||
TvToolbarOverlay(child: _buildTvToolbar(sources)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,6 @@ import '../focus/input_mode_tracker.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../i18n/strings.g.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_playlist.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../services/settings_service.dart';
|
||||
import '../utils/platform_detector.dart';
|
||||
@@ -15,14 +14,6 @@ import '../widgets/media_card_sliver_layout.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../widgets/skeleton_media_card.dart';
|
||||
|
||||
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
|
||||
/// Flutter widget Key.
|
||||
String _idForItem(Object item) {
|
||||
if (item is MediaItem) return item.id;
|
||||
if (item is MediaPlaylist) return item.id;
|
||||
return identityHashCode(item).toString();
|
||||
}
|
||||
|
||||
/// Mixin that provides common focus navigation functionality for detail screens.
|
||||
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
|
||||
///
|
||||
@@ -164,56 +155,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
/// Used by collection, smart playlist, and music artist detail screens.
|
||||
/// [shape] overrides the grid cell silhouette (e.g. [CardShape.square]
|
||||
/// for album grids); null keeps the stock poster geometry.
|
||||
///
|
||||
/// Fully-loaded case of [buildSparseFocusableGrid]: every slot resolves to an
|
||||
/// item, so the skeleton branch is unreachable.
|
||||
Widget buildFocusableGrid({
|
||||
required List<dynamic> items,
|
||||
required List<MediaItem> items,
|
||||
required void Function(MediaItem source) onRefresh,
|
||||
String? collectionId,
|
||||
VoidCallback? onListRefresh,
|
||||
CardShape? shape,
|
||||
}) {
|
||||
return SettingsBuilder(
|
||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
||||
builder: (context) {
|
||||
final svc = SettingsService.instance;
|
||||
final viewMode = svc.read(SettingsService.viewMode);
|
||||
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
|
||||
|
||||
return MediaCardSliverLayout(
|
||||
viewMode: viewMode,
|
||||
itemCount: items.length,
|
||||
density: libraryDensity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
fullBleedImage: useFullCardLayout,
|
||||
shape: shape,
|
||||
itemBuilder: (context, position) {
|
||||
final index = position.index;
|
||||
final item = items[index];
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
|
||||
return FocusableMediaCard(
|
||||
key: Key(_idForItem(item)),
|
||||
item: item,
|
||||
focusNode: focusNode,
|
||||
semanticValue: _semanticPosition(position),
|
||||
disableScale: position.disableScale,
|
||||
return buildSparseFocusableGrid(
|
||||
totalItems: items.length,
|
||||
itemAt: (index) => items[index],
|
||||
onRefresh: onRefresh,
|
||||
collectionId: collectionId,
|
||||
onListRefresh: onListRefresh,
|
||||
fullBleedImage: useFullCardLayout && position.isGrid,
|
||||
cardShapeOverride: shape,
|
||||
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
||||
onBack: handleBackFromContent,
|
||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
shape: shape,
|
||||
);
|
||||
}
|
||||
|
||||
/// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems]
|
||||
/// Sparse-loading counterpart of [buildFocusableGrid]. Renders [totalItems]
|
||||
/// slots; for each, [itemAt] returns the loaded item or null if not yet
|
||||
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
|
||||
/// the caller can kick off a page fetch containing that index.
|
||||
@@ -242,7 +204,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
||||
onSkeletonVisible?.call(index);
|
||||
return const SkeletonMediaCard();
|
||||
}
|
||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item');
|
||||
final focusNode = _focusNodeForIndex(index);
|
||||
return FocusableMediaCard(
|
||||
key: Key(item.id),
|
||||
item: item,
|
||||
|
||||
@@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart';
|
||||
import '../widgets/loading_indicator_box.dart';
|
||||
import '../widgets/overlay_sheet.dart';
|
||||
import '../focus/focusable_action_bar.dart';
|
||||
import '../focus/focusable_button.dart';
|
||||
import '../focus/key_event_utils.dart';
|
||||
import '../mixins/grid_focus_node_mixin.dart';
|
||||
import '../mixins/paginated_item_loader.dart';
|
||||
@@ -493,34 +492,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error;
|
||||
bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading;
|
||||
|
||||
Widget _buildContinuationStatusSliver() {
|
||||
final exception = _pageLoadError;
|
||||
final error = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: error == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(error, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
FocusableButton(
|
||||
focusNode: _continuationRetryFocusNode,
|
||||
onPressed: _retryHubContinuation,
|
||||
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
|
||||
onBack: handleBackFromContent,
|
||||
child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void refresh() {
|
||||
_loadMoreItems();
|
||||
@@ -633,7 +604,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
},
|
||||
),
|
||||
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:material_symbols_icons/symbols.dart';
|
||||
import '../../focus/focusable_button.dart';
|
||||
import '../../i18n/strings.g.dart';
|
||||
import 'state_messages.dart';
|
||||
|
||||
@@ -10,9 +11,7 @@ class SliverErrorState extends StatelessWidget {
|
||||
final String? retryLabel;
|
||||
final FocusNode? actionFocusNode;
|
||||
final VoidCallback? onActionNavigateUp;
|
||||
final VoidCallback? onActionNavigateDown;
|
||||
final VoidCallback? onActionNavigateLeft;
|
||||
final VoidCallback? onActionNavigateRight;
|
||||
final VoidCallback? onActionBack;
|
||||
final bool actionAutofocus;
|
||||
final bool actionUseBackgroundFocus;
|
||||
@@ -24,9 +23,7 @@ class SliverErrorState extends StatelessWidget {
|
||||
this.retryLabel,
|
||||
this.actionFocusNode,
|
||||
this.onActionNavigateUp,
|
||||
this.onActionNavigateDown,
|
||||
this.onActionNavigateLeft,
|
||||
this.onActionNavigateRight,
|
||||
this.onActionBack,
|
||||
this.actionAutofocus = false,
|
||||
this.actionUseBackgroundFocus = false,
|
||||
@@ -41,9 +38,7 @@ class SliverErrorState extends StatelessWidget {
|
||||
retryLabel: retryLabel,
|
||||
actionFocusNode: actionFocusNode,
|
||||
onActionNavigateUp: onActionNavigateUp,
|
||||
onActionNavigateDown: onActionNavigateDown,
|
||||
onActionNavigateLeft: onActionNavigateLeft,
|
||||
onActionNavigateRight: onActionNavigateRight,
|
||||
onActionBack: onActionBack,
|
||||
actionAutofocus: actionAutofocus,
|
||||
actionUseBackgroundFocus: actionUseBackgroundFocus,
|
||||
@@ -61,9 +56,7 @@ class SliverEmptyState extends StatelessWidget {
|
||||
final IconData? actionIcon;
|
||||
final FocusNode? actionFocusNode;
|
||||
final VoidCallback? onActionNavigateUp;
|
||||
final VoidCallback? onActionNavigateDown;
|
||||
final VoidCallback? onActionNavigateLeft;
|
||||
final VoidCallback? onActionNavigateRight;
|
||||
final VoidCallback? onActionBack;
|
||||
|
||||
const SliverEmptyState({
|
||||
@@ -76,9 +69,7 @@ class SliverEmptyState extends StatelessWidget {
|
||||
this.actionIcon,
|
||||
this.actionFocusNode,
|
||||
this.onActionNavigateUp,
|
||||
this.onActionNavigateDown,
|
||||
this.onActionNavigateLeft,
|
||||
this.onActionNavigateRight,
|
||||
this.onActionBack,
|
||||
});
|
||||
|
||||
@@ -93,14 +84,61 @@ class SliverEmptyState extends StatelessWidget {
|
||||
actionIcon: actionIcon,
|
||||
actionFocusNode: actionFocusNode,
|
||||
onActionNavigateUp: onActionNavigateUp,
|
||||
onActionNavigateDown: onActionNavigateDown,
|
||||
onActionNavigateLeft: onActionNavigateLeft,
|
||||
onActionNavigateRight: onActionNavigateRight,
|
||||
onActionBack: onActionBack,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Footer sliver for continuation (append-to-list) pagination: a spinner while
|
||||
/// the next page loads, or the error message with a focusable retry button.
|
||||
class ContinuationStatusSliver extends StatelessWidget {
|
||||
/// Failure from the last page load; null while the page is still loading.
|
||||
final Object? error;
|
||||
final VoidCallback onRetry;
|
||||
final FocusNode retryFocusNode;
|
||||
final VoidCallback? onNavigateUp;
|
||||
final VoidCallback? onBack;
|
||||
|
||||
const ContinuationStatusSliver({
|
||||
super.key,
|
||||
required this.error,
|
||||
required this.onRetry,
|
||||
required this.retryFocusNode,
|
||||
this.onNavigateUp,
|
||||
this.onBack,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final exception = error;
|
||||
final message = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
||||
return SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Center(
|
||||
child: message == null
|
||||
? const CircularProgressIndicator()
|
||||
: Column(
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
Text(message, textAlign: TextAlign.center),
|
||||
const SizedBox(height: 8),
|
||||
FocusableButton(
|
||||
focusNode: retryFocusNode,
|
||||
onPressed: onRetry,
|
||||
onNavigateUp: onNavigateUp,
|
||||
onBack: onBack,
|
||||
child: TextButton(onPressed: onRetry, child: Text(t.common.retry)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A widget that handles loading, error, empty, and content states
|
||||
/// Provides a consistent UI pattern across the app for data-driven screens
|
||||
class ContentStateBuilder<T> extends StatelessWidget {
|
||||
|
||||
@@ -6,6 +6,7 @@ import '../../media/media_item.dart';
|
||||
import '../../media/media_kind.dart';
|
||||
import '../../media/media_server_client.dart';
|
||||
import '../../services/jellyfin_sequential_launcher.dart';
|
||||
import '../../services/media_list_playback_launcher.dart';
|
||||
import '../../services/play_queue_launcher.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
@@ -243,42 +244,19 @@ class FolderTreeViewState extends State<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) {
|
||||
final launcher = JellyfinSequentialLauncher(context: context);
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: false);
|
||||
return;
|
||||
}
|
||||
|
||||
final folderKey = folder.backendFolderKey;
|
||||
if (folderKey == null) return;
|
||||
launcher = JellyfinSequentialLauncher(context: context);
|
||||
} else {
|
||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
||||
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
await launcher.launchFromFolder(
|
||||
folderKey: folderKey,
|
||||
shuffle: false,
|
||||
libraryId: folder.libraryId,
|
||||
libraryTitle: folder.libraryTitle,
|
||||
);
|
||||
launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
await launcher.launchFromFolder(folder: folder, shuffle: shuffle);
|
||||
}
|
||||
|
||||
/// Expandable rows: directory rows plus Jellyfin media containers whose
|
||||
@@ -400,8 +378,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
||||
serverId: widget.serverId,
|
||||
onExpand: isExpandable ? () => _toggleFolder(item) : null,
|
||||
onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null,
|
||||
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null,
|
||||
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
|
||||
onPlayAll: canPlayFolder ? () => _launchFolder(item, shuffle: false) : null,
|
||||
onShuffle: canPlayFolder ? () => _launchFolder(item, shuffle: true) : null,
|
||||
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
|
||||
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
|
||||
onNavigateLeft: widget.onNavigateLeft,
|
||||
|
||||
@@ -39,9 +39,7 @@ class StateMessageWidget extends StatelessWidget {
|
||||
final IconData? actionIcon;
|
||||
final FocusNode? actionFocusNode;
|
||||
final VoidCallback? onActionNavigateUp;
|
||||
final VoidCallback? onActionNavigateDown;
|
||||
final VoidCallback? onActionNavigateLeft;
|
||||
final VoidCallback? onActionNavigateRight;
|
||||
final VoidCallback? onActionBack;
|
||||
|
||||
/// Whether the action button should request focus when it appears.
|
||||
@@ -63,9 +61,7 @@ class StateMessageWidget extends StatelessWidget {
|
||||
this.actionLabel,
|
||||
this.actionFocusNode,
|
||||
this.onActionNavigateUp,
|
||||
this.onActionNavigateDown,
|
||||
this.onActionNavigateLeft,
|
||||
this.onActionNavigateRight,
|
||||
this.onActionBack,
|
||||
this.actionIcon,
|
||||
this.actionAutofocus = false,
|
||||
@@ -110,9 +106,7 @@ class StateMessageWidget extends StatelessWidget {
|
||||
FocusableButton(
|
||||
focusNode: actionFocusNode,
|
||||
onNavigateUp: onActionNavigateUp,
|
||||
onNavigateDown: onActionNavigateDown,
|
||||
onNavigateLeft: onActionNavigateLeft,
|
||||
onNavigateRight: onActionNavigateRight,
|
||||
onBack: onActionBack,
|
||||
onPressed: onAction,
|
||||
autofocus: actionAutofocus,
|
||||
@@ -155,9 +149,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
final IconData? actionIcon;
|
||||
final FocusNode? actionFocusNode;
|
||||
final VoidCallback? onActionNavigateUp;
|
||||
final VoidCallback? onActionNavigateDown;
|
||||
final VoidCallback? onActionNavigateLeft;
|
||||
final VoidCallback? onActionNavigateRight;
|
||||
final VoidCallback? onActionBack;
|
||||
|
||||
const EmptyStateWidget({
|
||||
@@ -171,9 +163,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
this.actionIcon,
|
||||
this.actionFocusNode,
|
||||
this.onActionNavigateUp,
|
||||
this.onActionNavigateDown,
|
||||
this.onActionNavigateLeft,
|
||||
this.onActionNavigateRight,
|
||||
this.onActionBack,
|
||||
});
|
||||
|
||||
@@ -189,9 +179,7 @@ class EmptyStateWidget extends StatelessWidget {
|
||||
actionIcon: actionIcon ?? Symbols.add_rounded,
|
||||
actionFocusNode: actionFocusNode,
|
||||
onActionNavigateUp: onActionNavigateUp,
|
||||
onActionNavigateDown: onActionNavigateDown,
|
||||
onActionNavigateLeft: onActionNavigateLeft,
|
||||
onActionNavigateRight: onActionNavigateRight,
|
||||
onActionBack: onActionBack,
|
||||
);
|
||||
}
|
||||
@@ -218,9 +206,7 @@ class ErrorStateWidget extends StatelessWidget {
|
||||
final String? retryLabel;
|
||||
final FocusNode? actionFocusNode;
|
||||
final VoidCallback? onActionNavigateUp;
|
||||
final VoidCallback? onActionNavigateDown;
|
||||
final VoidCallback? onActionNavigateLeft;
|
||||
final VoidCallback? onActionNavigateRight;
|
||||
final VoidCallback? onActionBack;
|
||||
|
||||
const ErrorStateWidget({
|
||||
@@ -231,9 +217,7 @@ class ErrorStateWidget extends StatelessWidget {
|
||||
this.retryLabel,
|
||||
this.actionFocusNode,
|
||||
this.onActionNavigateUp,
|
||||
this.onActionNavigateDown,
|
||||
this.onActionNavigateLeft,
|
||||
this.onActionNavigateRight,
|
||||
this.onActionBack,
|
||||
this.actionAutofocus = false,
|
||||
this.actionUseBackgroundFocus = false,
|
||||
@@ -251,9 +235,7 @@ class ErrorStateWidget extends StatelessWidget {
|
||||
actionIcon: Symbols.refresh_rounded,
|
||||
actionFocusNode: actionFocusNode,
|
||||
onActionNavigateUp: onActionNavigateUp,
|
||||
onActionNavigateDown: onActionNavigateDown,
|
||||
onActionNavigateLeft: onActionNavigateLeft,
|
||||
onActionNavigateRight: onActionNavigateRight,
|
||||
onActionBack: onActionBack,
|
||||
actionAutofocus: actionAutofocus,
|
||||
actionUseBackgroundFocus: actionUseBackgroundFocus,
|
||||
|
||||
@@ -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.
|
||||
@protected
|
||||
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/overlay_sheet.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import '../../../services/plex_client.dart';
|
||||
import '../folder_tree_view.dart';
|
||||
import '../filters_bottom_sheet.dart';
|
||||
import '../sort_bottom_sheet.dart';
|
||||
@@ -55,6 +54,7 @@ import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../mixins/deletion_aware.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../mixins/standard_paginated_view.dart';
|
||||
import '../../../widgets/card_inflation_budget.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../widgets/sliver_child_memo.dart';
|
||||
@@ -104,13 +104,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
GridFocusNodeMixin,
|
||||
WatchStateAware,
|
||||
DeletionAware,
|
||||
DeletionMirrorsWatchState,
|
||||
PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
|
||||
PaginatedItemUpdatable<LibraryBrowseTab>,
|
||||
SkeletonUpgradeScheduler {
|
||||
String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey);
|
||||
|
||||
@override
|
||||
String? get deletionServerId => widget.library.serverId;
|
||||
|
||||
// DeletionMirrorsWatchState points the deletion filters at these three: the
|
||||
// grid shows the same loaded items for both event families.
|
||||
@override
|
||||
String? get watchStateServerId => widget.library.serverId;
|
||||
|
||||
@@ -130,22 +131,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
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
|
||||
void onWatchStateChanged(WatchStateEvent event) {
|
||||
if (event.changeType == WatchStateChangeType.progressUpdate ||
|
||||
@@ -213,16 +198,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
@override
|
||||
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)
|
||||
List<MediaFilter> _filters = [];
|
||||
List<MediaSort> _sortOptions = [];
|
||||
@@ -1061,8 +1036,8 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
||||
Future<List<MediaFilterValue>> _loadFilterValues(MediaFilter filter) async {
|
||||
if (!mounted) return const [];
|
||||
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(widget.library.serverId));
|
||||
if (client is PlexClient) return client.getFilterValues(filter.key);
|
||||
final client = context.tryGetPlexClientForServer(serverIdOrNull(widget.library.serverId));
|
||||
if (client != null) return client.getFilterValues(filter.key);
|
||||
|
||||
// Jellyfin's canonical filter values come from the cached `/Items/Filters`
|
||||
// payload. If that payload missed a category, there is no neutral endpoint
|
||||
|
||||
@@ -1,25 +1,12 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
import '../../../focus/input_mode_tracker.dart';
|
||||
import '../../../media/library_query.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/error_message_utils.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
import '../../../utils/library_refresh_notifier.dart';
|
||||
import '../../../utils/media_server_http_client.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../widgets/card_inflation_budget.dart';
|
||||
import '../../../widgets/focusable_media_card.dart';
|
||||
import '../../../widgets/media_card_sliver_layout.dart';
|
||||
import '../../../widgets/settings_builder.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../widgets/sliver_child_memo.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
import 'paginated_card_grid_tab.dart';
|
||||
|
||||
/// Collections tab for library screen.
|
||||
/// Plex scopes collections to the library; Jellyfin exposes a shared BoxSets root.
|
||||
@@ -39,23 +26,13 @@ class LibraryCollectionsTab extends BaseLibraryTab<MediaItem> {
|
||||
State<LibraryCollectionsTab> createState() => _LibraryCollectionsTabState();
|
||||
}
|
||||
|
||||
class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, LibraryCollectionsTab>
|
||||
with
|
||||
LibraryTabFocusMixin<LibraryCollectionsTab>,
|
||||
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>();
|
||||
class _LibraryCollectionsTabState extends PaginatedCardGridTabState<MediaItem, LibraryCollectionsTab> {
|
||||
@override
|
||||
int get pageSize => 36;
|
||||
|
||||
@override
|
||||
String get focusNodeDebugLabel => 'collections_first_item';
|
||||
|
||||
@override
|
||||
int get itemCount => totalSize;
|
||||
|
||||
@override
|
||||
IconData get emptyIcon => Symbols.collections_rounded;
|
||||
|
||||
@@ -69,7 +46,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().collectionsStream;
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> loadData() async => const [];
|
||||
String idOf(MediaItem item) => item.id;
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaItem>> fetchPage(int start, int size, AbortController? abort) {
|
||||
@@ -78,66 +55,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
String? loadErrorMessage;
|
||||
await loadInitialPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, stackTrace) {
|
||||
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext);
|
||||
isLoading = false;
|
||||
},
|
||||
onLoaded: (_, _) {
|
||||
hasLoadedData = true;
|
||||
tryFocus();
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onDataLoaded!();
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<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 {
|
||||
bool get usesSquareCards {
|
||||
final loaded = loadedItems.values;
|
||||
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.
|
||||
bool _isMusicCollection(MediaItem item) =>
|
||||
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:material_symbols_icons/symbols.dart';
|
||||
import '../../../focus/input_mode_tracker.dart';
|
||||
import '../../../media/library_query.dart';
|
||||
import '../../../media/media_item.dart';
|
||||
import '../../../media/media_kind.dart';
|
||||
import '../../../media/media_playlist.dart';
|
||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||
import '../../../mixins/paginated_item_loader.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/error_message_utils.dart';
|
||||
import '../../../utils/layout_constants.dart';
|
||||
import '../../../utils/library_refresh_notifier.dart';
|
||||
import '../../../utils/media_server_http_client.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../widgets/card_inflation_budget.dart';
|
||||
import '../../../widgets/focusable_media_card.dart';
|
||||
import '../../../widgets/media_card_sliver_layout.dart';
|
||||
import '../../../widgets/settings_builder.dart';
|
||||
import '../../../widgets/skeleton_media_card.dart';
|
||||
import '../../../widgets/sliver_child_memo.dart';
|
||||
import '../../../i18n/strings.g.dart';
|
||||
import '../../main_screen.dart';
|
||||
import 'base_library_tab.dart';
|
||||
import 'paginated_card_grid_tab.dart';
|
||||
|
||||
/// Playlists tab for library screen
|
||||
/// Shows playlists that contain items from the current library
|
||||
@@ -41,23 +27,13 @@ class LibraryPlaylistsTab extends BaseLibraryTab<MediaPlaylist> {
|
||||
State<LibraryPlaylistsTab> createState() => _LibraryPlaylistsTabState();
|
||||
}
|
||||
|
||||
class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, LibraryPlaylistsTab>
|
||||
with
|
||||
LibraryTabFocusMixin<LibraryPlaylistsTab>,
|
||||
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>();
|
||||
class _LibraryPlaylistsTabState extends PaginatedCardGridTabState<MediaPlaylist, LibraryPlaylistsTab> {
|
||||
@override
|
||||
int get pageSize => 200;
|
||||
|
||||
@override
|
||||
String get focusNodeDebugLabel => 'playlists_first_item';
|
||||
|
||||
@override
|
||||
int get itemCount => totalSize;
|
||||
|
||||
@override
|
||||
IconData get emptyIcon => Symbols.playlist_play_rounded;
|
||||
|
||||
@@ -71,7 +47,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
||||
Stream<void>? getRefreshStream() => LibraryRefreshNotifier().playlistsStream;
|
||||
|
||||
@override
|
||||
Future<List<MediaPlaylist>> loadData() async => const [];
|
||||
String idOf(MediaPlaylist playlist) => playlist.id;
|
||||
|
||||
@override
|
||||
Future<LibraryPage<MediaPlaylist>> fetchPage(int start, int size, AbortController? abort) {
|
||||
@@ -84,154 +60,5 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> loadItems() async {
|
||||
String? loadErrorMessage;
|
||||
await loadInitialPaginatedItems(
|
||||
pageSize: _pageSize,
|
||||
resetViewState: () {
|
||||
isLoading = true;
|
||||
errorMessage = null;
|
||||
items = [];
|
||||
},
|
||||
applyLoadedItems: (loaded) {
|
||||
items = loaded;
|
||||
isLoading = false;
|
||||
},
|
||||
applyError: (error, stackTrace) {
|
||||
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: errorContext);
|
||||
isLoading = false;
|
||||
},
|
||||
onLoaded: (_, _) {
|
||||
hasLoadedData = true;
|
||||
tryFocus();
|
||||
if (widget.onDataLoaded != null) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) widget.onDataLoaded!();
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: errorContext);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget buildContent(List<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();
|
||||
}
|
||||
bool get usesSquareCards => widget.library.kind.isMusic;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,8 @@ import '../../../mixins/item_updatable.dart';
|
||||
import '../../../mixins/watch_state_aware.dart';
|
||||
import '../../../services/settings_service.dart';
|
||||
import '../../../utils/deletion_notifier.dart';
|
||||
import '../../../utils/global_key_utils.dart';
|
||||
import '../../../utils/hub_icons.dart';
|
||||
import '../../../utils/media_event_keys.dart';
|
||||
import '../../../utils/platform_detector.dart';
|
||||
import '../../../utils/provider_extensions.dart';
|
||||
import '../../../utils/watch_state_notifier.dart';
|
||||
@@ -46,7 +47,7 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
||||
}
|
||||
|
||||
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
|
||||
with ItemUpdatable, WatchStateAware, DeletionAware {
|
||||
with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState {
|
||||
/// GlobalKeys for each hub section to enable vertical navigation
|
||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
@@ -72,45 +73,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
@override
|
||||
String? get watchStateServerId => widget.library.serverId;
|
||||
|
||||
@override
|
||||
String? get deletionServerId => widget.library.serverId;
|
||||
/// Every item on screen, across all hubs.
|
||||
Iterable<MediaItem> get _visibleItems => items.expand((hub) => hub.items);
|
||||
|
||||
// Deletion filtering needs the same id sets as watch state: each visible
|
||||
// item plus its parents, so deleting a season/show also matches the
|
||||
// episodes it contains here.
|
||||
// Deletion mirrors these via DeletionMirrorsWatchState: each visible item
|
||||
// plus its parents, so deleting a season/show also matches the episodes it
|
||||
// contains here.
|
||||
@override
|
||||
Set<String>? get deletionIds => watchedIds;
|
||||
Set<String>? get watchedIds => hierarchicalEventIds(_visibleItems);
|
||||
|
||||
@override
|
||||
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
|
||||
|
||||
@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;
|
||||
}
|
||||
Set<String>? get watchedGlobalKeys =>
|
||||
hierarchicalEventGlobalKeys(_visibleItems, fallbackServerId: widget.library.serverId);
|
||||
|
||||
@override
|
||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||
@@ -316,7 +290,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
key: index < _hubKeys.length ? _hubKeys[index] : null,
|
||||
hub: hub,
|
||||
focusMemory: _hubFocusMemory,
|
||||
icon: _getHubIcon(hub),
|
||||
icon: hubIconFor(hub),
|
||||
isInContinueWatching: isContinueWatching,
|
||||
usesContinueWatchingAction: usesContinueWatchingAction,
|
||||
onRefresh: updateItem,
|
||||
@@ -351,7 +325,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
key: _tvBrowseRailKey,
|
||||
hubs: tvHubs,
|
||||
focusMemory: _hubFocusMemory,
|
||||
iconForHub: (hub, _) => _getHubIcon(hub),
|
||||
iconForHub: (hub, _) => hubIconFor(hub),
|
||||
onFocusedItemChanged: _setSpotlightItem,
|
||||
onRefresh: updateItem,
|
||||
onRemoveFromContinueWatching: _refreshContinueWatching,
|
||||
@@ -371,24 +345,4 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
||||
// Reload all data to refresh the continue watching section
|
||||
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 String? posterThumb,
|
||||
required String? posterServerId,
|
||||
ValueChanged<bool>? onRecordingStateChanged,
|
||||
}) {
|
||||
final effectiveContext = sheetContext ?? context;
|
||||
final multiServer = effectiveContext.read<MultiServerProvider>();
|
||||
@@ -74,6 +75,7 @@ mixin LiveTvActionsMixin<T extends StatefulWidget> on State<T> {
|
||||
posterUrl: posterUrl,
|
||||
onTuneChannel: channel != null ? () => tuneChannel(channel) : null,
|
||||
client: client,
|
||||
onRecordingStateChanged: onRecordingStateChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import '../../widgets/settings_builder.dart';
|
||||
import '../../utils/app_logger.dart';
|
||||
import '../../utils/error_message_utils.dart';
|
||||
import '../../utils/desktop_window_padding.dart';
|
||||
import '../../utils/live_tv_matching.dart';
|
||||
import '../../utils/platform_detector.dart';
|
||||
import '../../utils/serial_future_queue.dart';
|
||||
import '../../utils/snackbar_helper.dart';
|
||||
@@ -31,6 +32,8 @@ import 'tabs/guide_tab.dart';
|
||||
import 'tabs/recordings_tab.dart';
|
||||
import 'tabs/whats_on_tab.dart';
|
||||
|
||||
typedef _FavoriteScope = ({String source, String storeKey, FavoriteChannelPersistenceMode mode});
|
||||
|
||||
enum LiveTvTab { guide, whatsOn, recordings }
|
||||
|
||||
class LiveTvScreen extends StatefulWidget {
|
||||
@@ -66,13 +69,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
Set<String> _favoriteKeys = {};
|
||||
List<FavoriteChannel> _favoriteChannels = [];
|
||||
|
||||
/// Source URI per Live TV server/DVR, built from machineIdentifier + EPG provider identifier.
|
||||
final Map<String, String> _favoriteSourceByLiveServer = {};
|
||||
final Map<String, String> _favoriteSourceByChannel = {};
|
||||
final Map<String, String> _favoriteStoreByLiveServer = {};
|
||||
final Map<String, String> _favoriteStoreByChannel = {};
|
||||
/// Favorite source URI, store key and persistence mode per Live TV server/DVR.
|
||||
/// The source is built from machineIdentifier + EPG provider identifier.
|
||||
final Map<String, _FavoriteScope> _favoriteScopeByLiveServer = {};
|
||||
final Map<String, String> _liveServerKeyByChannel = {};
|
||||
|
||||
/// Store key per favorite source. A superset of the scope sources: it also
|
||||
/// collects sources of fetched and toggled favorites that belong to other
|
||||
/// servers sharing an account-scoped store.
|
||||
final Map<String, String> _favoriteStoreBySource = {};
|
||||
final Map<String, FavoriteChannelPersistenceMode> _favoriteModeByStore = {};
|
||||
Future<void>? _channelsLoadFuture;
|
||||
int _favoritesLoadGeneration = 0;
|
||||
Future<void>? _favoritesLoadFuture;
|
||||
@@ -90,8 +95,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
|
||||
String _liveServerScopeKey(LiveTvServerInfo serverInfo) => '${serverInfo.serverId}\u0000${serverInfo.dvrKey}';
|
||||
|
||||
_FavoriteScope? _favoriteScopeForChannel(LiveTvChannel channel) {
|
||||
final liveServerKey = _liveServerKeyByChannel[liveTvChannelScopeKey(channel)];
|
||||
return liveServerKey == null ? null : _favoriteScopeByLiveServer[liveServerKey];
|
||||
}
|
||||
|
||||
String _sourceForChannel(LiveTvChannel channel) {
|
||||
return channel.favoriteSource ?? _favoriteSourceByChannel[liveTvChannelScopeKey(channel)] ?? '';
|
||||
return channel.favoriteSource ?? _favoriteScopeForChannel(channel)?.source ?? '';
|
||||
}
|
||||
|
||||
String _favoriteKeyForChannel(LiveTvChannel channel) => favoriteChannelKey(_sourceForChannel(channel), channel.key);
|
||||
@@ -166,61 +176,56 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
await _recordingsTabKey.currentState?.reload();
|
||||
return;
|
||||
}
|
||||
await _serverReloadGuide();
|
||||
await _broadcastToDvrs(
|
||||
actionLabel: 'Reload guide',
|
||||
successMessage: t.liveTv.guideReloadRequested,
|
||||
action: (dvr, serverInfo) => dvr.reloadGuide(serverInfo.dvrKey),
|
||||
);
|
||||
await _loadChannels();
|
||||
}
|
||||
|
||||
Future<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 futures = <Future<void>>[];
|
||||
for (final serverInfo in multiServer.liveTvServers) {
|
||||
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
|
||||
if (client == null || client.liveTvDvr == null) continue;
|
||||
futures.add(_reloadGuideSafe(client, serverInfo.dvrKey));
|
||||
Future<void> runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async {
|
||||
try {
|
||||
await action(dvr, serverInfo);
|
||||
} catch (e) {
|
||||
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 {
|
||||
try {
|
||||
final dvr = client.liveTvDvr;
|
||||
if (dvr == null) return;
|
||||
await dvr.reloadGuide(dvrId);
|
||||
} catch (e) {
|
||||
// 403 (admin only) and transient errors are non-fatal — caller still
|
||||
// re-fetches client-side channels.
|
||||
appLogger.d('Reload guide failed for DVR $dvrId: $e');
|
||||
final futures = <Future<void>>[];
|
||||
for (final serverInfo in multiServer.liveTvServers) {
|
||||
final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr;
|
||||
if (dvr == null) continue;
|
||||
futures.add(runSafely(dvr, serverInfo));
|
||||
}
|
||||
if (futures.isEmpty) return false;
|
||||
await Future.wait(futures);
|
||||
if (!mounted) return false;
|
||||
showSnackBar(context, successMessage);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> _processRecordingRules() async {
|
||||
final multiServer = context.read<MultiServerProvider>();
|
||||
final futures = <Future<void>>[];
|
||||
for (final serverInfo in multiServer.liveTvServers) {
|
||||
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
|
||||
if (client == null || client.liveTvDvr == null) continue;
|
||||
futures.add(_processRulesSafe(client));
|
||||
}
|
||||
if (futures.isEmpty) return;
|
||||
await Future.wait(futures);
|
||||
if (!mounted) return;
|
||||
showSnackBar(context, t.liveTv.rulesProcessRequested);
|
||||
final reached = await _broadcastToDvrs(
|
||||
actionLabel: 'processRecordingRules',
|
||||
successMessage: t.liveTv.rulesProcessRequested,
|
||||
action: (dvr, _) => dvr.processRecordingRules(),
|
||||
);
|
||||
if (!reached) return;
|
||||
await _recordingsTabKey.currentState?.reload();
|
||||
}
|
||||
|
||||
Future<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.
|
||||
/// Re-inits the tab controller when the visible set changes (matches the
|
||||
/// libraries-screen pattern at libraries_screen.dart:365).
|
||||
@@ -265,15 +270,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
String? _sourceTitleForServerInfo(LiveTvServerInfo serverInfo) {
|
||||
for (final dvr in serverInfo.dvrs) {
|
||||
if (dvr.key == serverInfo.dvrKey) {
|
||||
return _nonEmpty(dvr.lineupTitle) ?? _nonEmpty(dvr.lineupURL) ?? _nonEmpty(dvr.lineup);
|
||||
return liveTvNonEmpty(dvr.lineupTitle) ?? liveTvNonEmpty(dvr.lineupURL) ?? liveTvNonEmpty(dvr.lineup);
|
||||
}
|
||||
}
|
||||
return _nonEmpty(serverInfo.lineup);
|
||||
}
|
||||
|
||||
String? _nonEmpty(String? value) {
|
||||
final trimmed = value?.trim();
|
||||
return trimmed == null || trimmed.isEmpty ? null : trimmed;
|
||||
return liveTvNonEmpty(serverInfo.lineup);
|
||||
}
|
||||
|
||||
Future<void> _loadChannels() {
|
||||
@@ -308,12 +308,9 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
|
||||
final allChannels = <LiveTvChannel>[];
|
||||
final seenChannels = <String>{};
|
||||
final favoriteSourceByLiveServer = <String, String>{};
|
||||
final favoriteSourceByChannel = <String, String>{};
|
||||
final favoriteStoreByLiveServer = <String, String>{};
|
||||
final favoriteStoreByChannel = <String, String>{};
|
||||
final favoriteScopeByLiveServer = <String, _FavoriteScope>{};
|
||||
final liveServerKeyByChannel = <String, String>{};
|
||||
final favoriteStoreBySource = <String, String>{};
|
||||
final favoriteModeByStore = <String, FavoriteChannelPersistenceMode>{};
|
||||
|
||||
appLogger.d(
|
||||
'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 storeKey = liveTv.favoriteStoreKey;
|
||||
final liveServerKey = _liveServerScopeKey(serverInfo);
|
||||
favoriteSourceByLiveServer[liveServerKey] = source;
|
||||
favoriteStoreByLiveServer[liveServerKey] = storeKey;
|
||||
favoriteScopeByLiveServer[liveServerKey] = (
|
||||
source: source,
|
||||
storeKey: storeKey,
|
||||
mode: liveTv.favoritePersistenceMode,
|
||||
);
|
||||
favoriteStoreBySource[source] = storeKey;
|
||||
favoriteModeByStore[storeKey] = liveTv.favoritePersistenceMode;
|
||||
|
||||
final channels = await genericClient.liveTv.fetchChannels(lineup: serverInfo.lineup);
|
||||
// Plex's DVR exposes a separate enabled-channel mapping; Jellyfin
|
||||
@@ -360,9 +359,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
);
|
||||
final dedupKey = liveTvChannelScopeKey(scopedChannel);
|
||||
if (seenChannels.add(dedupKey)) {
|
||||
final scopeKey = liveTvChannelScopeKey(scopedChannel);
|
||||
favoriteSourceByChannel[scopeKey] = source;
|
||||
favoriteStoreByChannel[scopeKey] = storeKey;
|
||||
liveServerKeyByChannel[dedupKey] = liveServerKey;
|
||||
allChannels.add(scopedChannel);
|
||||
}
|
||||
}
|
||||
@@ -383,24 +380,15 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
|
||||
setState(() {
|
||||
_channels = allChannels;
|
||||
_favoriteSourceByLiveServer
|
||||
_favoriteScopeByLiveServer
|
||||
..clear()
|
||||
..addAll(favoriteSourceByLiveServer);
|
||||
_favoriteSourceByChannel
|
||||
..addAll(favoriteScopeByLiveServer);
|
||||
_liveServerKeyByChannel
|
||||
..clear()
|
||||
..addAll(favoriteSourceByChannel);
|
||||
_favoriteStoreByLiveServer
|
||||
..clear()
|
||||
..addAll(favoriteStoreByLiveServer);
|
||||
_favoriteStoreByChannel
|
||||
..clear()
|
||||
..addAll(favoriteStoreByChannel);
|
||||
..addAll(liveServerKeyByChannel);
|
||||
_favoriteStoreBySource
|
||||
..clear()
|
||||
..addAll(favoriteStoreBySource);
|
||||
_favoriteModeByStore
|
||||
..clear()
|
||||
..addAll(favoriteModeByStore);
|
||||
_isLoading = false;
|
||||
});
|
||||
|
||||
@@ -438,10 +426,8 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
_favoritesLoaded = false;
|
||||
_favoritesWritable = false;
|
||||
final previousStoreBySource = Map<String, String>.of(_favoriteStoreBySource);
|
||||
final sourceByLiveServer = Map<String, String>.of(_favoriteSourceByLiveServer);
|
||||
final storeByLiveServer = Map<String, String>.of(_favoriteStoreByLiveServer);
|
||||
final scopeByLiveServer = Map<String, _FavoriteScope>.of(_favoriteScopeByLiveServer);
|
||||
final storeBySource = Map<String, String>.of(_favoriteStoreBySource);
|
||||
final modeByStore = Map<String, FavoriteChannelPersistenceMode>.of(_favoriteModeByStore);
|
||||
final merged = <FavoriteChannel>[];
|
||||
final successfulStores = <String>{};
|
||||
final failedStores = <String>{};
|
||||
@@ -453,12 +439,10 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
final liveTv = client.liveTv;
|
||||
final storeKey = liveTv.favoriteStoreKey;
|
||||
final liveServerKey = _liveServerScopeKey(serverInfo);
|
||||
storeByLiveServer[liveServerKey] = storeKey;
|
||||
modeByStore[storeKey] = liveTv.favoritePersistenceMode;
|
||||
|
||||
try {
|
||||
final source = await liveTv.buildFavoriteChannelSource(lineup: serverInfo.lineup);
|
||||
sourceByLiveServer[liveServerKey] = source;
|
||||
scopeByLiveServer[liveServerKey] = (source: source, storeKey: storeKey, mode: liveTv.favoritePersistenceMode);
|
||||
storeBySource[source] = storeKey;
|
||||
if (successfulStores.contains(storeKey)) continue;
|
||||
|
||||
@@ -487,18 +471,12 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
|
||||
if (!mounted || loadGeneration != _favoritesLoadGeneration) return;
|
||||
setState(() {
|
||||
_favoriteSourceByLiveServer
|
||||
_favoriteScopeByLiveServer
|
||||
..clear()
|
||||
..addAll(sourceByLiveServer);
|
||||
_favoriteStoreByLiveServer
|
||||
..clear()
|
||||
..addAll(storeByLiveServer);
|
||||
..addAll(scopeByLiveServer);
|
||||
_favoriteStoreBySource
|
||||
..clear()
|
||||
..addAll(storeBySource);
|
||||
_favoriteModeByStore
|
||||
..clear()
|
||||
..addAll(modeByStore);
|
||||
_favoriteChannels = merged;
|
||||
_refreshFavoriteKeys();
|
||||
_favoritesLoaded = failedStores.isEmpty || successfulStores.isNotEmpty || merged.isNotEmpty;
|
||||
@@ -520,8 +498,7 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
_enqueueFavoriteMutation(() {
|
||||
final source = _sourceForChannel(channel);
|
||||
final favoriteKey = favoriteChannelKey(source, channel.key);
|
||||
final scopeKey = liveTvChannelScopeKey(channel);
|
||||
final storeKey = channel.favoriteStoreKey ?? _favoriteStoreByChannel[scopeKey];
|
||||
final storeKey = channel.favoriteStoreKey ?? _favoriteScopeForChannel(channel)?.storeKey;
|
||||
if (storeKey != null) _favoriteStoreBySource[source] = storeKey;
|
||||
|
||||
setState(() {
|
||||
@@ -601,16 +578,13 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
||||
for (final serverInfo in multiServer.liveTvServers) {
|
||||
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
|
||||
if (client == null) continue;
|
||||
final liveServerKey = _liveServerScopeKey(serverInfo);
|
||||
final storeKey = _favoriteStoreByLiveServer[liveServerKey];
|
||||
if (storeKey == null || !writtenStores.add(storeKey)) continue;
|
||||
final mode = _favoriteModeByStore[storeKey] ?? client.liveTv.favoritePersistenceMode;
|
||||
final source = _favoriteSourceByLiveServer[liveServerKey];
|
||||
if (source == null) continue;
|
||||
final channels = switch (mode) {
|
||||
FavoriteChannelPersistenceMode.sharedFullList => byStore[storeKey] ?? const <FavoriteChannel>[],
|
||||
final scope = _favoriteScopeByLiveServer[_liveServerScopeKey(serverInfo)];
|
||||
if (scope == null || !writtenStores.add(scope.storeKey)) continue;
|
||||
final storeChannels = byStore[scope.storeKey] ?? const <FavoriteChannel>[];
|
||||
final channels = switch (scope.mode) {
|
||||
FavoriteChannelPersistenceMode.sharedFullList => storeChannels,
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -419,9 +419,23 @@ class _SettingRow extends StatelessWidget {
|
||||
return _EnumSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
|
||||
}
|
||||
if (type == 'int') {
|
||||
return _IntSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
|
||||
return _TextFieldSettingRow(
|
||||
setting: setting,
|
||||
currentValue: currentValue,
|
||||
autofocus: autofocus,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))],
|
||||
parseValue: int.tryParse,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
return _TextSettingRow(setting: setting, currentValue: currentValue, autofocus: autofocus, onChanged: onChanged);
|
||||
return _TextFieldSettingRow(
|
||||
setting: setting,
|
||||
currentValue: currentValue,
|
||||
autofocus: autofocus,
|
||||
parseValue: (text) => text,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -435,6 +449,28 @@ bool _coerceBool(Object? value) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Setting label with its optional secondary summary line.
|
||||
class _SettingLabel extends StatelessWidget {
|
||||
final String label;
|
||||
final String? summary;
|
||||
|
||||
const _SettingLabel({required this.label, this.summary});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final summary = this.summary;
|
||||
return Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodyMedium),
|
||||
if (summary != null && summary.isNotEmpty)
|
||||
Text(summary, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BoolSettingRow extends StatelessWidget {
|
||||
final SubscriptionSetting setting;
|
||||
final Object? currentValue;
|
||||
@@ -450,7 +486,6 @@ class _BoolSettingRow extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final value = _coerceBool(currentValue);
|
||||
void toggle() => onChanged(!value);
|
||||
return FocusableWrapper(
|
||||
@@ -466,17 +501,7 @@ class _BoolSettingRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(setting.label ?? setting.id, style: theme.textTheme.bodyMedium),
|
||||
if (setting.summary != null && setting.summary!.isNotEmpty)
|
||||
Text(
|
||||
setting.summary!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: _SettingLabel(label: setting.label ?? setting.id, summary: setting.summary),
|
||||
),
|
||||
IgnorePointer(
|
||||
child: Switch(value: value, onChanged: (v) => onChanged(v)),
|
||||
@@ -549,14 +574,7 @@ class _PickerRow extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(label, style: theme.textTheme.bodyMedium),
|
||||
if (summary != null && summary!.isNotEmpty)
|
||||
Text(summary!, style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant)),
|
||||
],
|
||||
),
|
||||
child: _SettingLabel(label: label, summary: summary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text(value, style: theme.textTheme.bodyMedium),
|
||||
@@ -575,24 +593,32 @@ class _PickerRow extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _IntSettingRow extends StatefulWidget {
|
||||
/// Free-text setting row. [parseValue] maps the field text to the value handed
|
||||
/// back to [onChanged] — identity for text settings, `int.tryParse` for ints.
|
||||
class _TextFieldSettingRow extends StatefulWidget {
|
||||
final SubscriptionSetting setting;
|
||||
final Object? currentValue;
|
||||
final bool autofocus;
|
||||
final TextInputType? keyboardType;
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
final Object? Function(String) parseValue;
|
||||
final void Function(Object?) onChanged;
|
||||
|
||||
const _IntSettingRow({
|
||||
const _TextFieldSettingRow({
|
||||
required this.setting,
|
||||
required this.currentValue,
|
||||
required this.autofocus,
|
||||
required this.parseValue,
|
||||
required this.onChanged,
|
||||
this.keyboardType,
|
||||
this.inputFormatters,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_IntSettingRow> createState() => _IntSettingRowState();
|
||||
State<_TextFieldSettingRow> createState() => _TextFieldSettingRowState();
|
||||
}
|
||||
|
||||
class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerMixin {
|
||||
class _TextFieldSettingRowState extends State<_TextFieldSettingRow> with ControllerDisposerMixin {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
@@ -602,7 +628,7 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _IntSettingRow oldWidget) {
|
||||
void didUpdateWidget(covariant _TextFieldSettingRow oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final next = widget.currentValue?.toString() ?? '';
|
||||
if (next != _controller.text) _controller.text = next;
|
||||
@@ -610,91 +636,21 @@ class _IntSettingRowState extends State<_IntSettingRow> with ControllerDisposerM
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium),
|
||||
if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty)
|
||||
Text(
|
||||
widget.setting.summary!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
_SettingLabel(label: widget.setting.label ?? widget.setting.id, summary: widget.setting.summary),
|
||||
const SizedBox(height: 4),
|
||||
FocusableTextField(
|
||||
controller: _controller,
|
||||
autofocus: widget.autofocus,
|
||||
keyboardType: TextInputType.number,
|
||||
inputFormatters: [FilteringTextInputFormatter.allow(RegExp(r'-?\d*'))],
|
||||
keyboardType: widget.keyboardType,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
onNavigateUp: () => FocusScope.of(context).previousFocus(),
|
||||
onNavigateDown: () => FocusScope.of(context).nextFocus(),
|
||||
onChanged: (text) {
|
||||
final parsed = int.tryParse(text);
|
||||
widget.onChanged(parsed);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextSettingRow extends StatefulWidget {
|
||||
final SubscriptionSetting setting;
|
||||
final Object? currentValue;
|
||||
final bool autofocus;
|
||||
final void Function(Object?) onChanged;
|
||||
|
||||
const _TextSettingRow({
|
||||
required this.setting,
|
||||
required this.currentValue,
|
||||
required this.autofocus,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_TextSettingRow> createState() => _TextSettingRowState();
|
||||
}
|
||||
|
||||
class _TextSettingRowState extends State<_TextSettingRow> with ControllerDisposerMixin {
|
||||
late final TextEditingController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = createTextEditingController(text: widget.currentValue?.toString() ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant _TextSettingRow oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final next = widget.currentValue?.toString() ?? '';
|
||||
if (next != _controller.text) _controller.text = next;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
Text(widget.setting.label ?? widget.setting.id, style: theme.textTheme.bodyMedium),
|
||||
if (widget.setting.summary != null && widget.setting.summary!.isNotEmpty)
|
||||
Text(
|
||||
widget.setting.summary!,
|
||||
style: theme.textTheme.bodySmall?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
FocusableTextField(
|
||||
controller: _controller,
|
||||
autofocus: widget.autofocus,
|
||||
onNavigateUp: () => FocusScope.of(context).previousFocus(),
|
||||
onNavigateDown: () => FocusScope.of(context).nextFocus(),
|
||||
onChanged: (text) => widget.onChanged(text),
|
||||
onChanged: (text) => widget.onChanged(widget.parseValue(text)),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user