refactor: extract shared mixins and helpers, drop dead abstractions
Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action.
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
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,
|
||||||
|
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"
|
||||||
|
}
|
||||||
+17
-30
@@ -25,10 +25,10 @@ on:
|
|||||||
type: boolean
|
type: boolean
|
||||||
|
|
||||||
env:
|
env:
|
||||||
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }}
|
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||||
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }}
|
FLUTTER_VERSION: "3.44.0"
|
||||||
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github
|
# Shared by every release build command; SENTRY_DIST stays per-platform.
|
||||||
DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true
|
RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
|
||||||
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
|
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
|
||||||
LINUX_APT_PACKAGES: >
|
LINUX_APT_PACKAGES: >
|
||||||
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev
|
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev
|
||||||
@@ -76,7 +76,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
@@ -110,7 +110,7 @@ jobs:
|
|||||||
EOF
|
EOF
|
||||||
|
|
||||||
- name: Build APKs
|
- name: Build APKs
|
||||||
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
|
run: flutter build apk --release --split-per-abi ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-android-apk --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
|
||||||
|
|
||||||
- name: Upload symbols to bugs.plezy.app
|
- name: Upload symbols to bugs.plezy.app
|
||||||
if: github.repository == 'edde746/plezy'
|
if: github.repository == 'edde746/plezy'
|
||||||
@@ -163,7 +163,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
@@ -188,7 +188,7 @@ jobs:
|
|||||||
run: flutter pub get --enforce-lockfile --no-example
|
run: flutter pub get --enforce-lockfile --no-example
|
||||||
|
|
||||||
- name: Build iOS (no codesign)
|
- name: Build iOS (no codesign)
|
||||||
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios
|
run: flutter build ios --release --no-codesign ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-ios --split-debug-info=debug-info/ios
|
||||||
|
|
||||||
- name: Upload symbols to bugs.plezy.app
|
- name: Upload symbols to bugs.plezy.app
|
||||||
if: github.repository == 'edde746/plezy'
|
if: github.repository == 'edde746/plezy'
|
||||||
@@ -231,7 +231,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
@@ -256,7 +256,7 @@ jobs:
|
|||||||
run: flutter pub get --enforce-lockfile --no-example
|
run: flutter pub get --enforce-lockfile --no-example
|
||||||
|
|
||||||
- name: Build macOS
|
- name: Build macOS
|
||||||
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos
|
run: flutter build macos --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-macos --split-debug-info=debug-info/macos
|
||||||
|
|
||||||
- name: Upload symbols to bugs.plezy.app
|
- name: Upload symbols to bugs.plezy.app
|
||||||
if: github.repository == 'edde746/plezy'
|
if: github.repository == 'edde746/plezy'
|
||||||
@@ -436,27 +436,14 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
- name: Set up Flutter 3.44.0 (git tag)
|
- name: Set up Flutter from its pinned commit
|
||||||
if: matrix.flutter_setup == 'git'
|
if: matrix.flutter_setup == 'git'
|
||||||
# Flutter publishes no windows-arm64 SDK archive, so subosito can't
|
uses: ./.github/actions/setup-flutter-git
|
||||||
# resolve 3.44.0 for arm64: the stable manifest has no arm64 entry, and
|
|
||||||
# `channel: master` would git-clone master HEAD (whose engine != our
|
|
||||||
# patched 3.44.0). Clone the 3.44.0 tag directly to get engine rev
|
|
||||||
# 4c525dac, which install-patched-engine.ps1 asserts before swapping.
|
|
||||||
shell: pwsh
|
|
||||||
run: |
|
|
||||||
$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 559ffa3f75e7402d65a8def9c28389a9b2e6fe42
|
|
||||||
git -C $root checkout --detach FETCH_HEAD
|
|
||||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
|
||||||
& "$root\bin\flutter.bat" --version
|
|
||||||
|
|
||||||
- name: Cache Pub dependencies
|
- name: Cache Pub dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||||
@@ -477,7 +464,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Windows ${{ matrix.arch }}
|
- name: Build Windows ${{ matrix.arch }}
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
run: flutter build windows --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
|
run: flutter build windows --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }} --split-debug-info=debug-info/windows-${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Upload symbols to bugs.plezy.app
|
- name: Upload symbols to bugs.plezy.app
|
||||||
if: github.repository == 'edde746/plezy'
|
if: github.repository == 'edde746/plezy'
|
||||||
@@ -510,7 +497,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
@@ -623,7 +610,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: ${{ matrix.flutter_channel }}
|
channel: ${{ matrix.flutter_channel }}
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
@@ -680,7 +667,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Linux ${{ matrix.arch }}
|
- name: Build Linux ${{ matrix.arch }}
|
||||||
shell: bash
|
shell: bash
|
||||||
run: flutter build linux --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
|
run: flutter build linux --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }} --split-debug-info=debug-info/linux-${{ matrix.arch }}
|
||||||
env:
|
env:
|
||||||
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig
|
PKG_CONFIG_PATH: ${{ github.workspace }}/libmpv-prefix/lib/pkgconfig:${{ github.workspace }}/libmpv-prefix/lib/${{ matrix.pkg_config_arch }}/pkgconfig
|
||||||
|
|
||||||
|
|||||||
+14
-61
@@ -10,6 +10,10 @@ on:
|
|||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
|
||||||
|
FLUTTER_VERSION: "3.44.0"
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
analyze:
|
analyze:
|
||||||
name: Code Analysis
|
name: Code Analysis
|
||||||
@@ -26,7 +30,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
@@ -53,30 +57,7 @@ jobs:
|
|||||||
run: python3 scripts/clean_translations.py --check --strict
|
run: python3 scripts/clean_translations.py --check --strict
|
||||||
|
|
||||||
- name: Verify workflow and script guards
|
- name: Verify workflow and script guards
|
||||||
run: |
|
run: bash scripts/ci_guard_checks.sh
|
||||||
python3 scripts/check_build_workflow.py
|
|
||||||
python3 scripts/test_check_build_workflow.py
|
|
||||||
python3 scripts/check_apple_spm_locks.py
|
|
||||||
python3 scripts/test_check_apple_spm_locks.py
|
|
||||||
python3 scripts/verify_runtime_inputs.py
|
|
||||||
python3 scripts/test_verify_runtime_inputs.py
|
|
||||||
python3 scripts/check_workflow_security.py
|
|
||||||
python3 scripts/test_check_workflow_security.py
|
|
||||||
python3 scripts/check_workflow_action_pins.py
|
|
||||||
python3 scripts/test_check_workflow_action_pins.py
|
|
||||||
python3 scripts/check_container_image_pins.py
|
|
||||||
python3 scripts/test_check_container_image_pins.py
|
|
||||||
python3 scripts/test_fetch_tvos_engine.py
|
|
||||||
python3 scripts/test_check_codegen.py
|
|
||||||
python3 scripts/test_generate_relay_protocol.py
|
|
||||||
python3 scripts/test_format_native.py
|
|
||||||
python3 scripts/test_run_maestro.py
|
|
||||||
python3 scripts/test_maestro_flow_contracts.py
|
|
||||||
python3 scripts/test_maestro_jellyfin_proxy.py
|
|
||||||
python3 scripts/check_update_packages_workflow.py
|
|
||||||
python3 scripts/test_pubspec_version.py
|
|
||||||
python3 scripts/test_clean_translations.py
|
|
||||||
python3 scripts/test_check_icon_consistency.py
|
|
||||||
|
|
||||||
- name: Verify formatting
|
- name: Verify formatting
|
||||||
run: |
|
run: |
|
||||||
@@ -130,7 +111,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
@@ -187,7 +168,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
@@ -281,7 +262,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
@@ -355,7 +336,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
@@ -478,41 +459,13 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
- name: Setup Flutter 3.44.0 from its immutable commit
|
- name: Set up Flutter from its pinned commit
|
||||||
if: matrix.flutter_setup == 'git'
|
if: matrix.flutter_setup == 'git'
|
||||||
shell: pwsh
|
uses: ./.github/actions/setup-flutter-git
|
||||||
run: |
|
|
||||||
$root = "$env:RUNNER_TEMP\flutter"
|
|
||||||
$expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
|
||||||
git init $root
|
|
||||||
git -C $root remote add origin https://github.com/flutter/flutter.git
|
|
||||||
git -C $root fetch --depth 1 origin refs/tags/3.44.0:refs/tags/3.44.0
|
|
||||||
git -C $root checkout --detach refs/tags/3.44.0
|
|
||||||
$actualCommit = git -C $root rev-parse HEAD
|
|
||||||
if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) {
|
|
||||||
throw "Flutter 3.44.0 resolved to $actualCommit, expected $expectedCommit"
|
|
||||||
}
|
|
||||||
"$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8
|
|
||||||
& "$root\bin\flutter.bat" --version
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Unable to bootstrap the Flutter SDK"
|
|
||||||
}
|
|
||||||
$versionOutput = & "$root\bin\flutter.bat" --version --machine
|
|
||||||
if ($LASTEXITCODE -ne 0) {
|
|
||||||
throw "Unable to resolve the Flutter SDK version"
|
|
||||||
}
|
|
||||||
$versionJson = $versionOutput -join "`n"
|
|
||||||
if ([string]::IsNullOrWhiteSpace($versionJson)) {
|
|
||||||
throw "Flutter did not report machine-readable version JSON"
|
|
||||||
}
|
|
||||||
$version = $versionJson | ConvertFrom-Json
|
|
||||||
if ($version.frameworkVersion -ne "3.44.0") {
|
|
||||||
throw "Flutter reported version $($version.frameworkVersion), expected 3.44.0"
|
|
||||||
}
|
|
||||||
|
|
||||||
- name: Install locked Dart dependencies
|
- name: Install locked Dart dependencies
|
||||||
shell: pwsh
|
shell: pwsh
|
||||||
@@ -566,7 +519,7 @@ jobs:
|
|||||||
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
|
||||||
with:
|
with:
|
||||||
channel: "stable"
|
channel: "stable"
|
||||||
flutter-version: "3.44.0"
|
flutter-version: ${{ env.FLUTTER_VERSION }}
|
||||||
cache: true
|
cache: true
|
||||||
pub-cache: false
|
pub-cache: false
|
||||||
|
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ jobs:
|
|||||||
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
|
||||||
with:
|
with:
|
||||||
path: ~/.pub-cache
|
path: ~/.pub-cache
|
||||||
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }}
|
key: ${{ steps.pub-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Save Gradle cache
|
- name: Save Gradle cache
|
||||||
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
|
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
|
||||||
@@ -252,7 +252,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.gradle/caches
|
~/.gradle/caches
|
||||||
~/.gradle/wrapper
|
~/.gradle/wrapper
|
||||||
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }}
|
key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Save Maestro CLI cache
|
- name: Save Maestro CLI cache
|
||||||
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
|
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
|
||||||
@@ -261,7 +261,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.maestro/bin
|
~/.maestro/bin
|
||||||
~/.maestro/lib
|
~/.maestro/lib
|
||||||
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }}
|
key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Save Android 15 AVD cache
|
- name: Save Android 15 AVD cache
|
||||||
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
|
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
|
||||||
@@ -270,7 +270,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.android/avd/maestro-api35.avd
|
~/.android/avd/maestro-api35.avd
|
||||||
~/.android/avd/maestro-api35.ini
|
~/.android/avd/maestro-api35.ini
|
||||||
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6
|
key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Save Android 9 AVD cache
|
- name: Save Android 9 AVD cache
|
||||||
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
|
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
|
||||||
@@ -279,7 +279,7 @@ jobs:
|
|||||||
path: |
|
path: |
|
||||||
~/.android/avd/maestro-api28.avd
|
~/.android/avd/maestro-api28.avd
|
||||||
~/.android/avd/maestro-api28.ini
|
~/.android/avd/maestro-api28.ini
|
||||||
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore
|
key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
|
||||||
|
|
||||||
- name: Upload Maestro diagnostics
|
- name: Upload Maestro diagnostics
|
||||||
if: always()
|
if: always()
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ import com.edde746.plezy.shared.FlutterOverlayHelper
|
|||||||
import com.edde746.plezy.shared.FrameRateManager
|
import com.edde746.plezy.shared.FrameRateManager
|
||||||
import com.edde746.plezy.shared.MediaCodecQuery
|
import com.edde746.plezy.shared.MediaCodecQuery
|
||||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||||
|
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
import java.util.concurrent.atomic.AtomicLong
|
import java.util.concurrent.atomic.AtomicLong
|
||||||
import org.chromium.net.CronetEngine
|
import org.chromium.net.CronetEngine
|
||||||
@@ -102,7 +103,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
|
|||||||
internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null
|
internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
class ExoPlayerCore(private val activity: Activity) : Player.Listener, SurfacePlayerCore {
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TAG = "ExoPlayerCore"
|
private const val TAG = "ExoPlayerCore"
|
||||||
@@ -3400,7 +3401,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVisible(visible: Boolean) {
|
override fun setVisible(visible: Boolean) {
|
||||||
if (disposing) return
|
if (disposing) return
|
||||||
currentVisible = visible
|
currentVisible = visible
|
||||||
activity.runOnUiThread {
|
activity.runOnUiThread {
|
||||||
@@ -3493,7 +3494,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPipModeChanged(isInPipMode: Boolean) {
|
override fun onPipModeChanged(isInPipMode: Boolean) {
|
||||||
if (disposing) return
|
if (disposing) return
|
||||||
activity.runOnUiThread {
|
activity.runOnUiThread {
|
||||||
if (disposing) return@runOnUiThread
|
if (disposing) return@runOnUiThread
|
||||||
@@ -3509,7 +3510,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateFrame() {
|
override fun updateFrame() {
|
||||||
if (disposing) return
|
if (disposing) return
|
||||||
activity.runOnUiThread {
|
activity.runOnUiThread {
|
||||||
if (disposing) return@runOnUiThread
|
if (disposing) return@runOnUiThread
|
||||||
@@ -3524,15 +3525,15 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
|
|
||||||
// Audio Focus
|
// Audio Focus
|
||||||
|
|
||||||
fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
|
override fun requestAudioFocus(): Boolean = audioFocusManager?.requestAudioFocus() ?: false
|
||||||
|
|
||||||
fun abandonAudioFocus() {
|
override fun abandonAudioFocus() {
|
||||||
audioFocusManager?.abandonAudioFocus()
|
audioFocusManager?.abandonAudioFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Frame Rate Matching
|
// Frame Rate Matching
|
||||||
|
|
||||||
fun setVideoFrameRate(
|
override fun setVideoFrameRate(
|
||||||
fps: Float,
|
fps: Float,
|
||||||
videoDurationMs: Long,
|
videoDurationMs: Long,
|
||||||
extraDelayMs: Long,
|
extraDelayMs: Long,
|
||||||
@@ -3548,7 +3549,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener {
|
|||||||
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
|
mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearVideoFrameRate() {
|
override fun clearVideoFrameRate() {
|
||||||
frameRateManager?.clearVideoFrameRate()
|
frameRateManager?.clearVideoFrameRate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import com.edde746.plezy.shared.MpvContentUriResolver
|
|||||||
import com.edde746.plezy.shared.PlayerChannelBinding
|
import com.edde746.plezy.shared.PlayerChannelBinding
|
||||||
import com.edde746.plezy.shared.PlayerDelegate
|
import com.edde746.plezy.shared.PlayerDelegate
|
||||||
import com.edde746.plezy.shared.ResolvedMpvUri
|
import com.edde746.plezy.shared.ResolvedMpvUri
|
||||||
|
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
import io.flutter.embedding.engine.plugins.activity.ActivityAware
|
||||||
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding
|
||||||
@@ -45,6 +46,10 @@ class ExoPlayerPlugin :
|
|||||||
private var activity: Activity? = null
|
private var activity: Activity? = null
|
||||||
private var activityBinding: ActivityPluginBinding? = null
|
private var activityBinding: ActivityPluginBinding? = null
|
||||||
|
|
||||||
|
/** Whichever core currently owns the surface; both expose [SurfacePlayerCore] identically. */
|
||||||
|
private val activeSurfaceCore: SurfacePlayerCore?
|
||||||
|
get() = if (usingMpvFallback) mpvCore else playerCore
|
||||||
|
|
||||||
// Every Dart observeProperty registration, kept so an ExoPlayer→MPV
|
// Every Dart observeProperty registration, kept so an ExoPlayer→MPV
|
||||||
// fallback can re-observe exactly what Dart asked for instead of
|
// fallback can re-observe exactly what Dart asked for instead of
|
||||||
// maintaining a parallel hard-coded list.
|
// maintaining a parallel hard-coded list.
|
||||||
@@ -949,20 +954,12 @@ class ExoPlayerPlugin :
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (usingMpvFallback) {
|
activeSurfaceCore?.setVisible(visible)
|
||||||
mpvCore?.setVisible(visible)
|
|
||||||
} else {
|
|
||||||
playerCore?.setVisible(visible)
|
|
||||||
}
|
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleUpdateFrame(result: MethodChannel.Result) {
|
private fun handleUpdateFrame(result: MethodChannel.Result) {
|
||||||
if (usingMpvFallback) {
|
activeSurfaceCore?.updateFrame()
|
||||||
mpvCore?.updateFrame()
|
|
||||||
} else {
|
|
||||||
playerCore?.updateFrame()
|
|
||||||
}
|
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -974,51 +971,31 @@ class ExoPlayerPlugin :
|
|||||||
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
|
val videoHeight = call.argument<Number>("videoHeight")?.toInt() ?: 0
|
||||||
|
|
||||||
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
|
Log.d(TAG, "setVideoFrameRate: fps=$fps, duration=$duration, extraDelayMs=$extraDelayMs, video=${videoWidth}x$videoHeight")
|
||||||
val onComplete: (Boolean) -> Unit = { switched -> result.success(switched) }
|
val core = activeSurfaceCore
|
||||||
if (usingMpvFallback) {
|
if (core == null) {
|
||||||
val core = mpvCore
|
result.success(false)
|
||||||
if (core == null) {
|
return
|
||||||
result.success(false)
|
}
|
||||||
} else {
|
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
|
||||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete)
|
result.success(switched)
|
||||||
}
|
|
||||||
} else {
|
|
||||||
val core = playerCore
|
|
||||||
if (core == null) {
|
|
||||||
result.success(false)
|
|
||||||
} else {
|
|
||||||
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
|
||||||
Log.d(TAG, "clearVideoFrameRate")
|
Log.d(TAG, "clearVideoFrameRate")
|
||||||
if (usingMpvFallback) {
|
activeSurfaceCore?.clearVideoFrameRate()
|
||||||
mpvCore?.clearVideoFrameRate()
|
|
||||||
} else {
|
|
||||||
playerCore?.clearVideoFrameRate()
|
|
||||||
}
|
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
|
private fun handleRequestAudioFocus(result: MethodChannel.Result) {
|
||||||
Log.d(TAG, "requestAudioFocus")
|
Log.d(TAG, "requestAudioFocus")
|
||||||
val granted = if (usingMpvFallback) {
|
val granted = activeSurfaceCore?.requestAudioFocus() ?: false
|
||||||
mpvCore?.requestAudioFocus() ?: false
|
|
||||||
} else {
|
|
||||||
playerCore?.requestAudioFocus() ?: false
|
|
||||||
}
|
|
||||||
result.success(granted)
|
result.success(granted)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
|
private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
|
||||||
Log.d(TAG, "abandonAudioFocus")
|
Log.d(TAG, "abandonAudioFocus")
|
||||||
if (usingMpvFallback) {
|
activeSurfaceCore?.abandonAudioFocus()
|
||||||
mpvCore?.abandonAudioFocus()
|
|
||||||
} else {
|
|
||||||
playerCore?.abandonAudioFocus()
|
|
||||||
}
|
|
||||||
result.success(null)
|
result.success(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1228,11 +1205,7 @@ class ExoPlayerPlugin :
|
|||||||
|
|
||||||
fun onPipModeChanged(isInPipMode: Boolean) {
|
fun onPipModeChanged(isInPipMode: Boolean) {
|
||||||
activity?.runOnUiThread {
|
activity?.runOnUiThread {
|
||||||
if (usingMpvFallback) {
|
activeSurfaceCore?.onPipModeChanged(isInPipMode)
|
||||||
mpvCore?.onPipModeChanged(isInPipMode)
|
|
||||||
} else {
|
|
||||||
playerCore?.onPipModeChanged(isInPipMode)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import com.edde746.plezy.shared.AudioFocusManager
|
|||||||
import com.edde746.plezy.shared.FrameRateManager
|
import com.edde746.plezy.shared.FrameRateManager
|
||||||
import com.edde746.plezy.shared.PlayerDelegate
|
import com.edde746.plezy.shared.PlayerDelegate
|
||||||
import com.edde746.plezy.shared.PlayerSurfaceHost
|
import com.edde746.plezy.shared.PlayerSurfaceHost
|
||||||
|
import com.edde746.plezy.shared.SurfacePlayerCore
|
||||||
import dev.jdtech.mpv.*
|
import dev.jdtech.mpv.*
|
||||||
import kotlinx.coroutines.*
|
import kotlinx.coroutines.*
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -40,7 +41,7 @@ class MpvPlayerCore private constructor(
|
|||||||
private val audioOnly: Boolean,
|
private val audioOnly: Boolean,
|
||||||
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
private val propertyWriterOverride: (suspend (String, String) -> Unit)?,
|
||||||
initializedForTesting: Boolean
|
initializedForTesting: Boolean
|
||||||
) : SurfaceHolder.Callback {
|
) : SurfaceHolder.Callback, SurfacePlayerCore {
|
||||||
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
constructor(context: Context, audioOnly: Boolean = false) : this(context, audioOnly, null, false)
|
||||||
|
|
||||||
internal constructor(
|
internal constructor(
|
||||||
@@ -405,7 +406,7 @@ class MpvPlayerCore private constructor(
|
|||||||
|
|
||||||
// Audio Focus
|
// Audio Focus
|
||||||
|
|
||||||
fun requestAudioFocus(): Boolean {
|
override fun requestAudioFocus(): Boolean {
|
||||||
val granted = audioFocusManager?.requestAudioFocus() ?: false
|
val granted = audioFocusManager?.requestAudioFocus() ?: false
|
||||||
if (granted && pausedForAudioFocusLoss) {
|
if (granted && pausedForAudioFocusLoss) {
|
||||||
resumeAfterAudioFocusGain("audio focus request granted")
|
resumeAfterAudioFocusGain("audio focus request granted")
|
||||||
@@ -413,7 +414,7 @@ class MpvPlayerCore private constructor(
|
|||||||
return granted
|
return granted
|
||||||
}
|
}
|
||||||
|
|
||||||
fun abandonAudioFocus() {
|
override fun abandonAudioFocus() {
|
||||||
audioFocusManager?.abandonAudioFocus()
|
audioFocusManager?.abandonAudioFocus()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1042,7 +1043,7 @@ class MpvPlayerCore private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setVisible(visible: Boolean) {
|
override fun setVisible(visible: Boolean) {
|
||||||
// Audio-only: no render layer to show or hide — tolerated no-op.
|
// Audio-only: no render layer to show or hide — tolerated no-op.
|
||||||
if (audioOnly || disposing) return
|
if (audioOnly || disposing) return
|
||||||
runOnMain {
|
runOnMain {
|
||||||
@@ -1067,11 +1068,11 @@ class MpvPlayerCore private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun onPipModeChanged(isInPipMode: Boolean) {
|
override fun onPipModeChanged(isInPipMode: Boolean) {
|
||||||
// MPV handles aspect ratio internally via its own surface management
|
// MPV handles aspect ratio internally via its own surface management
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updateFrame() {
|
override fun updateFrame() {
|
||||||
// Audio-only: no surface to refresh — tolerated no-op.
|
// Audio-only: no surface to refresh — tolerated no-op.
|
||||||
if (audioOnly || disposing) return
|
if (audioOnly || disposing) return
|
||||||
runOnMain {
|
runOnMain {
|
||||||
@@ -1106,7 +1107,7 @@ class MpvPlayerCore private constructor(
|
|||||||
|
|
||||||
// Frame Rate Matching
|
// Frame Rate Matching
|
||||||
|
|
||||||
fun setVideoFrameRate(
|
override fun setVideoFrameRate(
|
||||||
fps: Float,
|
fps: Float,
|
||||||
videoDurationMs: Long,
|
videoDurationMs: Long,
|
||||||
extraDelayMs: Long,
|
extraDelayMs: Long,
|
||||||
@@ -1128,7 +1129,7 @@ class MpvPlayerCore private constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun clearVideoFrameRate() {
|
override fun clearVideoFrameRate() {
|
||||||
frameRateManager?.clearVideoFrameRate()
|
frameRateManager?.clearVideoFrameRate()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package com.edde746.plezy.shared
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Surface and display concerns that the ExoPlayer and mpv cores implement
|
||||||
|
* identically, so a plugin holding either one dispatches without branching
|
||||||
|
* on which backend is active.
|
||||||
|
*
|
||||||
|
* Only backend-independent members belong here: playback control
|
||||||
|
* (play/seek/track selection) stays off this interface because mpv drives it
|
||||||
|
* through properties and commands where ExoPlayer uses direct method calls.
|
||||||
|
*/
|
||||||
|
interface SurfacePlayerCore {
|
||||||
|
fun setVisible(visible: Boolean)
|
||||||
|
fun updateFrame()
|
||||||
|
fun onPipModeChanged(isInPipMode: Boolean)
|
||||||
|
fun requestAudioFocus(): Boolean
|
||||||
|
fun abandonAudioFocus()
|
||||||
|
fun clearVideoFrameRate()
|
||||||
|
fun setVideoFrameRate(
|
||||||
|
fps: Float,
|
||||||
|
videoDurationMs: Long,
|
||||||
|
extraDelayMs: Long,
|
||||||
|
videoWidth: Int,
|
||||||
|
videoHeight: Int,
|
||||||
|
onComplete: (switched: Boolean) -> Unit
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ class ConnectionBootstrap {
|
|||||||
required this.profileRegistry,
|
required this.profileRegistry,
|
||||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||||
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
|
||||||
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers,
|
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
|
||||||
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
|
||||||
|
|
||||||
final StorageService storage;
|
final StorageService storage;
|
||||||
@@ -283,16 +283,6 @@ class ConnectionBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<PlexHomeUser>> _fetchPlexHomeUsers(String accountToken) async {
|
|
||||||
final auth = await PlexAuthService.create();
|
|
||||||
try {
|
|
||||||
final home = await auth.getHomeUsers(accountToken);
|
|
||||||
return home.users;
|
|
||||||
} finally {
|
|
||||||
auth.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
|
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
|
||||||
final auth = await PlexAuthService.create();
|
final auth = await PlexAuthService.create();
|
||||||
try {
|
try {
|
||||||
|
|||||||
+141
-302
@@ -237,197 +237,56 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
final joinRows = await (select(
|
final joinRows = await (select(
|
||||||
profileConnections,
|
profileConnections,
|
||||||
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get();
|
)..orderBy([(t) => OrderingTerm.asc(t.profileId), (t) => OrderingTerm.asc(t.connectionId)])).get();
|
||||||
|
// Drift's generated serializer is the recovery image's column schema:
|
||||||
|
// `toJson`/`fromJson` use these camelCase keys, so the read and restore
|
||||||
|
// sides can never drift apart when a column is added or renamed.
|
||||||
return {
|
return {
|
||||||
'connections': [
|
'connections': [for (final row in connectionRows) row.toJson()],
|
||||||
for (final row in connectionRows)
|
'profiles': [for (final row in profileRows) row.toJson()],
|
||||||
{
|
'profileConnections': [for (final row in joinRows) row.toJson()],
|
||||||
'id': row.id,
|
|
||||||
'kind': row.kind,
|
|
||||||
'displayName': row.displayName,
|
|
||||||
'configJson': row.configJson,
|
|
||||||
'isDefault': row.isDefault,
|
|
||||||
'createdAt': row.createdAt,
|
|
||||||
'lastAuthenticatedAt': row.lastAuthenticatedAt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'profiles': [
|
|
||||||
for (final row in profileRows)
|
|
||||||
{
|
|
||||||
'id': row.id,
|
|
||||||
'kind': row.kind,
|
|
||||||
'displayName': row.displayName,
|
|
||||||
'avatarThumbUrl': row.avatarThumbUrl,
|
|
||||||
'configJson': row.configJson,
|
|
||||||
'sortOrder': row.sortOrder,
|
|
||||||
'createdAt': row.createdAt,
|
|
||||||
'lastUsedAt': row.lastUsedAt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
'profileConnections': [
|
|
||||||
for (final row in joinRows)
|
|
||||||
{
|
|
||||||
'profileId': row.profileId,
|
|
||||||
'connectionId': row.connectionId,
|
|
||||||
'userToken': row.userToken,
|
|
||||||
'userIdentifier': row.userIdentifier,
|
|
||||||
'isDefault': row.isDefault,
|
|
||||||
'tokenAcquiredAt': row.tokenAcquiredAt,
|
|
||||||
'lastUsedAt': row.lastUsedAt,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<Map<String, Object?>> _readPendingRecoveryRows() async {
|
Future<Map<String, Object?>> _readPendingRecoveryRows() async {
|
||||||
final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
|
final rows = await (select(offlineWatchProgress)..orderBy([(t) => OrderingTerm.asc(t.id)])).get();
|
||||||
return {
|
return {
|
||||||
'offlineWatchProgress': [
|
'offlineWatchProgress': [for (final row in rows) row.toJson()],
|
||||||
for (final row in rows)
|
|
||||||
{
|
|
||||||
'id': row.id,
|
|
||||||
'profileId': row.profileId,
|
|
||||||
'serverId': row.serverId,
|
|
||||||
'clientScopeId': row.clientScopeId,
|
|
||||||
'ratingKey': row.ratingKey,
|
|
||||||
'globalKey': row.globalKey,
|
|
||||||
'actionType': row.actionType,
|
|
||||||
'viewOffset': row.viewOffset,
|
|
||||||
'duration': row.duration,
|
|
||||||
'shouldMarkWatched': row.shouldMarkWatched,
|
|
||||||
'createdAt': row.createdAt,
|
|
||||||
'updatedAt': row.updatedAt,
|
|
||||||
'syncAttempts': row.syncAttempts,
|
|
||||||
'lastError': row.lastError,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async {
|
Future<void> _restoreRecoverySnapshot(TvosDatabaseRecoverySnapshot snapshot) async {
|
||||||
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', const {
|
final connectionRows = _decodeRecoveryRows(snapshot.identity, 'connections', ConnectionRow.fromJson);
|
||||||
'id',
|
final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', ProfileRow.fromJson);
|
||||||
'kind',
|
final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', ProfileConnectionRow.fromJson);
|
||||||
'displayName',
|
final pendingRows = _decodeRecoveryRows(
|
||||||
'configJson',
|
snapshot.pending,
|
||||||
'isDefault',
|
'offlineWatchProgress',
|
||||||
'createdAt',
|
OfflineWatchProgressItem.fromJson,
|
||||||
'lastAuthenticatedAt',
|
);
|
||||||
});
|
|
||||||
final profileRows = _decodeRecoveryRows(snapshot.identity, 'profiles', const {
|
|
||||||
'id',
|
|
||||||
'kind',
|
|
||||||
'displayName',
|
|
||||||
'avatarThumbUrl',
|
|
||||||
'configJson',
|
|
||||||
'sortOrder',
|
|
||||||
'createdAt',
|
|
||||||
'lastUsedAt',
|
|
||||||
});
|
|
||||||
final joinRows = _decodeRecoveryRows(snapshot.identity, 'profileConnections', const {
|
|
||||||
'profileId',
|
|
||||||
'connectionId',
|
|
||||||
'userToken',
|
|
||||||
'userIdentifier',
|
|
||||||
'isDefault',
|
|
||||||
'tokenAcquiredAt',
|
|
||||||
'lastUsedAt',
|
|
||||||
});
|
|
||||||
final pendingRows = _decodeRecoveryRows(snapshot.pending, 'offlineWatchProgress', const {
|
|
||||||
'id',
|
|
||||||
'profileId',
|
|
||||||
'serverId',
|
|
||||||
'clientScopeId',
|
|
||||||
'ratingKey',
|
|
||||||
'globalKey',
|
|
||||||
'actionType',
|
|
||||||
'viewOffset',
|
|
||||||
'duration',
|
|
||||||
'shouldMarkWatched',
|
|
||||||
'createdAt',
|
|
||||||
'updatedAt',
|
|
||||||
'syncAttempts',
|
|
||||||
'lastError',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Recovery images from releases before the credential vault may contain
|
// Recovery images from releases before the credential vault may contain
|
||||||
// plaintext secrets. Protect them before they cross into Drift; already
|
// plaintext secrets. Protect them before they cross into Drift; already
|
||||||
// protected values remain byte-identical because vault protection is
|
// protected values remain byte-identical because vault protection is
|
||||||
// idempotent.
|
// idempotent.
|
||||||
for (final row in connectionRows) {
|
for (var index = 0; index < connectionRows.length; index++) {
|
||||||
final kind = _requiredRecoveryValue<String>(row, 'kind');
|
final row = connectionRows[index];
|
||||||
final configJson = _requiredRecoveryValue<String>(row, 'configJson');
|
final decoded = jsonDecode(row.configJson);
|
||||||
final decoded = jsonDecode(configJson);
|
|
||||||
if (decoded is! Map<String, dynamic>) {
|
if (decoded is! Map<String, dynamic>) {
|
||||||
throw const FormatException('Invalid connection configuration');
|
throw const FormatException('Invalid connection configuration');
|
||||||
}
|
}
|
||||||
if (_containsPlaintextConnectionCredential(kind, decoded)) {
|
if (_containsPlaintextConnectionCredential(row.kind, decoded)) {
|
||||||
row['configJson'] = jsonEncode(await CredentialVault.protectConnectionConfig(kind, decoded));
|
connectionRows[index] = row.copyWith(
|
||||||
|
configJson: jsonEncode(await CredentialVault.protectConnectionConfig(row.kind, decoded)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (final row in joinRows) {
|
for (var index = 0; index < joinRows.length; index++) {
|
||||||
final token = _requiredRecoveryValue<String>(row, 'userToken');
|
final row = joinRows[index];
|
||||||
if (token.isNotEmpty && !CredentialVault.isProtected(token)) {
|
if (row.userToken.isNotEmpty && !CredentialVault.isProtected(row.userToken)) {
|
||||||
row['userToken'] = await CredentialVault.protect(token);
|
joinRows[index] = row.copyWith(userToken: await CredentialVault.protect(row.userToken));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final connectionCompanions = [
|
|
||||||
for (final row in connectionRows)
|
|
||||||
ConnectionsCompanion(
|
|
||||||
id: Value(_requiredRecoveryValue<String>(row, 'id')),
|
|
||||||
kind: Value(_requiredRecoveryValue<String>(row, 'kind')),
|
|
||||||
displayName: Value(_requiredRecoveryValue<String>(row, 'displayName')),
|
|
||||||
configJson: Value(_requiredRecoveryValue<String>(row, 'configJson')),
|
|
||||||
isDefault: Value(_requiredRecoveryValue<bool>(row, 'isDefault')),
|
|
||||||
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
|
|
||||||
lastAuthenticatedAt: Value(_nullableRecoveryValue<int>(row, 'lastAuthenticatedAt')),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
final profileCompanions = [
|
|
||||||
for (final row in profileRows)
|
|
||||||
ProfilesCompanion(
|
|
||||||
id: Value(_requiredRecoveryValue<String>(row, 'id')),
|
|
||||||
kind: Value(_requiredRecoveryValue<String>(row, 'kind')),
|
|
||||||
displayName: Value(_requiredRecoveryValue<String>(row, 'displayName')),
|
|
||||||
avatarThumbUrl: Value(_nullableRecoveryValue<String>(row, 'avatarThumbUrl')),
|
|
||||||
configJson: Value(_requiredRecoveryValue<String>(row, 'configJson')),
|
|
||||||
sortOrder: Value(_requiredRecoveryValue<int>(row, 'sortOrder')),
|
|
||||||
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
|
|
||||||
lastUsedAt: Value(_nullableRecoveryValue<int>(row, 'lastUsedAt')),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
final joinCompanions = [
|
|
||||||
for (final row in joinRows)
|
|
||||||
ProfileConnectionsCompanion(
|
|
||||||
profileId: Value(_requiredRecoveryValue<String>(row, 'profileId')),
|
|
||||||
connectionId: Value(_requiredRecoveryValue<String>(row, 'connectionId')),
|
|
||||||
userToken: Value(_requiredRecoveryValue<String>(row, 'userToken')),
|
|
||||||
userIdentifier: Value(_requiredRecoveryValue<String>(row, 'userIdentifier')),
|
|
||||||
isDefault: Value(_requiredRecoveryValue<bool>(row, 'isDefault')),
|
|
||||||
tokenAcquiredAt: Value(_nullableRecoveryValue<int>(row, 'tokenAcquiredAt')),
|
|
||||||
lastUsedAt: Value(_nullableRecoveryValue<int>(row, 'lastUsedAt')),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
final pendingCompanions = [
|
|
||||||
for (final row in pendingRows)
|
|
||||||
OfflineWatchProgressCompanion(
|
|
||||||
id: Value(_requiredRecoveryValue<int>(row, 'id')),
|
|
||||||
profileId: Value(_nullableRecoveryValue<String>(row, 'profileId')),
|
|
||||||
serverId: Value(_requiredRecoveryValue<String>(row, 'serverId')),
|
|
||||||
clientScopeId: Value(_nullableRecoveryValue<String>(row, 'clientScopeId')),
|
|
||||||
ratingKey: Value(_requiredRecoveryValue<String>(row, 'ratingKey')),
|
|
||||||
globalKey: Value(_requiredRecoveryValue<String>(row, 'globalKey')),
|
|
||||||
actionType: Value(_requiredRecoveryValue<String>(row, 'actionType')),
|
|
||||||
viewOffset: Value(_nullableRecoveryValue<int>(row, 'viewOffset')),
|
|
||||||
duration: Value(_nullableRecoveryValue<int>(row, 'duration')),
|
|
||||||
shouldMarkWatched: Value(_requiredRecoveryValue<bool>(row, 'shouldMarkWatched')),
|
|
||||||
createdAt: Value(_requiredRecoveryValue<int>(row, 'createdAt')),
|
|
||||||
updatedAt: Value(_requiredRecoveryValue<int>(row, 'updatedAt')),
|
|
||||||
syncAttempts: Value(_requiredRecoveryValue<int>(row, 'syncAttempts')),
|
|
||||||
lastError: Value(_nullableRecoveryValue<String>(row, 'lastError')),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
await transaction(() async {
|
await transaction(() async {
|
||||||
// Recovery completion (the durable marker removal) is deliberately
|
// Recovery completion (the durable marker removal) is deliberately
|
||||||
// separate from this transaction. Replace the snapshot-owned rows so a
|
// separate from this transaction. Replace the snapshot-owned rows so a
|
||||||
@@ -437,54 +296,57 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
await delete(profiles).go();
|
await delete(profiles).go();
|
||||||
await delete(connections).go();
|
await delete(connections).go();
|
||||||
await delete(offlineWatchProgress).go();
|
await delete(offlineWatchProgress).go();
|
||||||
for (final row in connectionCompanions) {
|
// `toCompanion(false)` writes every column explicitly, including the
|
||||||
await into(connections).insert(row);
|
// nulls, so a restored row is byte-identical to the captured one rather
|
||||||
|
// than picking up column defaults.
|
||||||
|
for (final row in connectionRows) {
|
||||||
|
await into(connections).insert(row.toCompanion(false));
|
||||||
}
|
}
|
||||||
for (final row in profileCompanions) {
|
for (final row in profileRows) {
|
||||||
await into(profiles).insert(row);
|
await into(profiles).insert(row.toCompanion(false));
|
||||||
}
|
}
|
||||||
for (final row in joinCompanions) {
|
for (final row in joinRows) {
|
||||||
await into(profileConnections).insert(row);
|
await into(profileConnections).insert(row.toCompanion(false));
|
||||||
}
|
}
|
||||||
for (final row in pendingCompanions) {
|
for (final row in pendingRows) {
|
||||||
await into(offlineWatchProgress).insert(row);
|
await into(offlineWatchProgress).insert(row.toCompanion(false));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Map<String, Object?>> _decodeRecoveryRows(
|
static List<T> _decodeRecoveryRows<T extends DataClass>(
|
||||||
Map<String, Object?> group,
|
Map<String, Object?> group,
|
||||||
String key,
|
String key,
|
||||||
Set<String> expectedKeys,
|
T Function(Map<String, dynamic> json) fromJson,
|
||||||
) {
|
) {
|
||||||
final value = group[key];
|
final value = group[key];
|
||||||
if (value is! List) throw const FormatException('Invalid tvOS database recovery image');
|
if (value is! List) throw _invalidRecoveryImage;
|
||||||
return [
|
return [
|
||||||
for (final value in value)
|
for (final row in value)
|
||||||
if (value is Map<String, Object?> &&
|
if (row is Map<String, dynamic>) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage,
|
||||||
value.keys.toSet().containsAll(expectedKeys) &&
|
|
||||||
value.length == expectedKeys.length)
|
|
||||||
value
|
|
||||||
else
|
|
||||||
throw const FormatException('Invalid tvOS database recovery image'),
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static T _requiredRecoveryValue<T>(Map<String, Object?> row, String key) {
|
/// Reads one row through drift's generated deserializer and rejects anything
|
||||||
final value = row[key];
|
/// that does not round-trip back to the exact same map. Drift already throws
|
||||||
if (!row.containsKey(key) || value is! T) {
|
/// on a missing or mistyped required column; the round-trip additionally
|
||||||
throw const FormatException('Invalid tvOS database recovery image');
|
/// rejects unknown and missing-but-nullable columns, which the serializer
|
||||||
|
/// would otherwise accept silently.
|
||||||
|
static T _decodeRecoveryRow<T extends DataClass>(
|
||||||
|
Map<String, dynamic> row,
|
||||||
|
T Function(Map<String, dynamic> json) fromJson,
|
||||||
|
) {
|
||||||
|
final T decoded;
|
||||||
|
try {
|
||||||
|
decoded = fromJson(row);
|
||||||
|
} catch (_) {
|
||||||
|
throw _invalidRecoveryImage;
|
||||||
}
|
}
|
||||||
return value;
|
if (!mapEquals(decoded.toJson(), row)) throw _invalidRecoveryImage;
|
||||||
|
return decoded;
|
||||||
}
|
}
|
||||||
|
|
||||||
static T? _nullableRecoveryValue<T>(Map<String, Object?> row, String key) {
|
static const FormatException _invalidRecoveryImage = FormatException('Invalid tvOS database recovery image');
|
||||||
if (!row.containsKey(key)) throw const FormatException('Invalid tvOS database recovery image');
|
|
||||||
final value = row[key];
|
|
||||||
if (value == null) return null;
|
|
||||||
if (value is! T) throw const FormatException('Invalid tvOS database recovery image');
|
|
||||||
return value as T;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
int get schemaVersion => 19;
|
int get schemaVersion => 19;
|
||||||
@@ -649,89 +511,27 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
if (from < 17) {
|
if (from < 17) {
|
||||||
appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)');
|
appLogger.i('Scoping pinned legacy Plex metadata before removing bare cache rows (v17 migration)');
|
||||||
await customStatement('''
|
await customStatement(
|
||||||
WITH download_metadata_ids AS (
|
_rescopePinnedPlexMetadataStatement(
|
||||||
SELECT global_key, server_id, rating_key AS metadata_id
|
namespaceExpression: "'/~plex-profile/' || owner.profile_id || ':'",
|
||||||
FROM downloaded_media
|
ownerJoin: '''JOIN download_owners AS owner
|
||||||
UNION
|
ON owner.global_key = metadata.global_key''',
|
||||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
),
|
||||||
FROM downloaded_media
|
);
|
||||||
WHERE parent_rating_key IS NOT NULL
|
|
||||||
AND parent_rating_key != ''
|
|
||||||
UNION
|
|
||||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
|
||||||
FROM downloaded_media
|
|
||||||
WHERE grandparent_rating_key IS NOT NULL
|
|
||||||
AND grandparent_rating_key != ''
|
|
||||||
)
|
|
||||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
|
||||||
SELECT DISTINCT
|
|
||||||
metadata.server_id
|
|
||||||
|| '/~plex-profile/'
|
|
||||||
|| owner.profile_id
|
|
||||||
|| ':'
|
|
||||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
|
||||||
source.data,
|
|
||||||
source.pinned,
|
|
||||||
source.cached_at
|
|
||||||
FROM download_metadata_ids AS metadata
|
|
||||||
JOIN download_owners AS owner
|
|
||||||
ON owner.global_key = metadata.global_key
|
|
||||||
JOIN api_cache AS source
|
|
||||||
ON source.cache_key =
|
|
||||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
|
||||||
OR source.cache_key =
|
|
||||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
|
||||||
WHERE source.pinned = 1
|
|
||||||
ON CONFLICT(cache_key) DO UPDATE SET
|
|
||||||
data = excluded.data,
|
|
||||||
pinned = excluded.pinned,
|
|
||||||
cached_at = excluded.cached_at
|
|
||||||
''');
|
|
||||||
// A direct pre-v14 upgrade has no owners yet: profiles and owner
|
// A direct pre-v14 upgrade has no owners yet: profiles and owner
|
||||||
// adoption are bootstrapped only after the database opens. Preserve
|
// adoption are bootstrapped only after the database opens. Preserve
|
||||||
// those downloads in the neutral Plex transfer namespace so the
|
// those downloads in the neutral Plex transfer namespace so the
|
||||||
// first profile can adopt them without inheriting legacy watch data.
|
// first profile can adopt them without inheriting legacy watch data.
|
||||||
await customStatement('''
|
await customStatement(
|
||||||
WITH download_metadata_ids AS (
|
_rescopePinnedPlexMetadataStatement(
|
||||||
SELECT global_key, server_id, rating_key AS metadata_id
|
namespaceExpression: "'/~plex-transfer:'",
|
||||||
FROM downloaded_media
|
ownerFilter: '''AND NOT EXISTS (
|
||||||
UNION
|
SELECT 1
|
||||||
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
FROM download_owners AS owner
|
||||||
FROM downloaded_media
|
WHERE owner.global_key = metadata.global_key
|
||||||
WHERE parent_rating_key IS NOT NULL
|
)''',
|
||||||
AND parent_rating_key != ''
|
),
|
||||||
UNION
|
);
|
||||||
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
|
||||||
FROM downloaded_media
|
|
||||||
WHERE grandparent_rating_key IS NOT NULL
|
|
||||||
AND grandparent_rating_key != ''
|
|
||||||
)
|
|
||||||
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
|
||||||
SELECT DISTINCT
|
|
||||||
metadata.server_id
|
|
||||||
|| '/~plex-transfer:'
|
|
||||||
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
|
||||||
source.data,
|
|
||||||
source.pinned,
|
|
||||||
source.cached_at
|
|
||||||
FROM download_metadata_ids AS metadata
|
|
||||||
JOIN api_cache AS source
|
|
||||||
ON source.cache_key =
|
|
||||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
|
||||||
OR source.cache_key =
|
|
||||||
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
|
||||||
WHERE source.pinned = 1
|
|
||||||
AND NOT EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM download_owners AS owner
|
|
||||||
WHERE owner.global_key = metadata.global_key
|
|
||||||
)
|
|
||||||
ON CONFLICT(cache_key) DO UPDATE SET
|
|
||||||
data = excluded.data,
|
|
||||||
pinned = excluded.pinned,
|
|
||||||
cached_at = excluded.cached_at
|
|
||||||
''');
|
|
||||||
|
|
||||||
final transferRows = await customSelect('''
|
final transferRows = await customSelect('''
|
||||||
SELECT cache_key, data
|
SELECT cache_key, data
|
||||||
@@ -920,10 +720,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Expression<bool> _clientScopePredicate(GeneratedColumn<String> column, String? clientScopeId) {
|
|
||||||
return clientScopeId == null ? column.isNull() : column.equals(clientScopeId);
|
|
||||||
}
|
|
||||||
|
|
||||||
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
|
Expression<bool> _nullableTextPredicate(GeneratedColumn<String> column, String? value) {
|
||||||
return value == null ? column.isNull() : column.equals(value);
|
return value == null ? column.isNull() : column.equals(value);
|
||||||
}
|
}
|
||||||
@@ -974,7 +770,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
(t) =>
|
(t) =>
|
||||||
matchesKey(t) &
|
matchesKey(t) &
|
||||||
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
|
(filterProfile ? _nullableTextPredicate(t.profileId, profileId) : const Constant(true)) &
|
||||||
(filterClientScope ? _clientScopePredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
(filterClientScope ? _nullableTextPredicate(t.clientScopeId, clientScopeId) : const Constant(true)),
|
||||||
)
|
)
|
||||||
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]);
|
..orderBy([(t) => OrderingTerm.desc(t.updatedAt), (t) => OrderingTerm.desc(t.id)]);
|
||||||
}
|
}
|
||||||
@@ -1083,7 +879,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
(t) =>
|
(t) =>
|
||||||
t.globalKey.equals(globalKey) &
|
t.globalKey.equals(globalKey) &
|
||||||
_nullableTextPredicate(t.profileId, profileId) &
|
_nullableTextPredicate(t.profileId, profileId) &
|
||||||
_clientScopePredicate(t.clientScopeId, clientScopeId) &
|
_nullableTextPredicate(t.clientScopeId, clientScopeId) &
|
||||||
t.actionType.equals(OfflineActionType.progress.id),
|
t.actionType.equals(OfflineActionType.progress.id),
|
||||||
)
|
)
|
||||||
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
..orderBy([(t) => OrderingTerm.asc(t.id)]))
|
||||||
@@ -1145,7 +941,7 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
(t) =>
|
(t) =>
|
||||||
t.globalKey.equals(globalKey) &
|
t.globalKey.equals(globalKey) &
|
||||||
_nullableTextPredicate(t.profileId, profileId) &
|
_nullableTextPredicate(t.profileId, profileId) &
|
||||||
_clientScopePredicate(t.clientScopeId, clientScopeId),
|
_nullableTextPredicate(t.clientScopeId, clientScopeId),
|
||||||
))
|
))
|
||||||
.go();
|
.go();
|
||||||
|
|
||||||
@@ -1287,29 +1083,21 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) async {
|
Future<void> _writeSyncRule(String globalKey, SyncRulesCompanion values) async {
|
||||||
await (update(
|
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values);
|
||||||
syncRules,
|
|
||||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount)));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) async {
|
Future<void> updateSyncRuleCount(String globalKey, int episodeCount) =>
|
||||||
await (update(
|
_writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount)));
|
||||||
syncRules,
|
|
||||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) async {
|
Future<void> updateSyncRuleFilter(String globalKey, String downloadFilter) =>
|
||||||
await (update(
|
_writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter)));
|
||||||
syncRules,
|
|
||||||
)..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled)));
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> updateSyncRuleLastExecuted(String globalKey) async {
|
Future<void> updateSyncRuleEnabled(String globalKey, bool enabled) =>
|
||||||
await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(
|
_writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled)));
|
||||||
SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)),
|
|
||||||
);
|
Future<void> updateSyncRuleLastExecuted(String globalKey) =>
|
||||||
}
|
_writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)));
|
||||||
|
|
||||||
Future<void> deleteSyncRule(String globalKey) async {
|
Future<void> deleteSyncRule(String globalKey) async {
|
||||||
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go();
|
||||||
@@ -1331,6 +1119,57 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds the v17 statement that re-keys pinned legacy Plex metadata rows into
|
||||||
|
/// a scoped cache namespace.
|
||||||
|
///
|
||||||
|
/// The owned and the ownerless branch run the same operation over the same
|
||||||
|
/// `download_metadata_ids` set and differ only in three spots: the expression
|
||||||
|
/// spliced into the new `cache_key` ([namespaceExpression]), an optional join
|
||||||
|
/// that exposes the owning profile ([ownerJoin]), and an optional extra
|
||||||
|
/// predicate that keeps each branch to its own rows ([ownerFilter]).
|
||||||
|
String _rescopePinnedPlexMetadataStatement({
|
||||||
|
required String namespaceExpression,
|
||||||
|
String ownerJoin = '',
|
||||||
|
String ownerFilter = '',
|
||||||
|
}) =>
|
||||||
|
'''
|
||||||
|
WITH download_metadata_ids AS (
|
||||||
|
SELECT global_key, server_id, rating_key AS metadata_id
|
||||||
|
FROM downloaded_media
|
||||||
|
UNION
|
||||||
|
SELECT global_key, server_id, parent_rating_key AS metadata_id
|
||||||
|
FROM downloaded_media
|
||||||
|
WHERE parent_rating_key IS NOT NULL
|
||||||
|
AND parent_rating_key != ''
|
||||||
|
UNION
|
||||||
|
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
|
||||||
|
FROM downloaded_media
|
||||||
|
WHERE grandparent_rating_key IS NOT NULL
|
||||||
|
AND grandparent_rating_key != ''
|
||||||
|
)
|
||||||
|
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
|
||||||
|
SELECT DISTINCT
|
||||||
|
metadata.server_id
|
||||||
|
|| $namespaceExpression
|
||||||
|
|| substr(source.cache_key, length(metadata.server_id) + 2),
|
||||||
|
source.data,
|
||||||
|
source.pinned,
|
||||||
|
source.cached_at
|
||||||
|
FROM download_metadata_ids AS metadata
|
||||||
|
$ownerJoin
|
||||||
|
JOIN api_cache AS source
|
||||||
|
ON source.cache_key =
|
||||||
|
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
|
||||||
|
OR source.cache_key =
|
||||||
|
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
|
||||||
|
WHERE source.pinned = 1
|
||||||
|
$ownerFilter
|
||||||
|
ON CONFLICT(cache_key) DO UPDATE SET
|
||||||
|
data = excluded.data,
|
||||||
|
pinned = excluded.pinned,
|
||||||
|
cached_at = excluded.cached_at
|
||||||
|
''';
|
||||||
|
|
||||||
Future<File> _resolveProductionDatabaseFile() async {
|
Future<File> _resolveProductionDatabaseFile() async {
|
||||||
final dbFolder = (Platform.isAndroid || Platform.isIOS)
|
final dbFolder = (Platform.isAndroid || Platform.isIOS)
|
||||||
? await getApplicationDocumentsDirectory()
|
? await getApplicationDocumentsDirectory()
|
||||||
|
|||||||
@@ -271,64 +271,6 @@ extension DownloadDatabaseOperations on AppDatabase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> insertDownload({
|
|
||||||
required ServerId serverId,
|
|
||||||
String? clientScopeId,
|
|
||||||
required String ratingKey,
|
|
||||||
required String globalKey,
|
|
||||||
required String type,
|
|
||||||
String? parentRatingKey,
|
|
||||||
String? grandparentRatingKey,
|
|
||||||
required int status,
|
|
||||||
int mediaIndex = 0,
|
|
||||||
String? mediaSourceId,
|
|
||||||
}) async {
|
|
||||||
await customUpdate(
|
|
||||||
'''
|
|
||||||
INSERT INTO downloaded_media (
|
|
||||||
server_id,
|
|
||||||
client_scope_id,
|
|
||||||
rating_key,
|
|
||||||
global_key,
|
|
||||||
type,
|
|
||||||
parent_rating_key,
|
|
||||||
grandparent_rating_key,
|
|
||||||
status,
|
|
||||||
media_index,
|
|
||||||
media_source_id
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(global_key) DO UPDATE SET
|
|
||||||
server_id = excluded.server_id,
|
|
||||||
client_scope_id = excluded.client_scope_id,
|
|
||||||
rating_key = excluded.rating_key,
|
|
||||||
type = excluded.type,
|
|
||||||
parent_rating_key = excluded.parent_rating_key,
|
|
||||||
grandparent_rating_key = excluded.grandparent_rating_key,
|
|
||||||
status = excluded.status,
|
|
||||||
progress = 0,
|
|
||||||
total_bytes = NULL,
|
|
||||||
downloaded_bytes = 0,
|
|
||||||
error_message = NULL,
|
|
||||||
retry_count = 0,
|
|
||||||
media_index = excluded.media_index,
|
|
||||||
media_source_id = excluded.media_source_id
|
|
||||||
''',
|
|
||||||
variables: [
|
|
||||||
Variable<String>(serverId),
|
|
||||||
Variable<String>(clientScopeId),
|
|
||||||
Variable<String>(ratingKey),
|
|
||||||
Variable<String>(globalKey),
|
|
||||||
Variable<String>(type),
|
|
||||||
Variable<String>(parentRatingKey),
|
|
||||||
Variable<String>(grandparentRatingKey),
|
|
||||||
Variable<int>(status),
|
|
||||||
Variable<int>(mediaIndex),
|
|
||||||
Variable<String>(mediaSourceId),
|
|
||||||
],
|
|
||||||
updates: {downloadedMedia},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> addToQueue({
|
Future<void> addToQueue({
|
||||||
required String mediaGlobalKey,
|
required String mediaGlobalKey,
|
||||||
int priority = 0,
|
int priority = 0,
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ class ProfileConnections extends Table {
|
|||||||
// Profile.virtualPlexHome from PlexHomeService's live cache, never
|
// Profile.virtualPlexHome from PlexHomeService's live cache, never
|
||||||
// persisted in `profiles`), so an FK here would reject every join row
|
// persisted in `profiles`), so an FK here would reject every join row
|
||||||
// they need. Profile deletion instead cleans up join rows explicitly
|
// they need. Profile deletion instead cleans up join rows explicitly
|
||||||
// (removeAllProfileConnectionsAndCleanup in profile_connection_cleanup)
|
// (ProfileConnectionCleanup.removeAllProfileConnections)
|
||||||
// before calling ProfileRegistry.remove.
|
// before calling ProfileRegistry.remove.
|
||||||
TextColumn get profileId => text()();
|
TextColumn get profileId => text()();
|
||||||
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
|
TextColumn get connectionId => text().references(Connections, #id, onDelete: KeyAction.cascade)();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -32,22 +32,18 @@ class ChipKeyCallbacks {
|
|||||||
/// This mixin handles:
|
/// This mixin handles:
|
||||||
/// - Internal/external FocusNode pattern
|
/// - Internal/external FocusNode pattern
|
||||||
/// - `_isFocused` state tracking
|
/// - `_isFocused` state tracking
|
||||||
/// - Listener setup in `initState`
|
/// - Listener setup, handoff and cleanup across the State lifecycle
|
||||||
/// - Listener handoff in `didUpdateWidget`
|
|
||||||
/// - Cleanup in `dispose`
|
|
||||||
///
|
///
|
||||||
/// To use this mixin:
|
/// To use this mixin:
|
||||||
/// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class
|
/// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class
|
||||||
/// 2. Implement [widgetFocusNode] to return the widget's optional focusNode
|
/// 2. Implement [widgetFocusNode] to return the widget's optional focusNode
|
||||||
/// 3. Implement [debugLabel] to return a debug label for the internal node
|
/// 3. Implement [debugLabel] to return a debug label for the internal node
|
||||||
/// 4. Call [initFocusNode] in your `initState`
|
/// 4. Use [focusNode] and [isFocused] in your build method
|
||||||
/// 5. Call [updateFocusNode] in your `didUpdateWidget`
|
|
||||||
/// 6. Call [disposeFocusNode] in your `dispose`
|
|
||||||
/// 7. Use [focusNode] and [isFocused] in your build method
|
|
||||||
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
||||||
final _focusNodeBinding = OwnedFocusNodeBinding();
|
final _focusNodeBinding = OwnedFocusNodeBinding();
|
||||||
bool _isFocused = false;
|
bool _isFocused = false;
|
||||||
final _selectLongPress = DpadSelectLongPressController();
|
final _selectLongPress = DpadSelectLongPressController();
|
||||||
|
FocusNode? _boundExternalNode;
|
||||||
|
|
||||||
/// Override to return the widget's optional external focus node.
|
/// Override to return the widget's optional external focus node.
|
||||||
FocusNode? get widgetFocusNode;
|
FocusNode? get widgetFocusNode;
|
||||||
@@ -61,22 +57,30 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
|
|||||||
/// Whether this widget is currently focused.
|
/// Whether this widget is currently focused.
|
||||||
bool get isFocused => _isFocused;
|
bool get isFocused => _isFocused;
|
||||||
|
|
||||||
/// Call this in your `initState` to set up the focus listener.
|
@override
|
||||||
void initFocusNode() {
|
void initState() {
|
||||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
super.initState();
|
||||||
|
_bindFocusNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call this in your `didUpdateWidget` with the old widget's focusNode.
|
@override
|
||||||
void updateFocusNode(FocusNode? oldFocusNode) {
|
void didUpdateWidget(T oldWidget) {
|
||||||
if (oldFocusNode != widgetFocusNode) {
|
super.didUpdateWidget(oldWidget);
|
||||||
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
if (_boundExternalNode != widgetFocusNode) {
|
||||||
|
_bindFocusNode();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Call this in your `dispose` to clean up the focus listener.
|
@override
|
||||||
void disposeFocusNode() {
|
void dispose() {
|
||||||
_focusNodeBinding.dispose();
|
_focusNodeBinding.dispose();
|
||||||
_selectLongPress.dispose();
|
_selectLongPress.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _bindFocusNode() {
|
||||||
|
_boundExternalNode = widgetFocusNode;
|
||||||
|
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onFocusChange() {
|
void _onFocusChange() {
|
||||||
|
|||||||
+15
-34
@@ -24,6 +24,7 @@ import 'profiles/profile.dart';
|
|||||||
import 'profiles/profile_connection_cleanup.dart';
|
import 'profiles/profile_connection_cleanup.dart';
|
||||||
import 'profiles/profile_connection_registry.dart';
|
import 'profiles/profile_connection_registry.dart';
|
||||||
import 'profiles/profile_registry.dart';
|
import 'profiles/profile_registry.dart';
|
||||||
|
import 'profiles/profile_selection_policy.dart';
|
||||||
import 'mixins/mounted_set_state_mixin.dart';
|
import 'mixins/mounted_set_state_mixin.dart';
|
||||||
import 'theme/mono_theme.dart';
|
import 'theme/mono_theme.dart';
|
||||||
import 'profiles/plex_home_service.dart';
|
import 'profiles/plex_home_service.dart';
|
||||||
@@ -1069,8 +1070,7 @@ class _MainAppState extends State<MainApp> with WidgetsBindingObserver {
|
|||||||
pinPrompt: _rootPinPrompt,
|
pinPrompt: _rootPinPrompt,
|
||||||
shouldDeferInitialBind: (_) async {
|
shouldDeferInitialBind: (_) async {
|
||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
return settings.read(SettingsService.requireProfileSelectionOnOpen) &&
|
return activeProfile.requiresSelectionOnOpen(settings);
|
||||||
activeProfile.hasMultipleProfiles;
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -1211,7 +1211,7 @@ class _AppShell extends StatelessWidget {
|
|||||||
themeMode: themeProvider.materialThemeMode,
|
themeMode: themeProvider.materialThemeMode,
|
||||||
navigatorKey: rootNavigatorKey,
|
navigatorKey: rootNavigatorKey,
|
||||||
navigatorObservers: [BackKeySuppressorObserver()],
|
navigatorObservers: [BackKeySuppressorObserver()],
|
||||||
home: OrientationAwareSetup(databaseRecoveryOutcome: databaseRecoveryOutcome),
|
home: SetupScreen(databaseRecoveryOutcome: databaseRecoveryOutcome),
|
||||||
// Siri Remote select + gamepad A report as
|
// Siri Remote select + gamepad A report as
|
||||||
// LogicalKeyboardKey.{select,gameButtonA} which aren't
|
// LogicalKeyboardKey.{select,gameButtonA} which aren't
|
||||||
// in Flutter's default shortcut set — Material-level
|
// in Flutter's default shortcut set — Material-level
|
||||||
@@ -1298,32 +1298,6 @@ bool shouldBypassSetupForDatabaseRecovery(TvosDatabaseRecoveryOutcome outcome) {
|
|||||||
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
|
return outcome == TvosDatabaseRecoveryOutcome.recoveryRequired;
|
||||||
}
|
}
|
||||||
|
|
||||||
class OrientationAwareSetup extends StatefulWidget {
|
|
||||||
const OrientationAwareSetup({super.key, required this.databaseRecoveryOutcome});
|
|
||||||
|
|
||||||
final TvosDatabaseRecoveryOutcome databaseRecoveryOutcome;
|
|
||||||
|
|
||||||
@override
|
|
||||||
State<OrientationAwareSetup> createState() => _OrientationAwareSetupState();
|
|
||||||
}
|
|
||||||
|
|
||||||
class _OrientationAwareSetupState extends State<OrientationAwareSetup> {
|
|
||||||
@override
|
|
||||||
void didChangeDependencies() {
|
|
||||||
super.didChangeDependencies();
|
|
||||||
_setOrientationPreferences();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setOrientationPreferences() {
|
|
||||||
OrientationHelper.restoreDefaultOrientations(context);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return SetupScreen(databaseRecoveryOutcome: widget.databaseRecoveryOutcome);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class SetupScreen extends StatefulWidget {
|
class SetupScreen extends StatefulWidget {
|
||||||
const SetupScreen({
|
const SetupScreen({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -1354,6 +1328,15 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
_loadSavedCredentials();
|
_loadSavedCredentials();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didChangeDependencies() {
|
||||||
|
super.didChangeDependencies();
|
||||||
|
// The app's first screen: undo any orientation lock a previous run's
|
||||||
|
// full-screen player left behind, and re-apply it whenever the form
|
||||||
|
// factor signals (Theme.platform / MediaQuery size) change.
|
||||||
|
OrientationHelper.restoreDefaultOrientations(context);
|
||||||
|
}
|
||||||
|
|
||||||
void _setStatus(String message) {
|
void _setStatus(String message) {
|
||||||
setStateIfMounted(() => _statusMessage = message);
|
setStateIfMounted(() => _statusMessage = message);
|
||||||
}
|
}
|
||||||
@@ -1416,12 +1399,12 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
profileRegistry: profileRegistry,
|
profileRegistry: profileRegistry,
|
||||||
);
|
);
|
||||||
await bootstrap.run();
|
await bootstrap.run();
|
||||||
final pruned = await pruneUnreferencedJellyfinConnections(
|
final pruned = await ProfileConnectionCleanup(
|
||||||
profileConnections: profileConnections,
|
profileConnections: profileConnections,
|
||||||
connections: connRegistry,
|
connections: connRegistry,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
serverManager: serverManager,
|
serverManager: serverManager,
|
||||||
);
|
).pruneUnreferencedJellyfinConnections();
|
||||||
if (pruned > 0) {
|
if (pruned > 0) {
|
||||||
appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}');
|
appLogger.i('Setup: pruned $pruned unreferenced Jellyfin connection${pruned == 1 ? '' : 's'}');
|
||||||
}
|
}
|
||||||
@@ -1559,9 +1542,7 @@ class _SetupScreenState extends State<SetupScreen> with MountedSetStateMixin {
|
|||||||
final settings = await SettingsService.getInstance();
|
final settings = await SettingsService.getInstance();
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
||||||
final requireOnOpen =
|
final shouldPrompt = hasNoActive || activeProfile.requiresSelectionOnOpen(settings);
|
||||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
|
||||||
final shouldPrompt = hasNoActive || requireOnOpen;
|
|
||||||
|
|
||||||
var bindingSucceeded = activeProfile.lastBindingSucceeded;
|
var bindingSucceeded = activeProfile.lastBindingSucceeded;
|
||||||
if (shouldPrompt) {
|
if (shouldPrompt) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
// ignore_for_file: invalid_annotation_target
|
// ignore_for_file: invalid_annotation_target
|
||||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||||
|
|
||||||
|
import '../utils/media_server_http_client.dart' show AbortController;
|
||||||
import 'media_kind.dart';
|
import 'media_kind.dart';
|
||||||
|
|
||||||
part 'library_query.freezed.dart';
|
part 'library_query.freezed.dart';
|
||||||
@@ -84,3 +85,33 @@ int fallbackPageTotal({required int offset, required int itemCount, int? request
|
|||||||
final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
|
final fullPage = requestedSize != null && requestedSize > 0 && itemCount >= requestedSize;
|
||||||
return offset + itemCount + (fullPage ? 1 : 0);
|
return offset + itemCount + (fullPage ? 1 : 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Walk every page of a paginated endpoint and concatenate the results.
|
||||||
|
///
|
||||||
|
/// [fetchPage] receives a zero-based offset and [pageSize] and is called until
|
||||||
|
/// a page comes back empty, the accumulated count reaches the page's
|
||||||
|
/// [LibraryPage.totalCount], or — when [stopOnShortPage] is set — a page comes
|
||||||
|
/// back shorter than [pageSize]. The short-page break is for backends whose
|
||||||
|
/// total is unreliable; leave it off when the total is authoritative.
|
||||||
|
///
|
||||||
|
/// [abort] is checked before and after every request. Errors propagate.
|
||||||
|
Future<List<T>> drainPages<T>(
|
||||||
|
Future<LibraryPage<T>> Function(int start, int size) fetchPage, {
|
||||||
|
required int pageSize,
|
||||||
|
AbortController? abort,
|
||||||
|
bool stopOnShortPage = false,
|
||||||
|
}) async {
|
||||||
|
final all = <T>[];
|
||||||
|
var start = 0;
|
||||||
|
while (true) {
|
||||||
|
abort?.throwIfAborted();
|
||||||
|
final page = await fetchPage(start, pageSize);
|
||||||
|
abort?.throwIfAborted();
|
||||||
|
if (page.items.isEmpty) break;
|
||||||
|
all.addAll(page.items);
|
||||||
|
start += page.items.length;
|
||||||
|
if (start >= page.totalCount) break;
|
||||||
|
if (stopOnShortPage && page.items.length < pageSize) break;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|||||||
@@ -212,55 +212,35 @@ class ServerCapabilities {
|
|||||||
audioTranscoding: true,
|
audioTranscoding: true,
|
||||||
);
|
);
|
||||||
|
|
||||||
ServerCapabilities copyWith({
|
/// Every flag here is fixed per backend *kind* except [videoTranscoding],
|
||||||
bool? serverSidePlayQueue,
|
/// which Plex probes per server (`PlexClient.capabilities`) — so that is the
|
||||||
bool? serverSidePlaylists,
|
/// only override this type needs. Widen the parameter list if another flag
|
||||||
bool? liveTv,
|
/// ever becomes a runtime probe.
|
||||||
bool? liveTvDvr,
|
ServerCapabilities copyWith({bool? videoTranscoding}) {
|
||||||
bool? subtitleSearch,
|
|
||||||
bool? videoTranscoding,
|
|
||||||
bool? serverSideSync,
|
|
||||||
bool? richHubs,
|
|
||||||
bool? numericUserRating,
|
|
||||||
bool? userFavorites,
|
|
||||||
bool? continueWatchingRemoval,
|
|
||||||
bool? externalSubtitleSearch,
|
|
||||||
bool? trackPreferencePersistence,
|
|
||||||
bool? endpointFailover,
|
|
||||||
bool? offlineWatchQueue,
|
|
||||||
bool? discordRpc,
|
|
||||||
bool? richMetadataEdit,
|
|
||||||
AlphaBarMode? alphaBar,
|
|
||||||
bool? scrubThumbnails,
|
|
||||||
bool? folderGrouping,
|
|
||||||
bool? lyrics,
|
|
||||||
bool? instantMix,
|
|
||||||
bool? audioTranscoding,
|
|
||||||
}) {
|
|
||||||
return ServerCapabilities(
|
return ServerCapabilities(
|
||||||
serverSidePlayQueue: serverSidePlayQueue ?? this.serverSidePlayQueue,
|
serverSidePlayQueue: serverSidePlayQueue,
|
||||||
serverSidePlaylists: serverSidePlaylists ?? this.serverSidePlaylists,
|
serverSidePlaylists: serverSidePlaylists,
|
||||||
liveTv: liveTv ?? this.liveTv,
|
liveTv: liveTv,
|
||||||
liveTvDvr: liveTvDvr ?? this.liveTvDvr,
|
liveTvDvr: liveTvDvr,
|
||||||
subtitleSearch: subtitleSearch ?? this.subtitleSearch,
|
subtitleSearch: subtitleSearch,
|
||||||
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
|
videoTranscoding: videoTranscoding ?? this.videoTranscoding,
|
||||||
serverSideSync: serverSideSync ?? this.serverSideSync,
|
serverSideSync: serverSideSync,
|
||||||
richHubs: richHubs ?? this.richHubs,
|
richHubs: richHubs,
|
||||||
numericUserRating: numericUserRating ?? this.numericUserRating,
|
numericUserRating: numericUserRating,
|
||||||
userFavorites: userFavorites ?? this.userFavorites,
|
userFavorites: userFavorites,
|
||||||
continueWatchingRemoval: continueWatchingRemoval ?? this.continueWatchingRemoval,
|
continueWatchingRemoval: continueWatchingRemoval,
|
||||||
externalSubtitleSearch: externalSubtitleSearch ?? this.externalSubtitleSearch,
|
externalSubtitleSearch: externalSubtitleSearch,
|
||||||
trackPreferencePersistence: trackPreferencePersistence ?? this.trackPreferencePersistence,
|
trackPreferencePersistence: trackPreferencePersistence,
|
||||||
endpointFailover: endpointFailover ?? this.endpointFailover,
|
endpointFailover: endpointFailover,
|
||||||
offlineWatchQueue: offlineWatchQueue ?? this.offlineWatchQueue,
|
offlineWatchQueue: offlineWatchQueue,
|
||||||
discordRpc: discordRpc ?? this.discordRpc,
|
discordRpc: discordRpc,
|
||||||
richMetadataEdit: richMetadataEdit ?? this.richMetadataEdit,
|
richMetadataEdit: richMetadataEdit,
|
||||||
alphaBar: alphaBar ?? this.alphaBar,
|
alphaBar: alphaBar,
|
||||||
scrubThumbnails: scrubThumbnails ?? this.scrubThumbnails,
|
scrubThumbnails: scrubThumbnails,
|
||||||
folderGrouping: folderGrouping ?? this.folderGrouping,
|
folderGrouping: folderGrouping,
|
||||||
lyrics: lyrics ?? this.lyrics,
|
lyrics: lyrics,
|
||||||
instantMix: instantMix ?? this.instantMix,
|
instantMix: instantMix,
|
||||||
audioTranscoding: audioTranscoding ?? this.audioTranscoding,
|
audioTranscoding: audioTranscoding,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import '../media/media_kind.dart';
|
|||||||
import '../media/media_server_client.dart';
|
import '../media/media_server_client.dart';
|
||||||
import '../services/jellyfin_client.dart';
|
import '../services/jellyfin_client.dart';
|
||||||
import '../utils/jellyfin_time.dart';
|
import '../utils/jellyfin_time.dart';
|
||||||
import '../utils/media_image_helper.dart';
|
|
||||||
import 'metadata_edit_models.dart';
|
import 'metadata_edit_models.dart';
|
||||||
|
|
||||||
class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
||||||
@@ -39,7 +38,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
||||||
final kind = draft.sourceItem.kind;
|
final kind = draft.sourceItem.kind;
|
||||||
return [
|
return [
|
||||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)),
|
MetadataEditSection(
|
||||||
|
id: 'basic',
|
||||||
|
title: t.metadataEdit.basicInfo,
|
||||||
|
fields: metadataBasicFields(kind, studioType: MetadataEditFieldType.stringList),
|
||||||
|
),
|
||||||
if (_tagFields(kind).isNotEmpty)
|
if (_tagFields(kind).isNotEmpty)
|
||||||
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
||||||
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
||||||
@@ -53,11 +56,11 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
final dto = Map<String, dynamic>.from(raw);
|
final dto = Map<String, dynamic>.from(raw);
|
||||||
|
|
||||||
dto['ProviderIds'] = _stringMap(dto['ProviderIds']);
|
dto['ProviderIds'] = _stringMap(dto['ProviderIds']);
|
||||||
dto['Tags'] = _stringList(dto['Tags']);
|
dto['Tags'] = metadataStringList(dto['Tags']);
|
||||||
dto['Genres'] = _stringList(dto['Genres']);
|
dto['Genres'] = metadataStringList(dto['Genres']);
|
||||||
dto['People'] = _mapList(dto['People']);
|
dto['People'] = _mapList(dto['People']);
|
||||||
dto['Studios'] = _mapList(dto['Studios']);
|
dto['Studios'] = _mapList(dto['Studios']);
|
||||||
dto['LockedFields'] = _stringList(dto['LockedFields']);
|
dto['LockedFields'] = metadataStringList(dto['LockedFields']);
|
||||||
dto['LockData'] = dto['LockData'] == true;
|
dto['LockData'] = dto['LockData'] == true;
|
||||||
dto.remove('Trickplay');
|
dto.remove('Trickplay');
|
||||||
|
|
||||||
@@ -71,29 +74,29 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
final value = draft.value<String>('originallyAvailableAt') ?? '';
|
final value = draft.value<String>('originallyAvailableAt') ?? '';
|
||||||
dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']);
|
dto['PremiereDate'] = _jellyfinDate(value, raw['PremiereDate']);
|
||||||
}
|
}
|
||||||
if (_fieldChanged(draft, 'studio')) {
|
if (_listFieldChanged(draft, 'studio')) {
|
||||||
dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio']));
|
dto['Studios'] = _replaceNamePairs(_mapList(dto['Studios']), metadataStringList(draft.values['studio']));
|
||||||
}
|
}
|
||||||
if (draft.fieldChanged('tagline')) {
|
if (draft.fieldChanged('tagline')) {
|
||||||
final tagline = metadataEmptyToNull(draft.value<String>('tagline'));
|
final tagline = metadataEmptyToNull(draft.value<String>('tagline'));
|
||||||
final existing = _stringList(dto['Taglines']);
|
final existing = metadataStringList(dto['Taglines']);
|
||||||
dto['Taglines'] = tagline == null ? <String>[] : <String>[tagline, ...existing.skip(1)];
|
dto['Taglines'] = tagline == null ? <String>[] : <String>[tagline, ...existing.skip(1)];
|
||||||
}
|
}
|
||||||
if (_fieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']);
|
if (_listFieldChanged(draft, 'genre')) dto['Genres'] = metadataStringList(draft.values['genre']);
|
||||||
if (_fieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']);
|
if (_listFieldChanged(draft, 'country')) dto['ProductionLocations'] = metadataStringList(draft.values['country']);
|
||||||
if (_fieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']);
|
if (_listFieldChanged(draft, 'label')) dto['Tags'] = metadataStringList(draft.values['label']);
|
||||||
|
|
||||||
var peopleChanged = false;
|
var peopleChanged = false;
|
||||||
var people = _mapList(dto['People']);
|
var people = _mapList(dto['People']);
|
||||||
if (_fieldChanged(draft, 'director')) {
|
if (_listFieldChanged(draft, 'director')) {
|
||||||
people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director']));
|
people = _replacePeopleByType(people, 'Director', metadataStringList(draft.values['director']));
|
||||||
peopleChanged = true;
|
peopleChanged = true;
|
||||||
}
|
}
|
||||||
if (_fieldChanged(draft, 'writer')) {
|
if (_listFieldChanged(draft, 'writer')) {
|
||||||
people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer']));
|
people = _replacePeopleByType(people, 'Writer', metadataStringList(draft.values['writer']));
|
||||||
peopleChanged = true;
|
peopleChanged = true;
|
||||||
}
|
}
|
||||||
if (_fieldChanged(draft, 'producer')) {
|
if (_listFieldChanged(draft, 'producer')) {
|
||||||
people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer']));
|
people = _replacePeopleByType(people, 'Producer', metadataStringList(draft.values['producer']));
|
||||||
peopleChanged = true;
|
peopleChanged = true;
|
||||||
}
|
}
|
||||||
@@ -132,11 +135,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
|
||||||
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
||||||
final imageType = field.artwork?.key;
|
final imageType = field.artwork?.key;
|
||||||
@@ -180,12 +178,12 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
? metadataFirstString(raw['Taglines'])
|
? metadataFirstString(raw['Taglines'])
|
||||||
: item.tagline ?? '';
|
: item.tagline ?? '';
|
||||||
values['summary'] = raw['Overview'] as String? ?? item.summary ?? '';
|
values['summary'] = raw['Overview'] as String? ?? item.summary ?? '';
|
||||||
values['genre'] = _stringList(raw['Genres']);
|
values['genre'] = metadataStringList(raw['Genres']);
|
||||||
values['director'] = _peopleByType(raw['People'], 'Director');
|
values['director'] = _peopleByType(raw['People'], 'Director');
|
||||||
values['writer'] = _peopleByType(raw['People'], 'Writer');
|
values['writer'] = _peopleByType(raw['People'], 'Writer');
|
||||||
values['producer'] = _peopleByType(raw['People'], 'Producer');
|
values['producer'] = _peopleByType(raw['People'], 'Producer');
|
||||||
values['country'] = _stringList(raw['ProductionLocations']);
|
values['country'] = metadataStringList(raw['ProductionLocations']);
|
||||||
values['label'] = _stringList(raw['Tags']);
|
values['label'] = metadataStringList(raw['Tags']);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _writeArtworkValues(Map<String, Object?> values, MediaItem item) {
|
void _writeArtworkValues(Map<String, Object?> values, MediaItem item) {
|
||||||
@@ -194,29 +192,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
values['artwork:Logo'] = item.clearLogoPath;
|
values['artwork:Logo'] = item.clearLogoPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MetadataEditField> _basicFields(MediaKind kind) {
|
|
||||||
return [
|
|
||||||
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
|
|
||||||
if (kind != MediaKind.season)
|
|
||||||
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
|
||||||
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
|
|
||||||
if (kind != MediaKind.season)
|
|
||||||
MetadataEditField(
|
|
||||||
id: 'originallyAvailableAt',
|
|
||||||
label: t.metadataEdit.releaseDate,
|
|
||||||
type: MetadataEditFieldType.date,
|
|
||||||
),
|
|
||||||
if (kind != MediaKind.season)
|
|
||||||
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
|
||||||
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.stringList),
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
|
||||||
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
|
|
||||||
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
List<MetadataEditField> _tagFields(MediaKind kind) {
|
List<MetadataEditField> _tagFields(MediaKind kind) {
|
||||||
MetadataEditField tag(String id, String label) =>
|
MetadataEditField tag(String id, String label) =>
|
||||||
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
||||||
@@ -234,100 +209,20 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MetadataEditField> _artworkFields(MediaKind kind) {
|
List<MetadataEditField> _artworkFields(MediaKind kind) =>
|
||||||
final fields = <MetadataEditField>[
|
metadataArtworkFields(kind, posterKey: 'Primary', backdropKey: 'Backdrop', logoKey: 'Logo');
|
||||||
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
|
||||||
kind == MediaKind.episode
|
|
||||||
? _artworkField(
|
|
||||||
'Primary',
|
|
||||||
t.metadataEdit.poster,
|
|
||||||
t.metadataEdit.selectPoster,
|
|
||||||
80,
|
|
||||||
45,
|
|
||||||
2,
|
|
||||||
16 / 9,
|
|
||||||
imageType: ImageType.thumb,
|
|
||||||
)
|
|
||||||
: _artworkField('Primary', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
|
||||||
];
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
|
||||||
fields.add(
|
|
||||||
_artworkField(
|
|
||||||
'Backdrop',
|
|
||||||
t.metadataEdit.background,
|
|
||||||
t.metadataEdit.selectBackground,
|
|
||||||
80,
|
|
||||||
45,
|
|
||||||
2,
|
|
||||||
16 / 9,
|
|
||||||
imageType: ImageType.art,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show) {
|
|
||||||
fields.add(
|
|
||||||
_artworkField(
|
|
||||||
'Logo',
|
|
||||||
t.metadataEdit.logo,
|
|
||||||
t.metadataEdit.selectLogo,
|
|
||||||
80,
|
|
||||||
32,
|
|
||||||
2,
|
|
||||||
2.5,
|
|
||||||
fit: MetadataArtworkFit.contain,
|
|
||||||
imageType: ImageType.logo,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
MetadataEditField _artworkField(
|
|
||||||
String key,
|
|
||||||
String label,
|
|
||||||
String title,
|
|
||||||
double width,
|
|
||||||
double height,
|
|
||||||
int columns,
|
|
||||||
double aspectRatio, {
|
|
||||||
MetadataArtworkFit fit = MetadataArtworkFit.cover,
|
|
||||||
ImageType imageType = ImageType.poster,
|
|
||||||
}) {
|
|
||||||
return MetadataEditField(
|
|
||||||
id: 'artwork:$key',
|
|
||||||
label: label,
|
|
||||||
type: MetadataEditFieldType.artwork,
|
|
||||||
saveMode: MetadataEditSaveMode.immediate,
|
|
||||||
artwork: MetadataArtworkConfig(
|
|
||||||
key: key,
|
|
||||||
selectTitle: title,
|
|
||||||
previewWidth: width,
|
|
||||||
previewHeight: height,
|
|
||||||
gridColumns: columns,
|
|
||||||
gridAspectRatio: aspectRatio,
|
|
||||||
fit: fit,
|
|
||||||
imageType: imageType,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _setChangedString(Map<String, dynamic> dto, MetadataEditDraft draft, String fieldId, String dtoKey) {
|
void _setChangedString(Map<String, dynamic> dto, MetadataEditDraft draft, String fieldId, String dtoKey) {
|
||||||
if (!draft.fieldChanged(fieldId)) return;
|
if (!draft.fieldChanged(fieldId)) return;
|
||||||
dto[dtoKey] = metadataEmptyToNull(draft.value<String>(fieldId));
|
dto[dtoKey] = metadataEmptyToNull(draft.value<String>(fieldId));
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _fieldChanged(MetadataEditDraft draft, String fieldId) {
|
/// Every id passed here names a `stringList` field, so the comparison is
|
||||||
for (final section in schemaFor(draft)) {
|
/// order-insensitive regardless of which kind's schema is in play.
|
||||||
for (final field in section.fields) {
|
bool _listFieldChanged(MetadataEditDraft draft, String fieldId) =>
|
||||||
if (field.id == fieldId) return metadataEditFieldChanged(draft, field);
|
!metadataEditStringListEquals(draft.values[fieldId], draft.originalValues[fieldId]);
|
||||||
}
|
|
||||||
}
|
|
||||||
return draft.fieldChanged(fieldId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _stringList(Object? value) => metadataStringList(value);
|
|
||||||
|
|
||||||
Map<String, String> _stringMap(Object? value) {
|
Map<String, String> _stringMap(Object? value) {
|
||||||
if (value is! Map) return <String, String>{};
|
if (value is! Map) return <String, String>{};
|
||||||
return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? ''));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import '../i18n/strings.g.dart';
|
||||||
import '../media/media_backend.dart';
|
import '../media/media_backend.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_kind.dart';
|
import '../media/media_kind.dart';
|
||||||
@@ -147,7 +148,9 @@ abstract class MetadataEditAdapter {
|
|||||||
|
|
||||||
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field);
|
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field);
|
||||||
|
|
||||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option);
|
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
||||||
|
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url);
|
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url);
|
||||||
|
|
||||||
@@ -160,6 +163,133 @@ abstract class MetadataEditAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Basic-info fields shared by every backend; only the studio field type differs.
|
||||||
|
List<MetadataEditField> metadataBasicFields(
|
||||||
|
MediaKind kind, {
|
||||||
|
MetadataEditFieldType studioType = MetadataEditFieldType.text,
|
||||||
|
}) {
|
||||||
|
return [
|
||||||
|
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
|
||||||
|
if (kind != MediaKind.season)
|
||||||
|
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
|
||||||
|
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||||
|
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
|
||||||
|
if (kind != MediaKind.season)
|
||||||
|
MetadataEditField(
|
||||||
|
id: 'originallyAvailableAt',
|
||||||
|
label: t.metadataEdit.releaseDate,
|
||||||
|
type: MetadataEditFieldType.date,
|
||||||
|
),
|
||||||
|
if (kind != MediaKind.season)
|
||||||
|
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
|
||||||
|
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||||
|
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: studioType),
|
||||||
|
if (kind == MediaKind.movie || kind == MediaKind.show)
|
||||||
|
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
|
||||||
|
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Artwork fields shared by every backend; each backend supplies its own artwork
|
||||||
|
/// key names, which kinds carry a logo, and whether square art exists.
|
||||||
|
List<MetadataEditField> metadataArtworkFields(
|
||||||
|
MediaKind kind, {
|
||||||
|
required String posterKey,
|
||||||
|
required String backdropKey,
|
||||||
|
required String logoKey,
|
||||||
|
String? squareKey,
|
||||||
|
Set<MediaKind> logoKinds = const {MediaKind.movie, MediaKind.show},
|
||||||
|
}) {
|
||||||
|
final fields = <MetadataEditField>[
|
||||||
|
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
||||||
|
kind == MediaKind.episode
|
||||||
|
? metadataArtworkField(
|
||||||
|
posterKey,
|
||||||
|
t.metadataEdit.poster,
|
||||||
|
t.metadataEdit.selectPoster,
|
||||||
|
80,
|
||||||
|
45,
|
||||||
|
2,
|
||||||
|
16 / 9,
|
||||||
|
imageType: ImageType.thumb,
|
||||||
|
)
|
||||||
|
: metadataArtworkField(posterKey, t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
||||||
|
];
|
||||||
|
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
||||||
|
fields.add(
|
||||||
|
metadataArtworkField(
|
||||||
|
backdropKey,
|
||||||
|
t.metadataEdit.background,
|
||||||
|
t.metadataEdit.selectBackground,
|
||||||
|
80,
|
||||||
|
45,
|
||||||
|
2,
|
||||||
|
16 / 9,
|
||||||
|
imageType: ImageType.art,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (logoKinds.contains(kind)) {
|
||||||
|
fields.add(
|
||||||
|
metadataArtworkField(
|
||||||
|
logoKey,
|
||||||
|
t.metadataEdit.logo,
|
||||||
|
t.metadataEdit.selectLogo,
|
||||||
|
80,
|
||||||
|
32,
|
||||||
|
2,
|
||||||
|
2.5,
|
||||||
|
fit: MetadataArtworkFit.contain,
|
||||||
|
imageType: ImageType.logo,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (squareKey != null) {
|
||||||
|
fields.add(
|
||||||
|
metadataArtworkField(
|
||||||
|
squareKey,
|
||||||
|
t.metadataEdit.squareArt,
|
||||||
|
t.metadataEdit.selectSquareArt,
|
||||||
|
50,
|
||||||
|
50,
|
||||||
|
3,
|
||||||
|
1,
|
||||||
|
imageType: ImageType.avatar,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
MetadataEditField metadataArtworkField(
|
||||||
|
String key,
|
||||||
|
String label,
|
||||||
|
String title,
|
||||||
|
double width,
|
||||||
|
double height,
|
||||||
|
int columns,
|
||||||
|
double aspectRatio, {
|
||||||
|
MetadataArtworkFit fit = MetadataArtworkFit.cover,
|
||||||
|
ImageType imageType = ImageType.poster,
|
||||||
|
}) {
|
||||||
|
return MetadataEditField(
|
||||||
|
id: 'artwork:$key',
|
||||||
|
label: label,
|
||||||
|
type: MetadataEditFieldType.artwork,
|
||||||
|
saveMode: MetadataEditSaveMode.immediate,
|
||||||
|
artwork: MetadataArtworkConfig(
|
||||||
|
key: key,
|
||||||
|
selectTitle: title,
|
||||||
|
previewWidth: width,
|
||||||
|
previewHeight: height,
|
||||||
|
gridColumns: columns,
|
||||||
|
gridAspectRatio: aspectRatio,
|
||||||
|
fit: fit,
|
||||||
|
imageType: imageType,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
bool metadataEditValueEquals(Object? a, Object? b) {
|
bool metadataEditValueEquals(Object? a, Object? b) {
|
||||||
if (identical(a, b)) return true;
|
if (identical(a, b)) return true;
|
||||||
if (a is List && b is List) {
|
if (a is List && b is List) {
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import '../media/media_server_client.dart';
|
|||||||
import '../services/plex_client.dart';
|
import '../services/plex_client.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/language_codes.dart';
|
import '../utils/language_codes.dart';
|
||||||
import '../utils/media_image_helper.dart';
|
|
||||||
import 'metadata_edit_models.dart';
|
import 'metadata_edit_models.dart';
|
||||||
|
|
||||||
class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
||||||
@@ -60,7 +59,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
|
||||||
final kind = draft.sourceItem.kind;
|
final kind = draft.sourceItem.kind;
|
||||||
return [
|
return [
|
||||||
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)),
|
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: metadataBasicFields(kind)),
|
||||||
if (_tagFields(kind).isNotEmpty)
|
if (_tagFields(kind).isNotEmpty)
|
||||||
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
|
||||||
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
|
||||||
@@ -133,11 +132,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
|
|
||||||
return applyArtworkFromUrl(draft, field, option.sourceUrl);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
|
||||||
final element = field.artwork?.key;
|
final element = field.artwork?.key;
|
||||||
@@ -213,29 +207,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MetadataEditField> _basicFields(MediaKind kind) {
|
|
||||||
return [
|
|
||||||
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
|
|
||||||
if (kind != MediaKind.season)
|
|
||||||
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
|
||||||
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
|
|
||||||
if (kind != MediaKind.season)
|
|
||||||
MetadataEditField(
|
|
||||||
id: 'originallyAvailableAt',
|
|
||||||
label: t.metadataEdit.releaseDate,
|
|
||||||
type: MetadataEditFieldType.date,
|
|
||||||
),
|
|
||||||
if (kind != MediaKind.season)
|
|
||||||
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
|
||||||
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.text),
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show)
|
|
||||||
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
|
|
||||||
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
List<MetadataEditField> _tagFields(MediaKind kind) {
|
List<MetadataEditField> _tagFields(MediaKind kind) {
|
||||||
MetadataEditField tag(String id, String label) =>
|
MetadataEditField tag(String id, String label) =>
|
||||||
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
|
||||||
@@ -267,94 +238,14 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
List<MetadataEditField> _artworkFields(MediaKind kind) {
|
List<MetadataEditField> _artworkFields(MediaKind kind) => metadataArtworkFields(
|
||||||
final fields = <MetadataEditField>[
|
kind,
|
||||||
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
|
posterKey: 'posters',
|
||||||
kind == MediaKind.episode
|
backdropKey: 'arts',
|
||||||
? _artworkField(
|
logoKey: 'clearLogos',
|
||||||
'posters',
|
squareKey: 'squareArts',
|
||||||
t.metadataEdit.poster,
|
logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection},
|
||||||
t.metadataEdit.selectPoster,
|
);
|
||||||
80,
|
|
||||||
45,
|
|
||||||
2,
|
|
||||||
16 / 9,
|
|
||||||
imageType: ImageType.thumb,
|
|
||||||
)
|
|
||||||
: _artworkField('posters', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
|
|
||||||
];
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
|
|
||||||
fields.add(
|
|
||||||
_artworkField(
|
|
||||||
'arts',
|
|
||||||
t.metadataEdit.background,
|
|
||||||
t.metadataEdit.selectBackground,
|
|
||||||
80,
|
|
||||||
45,
|
|
||||||
2,
|
|
||||||
16 / 9,
|
|
||||||
imageType: ImageType.art,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.collection) {
|
|
||||||
fields.add(
|
|
||||||
_artworkField(
|
|
||||||
'clearLogos',
|
|
||||||
t.metadataEdit.logo,
|
|
||||||
t.metadataEdit.selectLogo,
|
|
||||||
80,
|
|
||||||
32,
|
|
||||||
2,
|
|
||||||
2.5,
|
|
||||||
fit: MetadataArtworkFit.contain,
|
|
||||||
imageType: ImageType.logo,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
fields.add(
|
|
||||||
_artworkField(
|
|
||||||
'squareArts',
|
|
||||||
t.metadataEdit.squareArt,
|
|
||||||
t.metadataEdit.selectSquareArt,
|
|
||||||
50,
|
|
||||||
50,
|
|
||||||
3,
|
|
||||||
1,
|
|
||||||
imageType: ImageType.avatar,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fields;
|
|
||||||
}
|
|
||||||
|
|
||||||
MetadataEditField _artworkField(
|
|
||||||
String key,
|
|
||||||
String label,
|
|
||||||
String title,
|
|
||||||
double width,
|
|
||||||
double height,
|
|
||||||
int columns,
|
|
||||||
double aspectRatio, {
|
|
||||||
MetadataArtworkFit fit = MetadataArtworkFit.cover,
|
|
||||||
ImageType imageType = ImageType.poster,
|
|
||||||
}) {
|
|
||||||
return MetadataEditField(
|
|
||||||
id: 'artwork:$key',
|
|
||||||
label: label,
|
|
||||||
type: MetadataEditFieldType.artwork,
|
|
||||||
saveMode: MetadataEditSaveMode.immediate,
|
|
||||||
artwork: MetadataArtworkConfig(
|
|
||||||
key: key,
|
|
||||||
selectTitle: title,
|
|
||||||
previewWidth: width,
|
|
||||||
previewHeight: height,
|
|
||||||
gridColumns: columns,
|
|
||||||
gridAspectRatio: aspectRatio,
|
|
||||||
fit: fit,
|
|
||||||
imageType: imageType,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<MetadataEditField> _advancedFields(MediaKind kind) {
|
List<MetadataEditField> _advancedFields(MediaKind kind) {
|
||||||
final fields = <MetadataEditField>[];
|
final fields = <MetadataEditField>[];
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../utils/deletion_notifier.dart';
|
import '../utils/deletion_notifier.dart';
|
||||||
import 'event_aware.dart';
|
import 'event_aware.dart';
|
||||||
|
import 'watch_state_aware.dart';
|
||||||
|
|
||||||
/// Mixin for screens that need to react to deletion events.
|
/// Mixin for screens that need to react to deletion events.
|
||||||
///
|
///
|
||||||
@@ -74,3 +75,21 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Points [DeletionAware]'s filters at the [WatchStateAware] ones.
|
||||||
|
///
|
||||||
|
/// The usual case: a screen shows the same rows for both event families, so a
|
||||||
|
/// deleted show and a watched show affect exactly the same items. Mix this in
|
||||||
|
/// after both aware mixins instead of re-typing the three getters. A screen
|
||||||
|
/// that genuinely needs a different scope overrides the getter it cares about
|
||||||
|
/// (or skips this mixin entirely).
|
||||||
|
mixin DeletionMirrorsWatchState<T extends StatefulWidget> on WatchStateAware<T>, DeletionAware<T> {
|
||||||
|
@override
|
||||||
|
String? get deletionServerId => watchStateServerId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<String>? get deletionIds => watchedIds;
|
||||||
|
}
|
||||||
|
|||||||
@@ -104,43 +104,6 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
|
|||||||
return (page: result, applied: true);
|
return (page: result, applied: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Shared initial-load transaction for paginated consumers.
|
|
||||||
///
|
|
||||||
/// Owns reset, stale-result rejection, mounted checks, and error-state
|
|
||||||
/// application. Callers supply only their view fields, logging, and
|
|
||||||
/// post-success behavior.
|
|
||||||
Future<bool> loadInitialPaginatedItems({
|
|
||||||
required int pageSize,
|
|
||||||
required VoidCallback resetViewState,
|
|
||||||
required void Function(List<T> items) applyLoadedItems,
|
|
||||||
required void Function(Object error, StackTrace stackTrace) applyError,
|
|
||||||
void Function(int loadedCount, int totalCount)? onLoaded,
|
|
||||||
void Function(Object error, StackTrace stackTrace)? onError,
|
|
||||||
}) async {
|
|
||||||
setState(() {
|
|
||||||
resetViewState();
|
|
||||||
resetPaginationState();
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
final initialPage = await loadInitialPageWithStatus(pageSize);
|
|
||||||
if (!initialPage.applied || !mounted) return false;
|
|
||||||
|
|
||||||
setState(() {
|
|
||||||
applyLoadedItems(loadedItems.values.toList());
|
|
||||||
});
|
|
||||||
onLoaded?.call(loadedItems.length, totalSize);
|
|
||||||
return true;
|
|
||||||
} catch (error, stackTrace) {
|
|
||||||
onError?.call(error, stackTrace);
|
|
||||||
if (!mounted) return false;
|
|
||||||
setState(() {
|
|
||||||
applyError(error, stackTrace);
|
|
||||||
});
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
|
/// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
|
||||||
/// with [buffer] extra indices on each side. Serialized — only one
|
/// with [buffer] extra indices on each side. Serialized — only one
|
||||||
/// range-fetch runs at a time — and re-checks after each success so a
|
/// range-fetch runs at a time — and re-checks after each success so a
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
|
||||||
|
import '../media/media_item.dart';
|
||||||
|
import 'item_updatable.dart';
|
||||||
|
import 'paginated_item_loader.dart';
|
||||||
|
|
||||||
|
/// Standard view-state wiring for screens whose body is a single paginated
|
||||||
|
/// list.
|
||||||
|
///
|
||||||
|
/// [PaginatedItemLoader] owns the sparse `loadedItems` map; the hosts
|
||||||
|
/// (`BaseMediaListDetailScreen`, `BaseLibraryTabState`) additionally expose
|
||||||
|
/// `items` / `isLoading` / `errorMessage` to drive the loading, empty and
|
||||||
|
/// error chrome. This mixin owns the transitions between the two, so a
|
||||||
|
/// screen's `loadItems` supplies only the page size, the error text, and an
|
||||||
|
/// optional post-load hook.
|
||||||
|
mixin StandardPaginatedView<T, W extends StatefulWidget> on PaginatedItemLoader<T, W> {
|
||||||
|
set items(List<T> value);
|
||||||
|
set isLoading(bool value);
|
||||||
|
set errorMessage(String? value);
|
||||||
|
|
||||||
|
/// Initial-load transaction: clears the view state, fetches the first page,
|
||||||
|
/// then publishes either the loaded items or [errorMessageFor]'s text.
|
||||||
|
///
|
||||||
|
/// Stale results — a newer load started, or the screen was disposed — are
|
||||||
|
/// dropped without touching state. [errorMessageFor] runs even when
|
||||||
|
/// unmounted, so screens can log from it; [onLoaded] runs only after a
|
||||||
|
/// successful publish.
|
||||||
|
Future<void> loadStandardPaginatedItems({
|
||||||
|
required int pageSize,
|
||||||
|
required String Function(Object error, StackTrace stackTrace) errorMessageFor,
|
||||||
|
void Function(int loadedCount, int totalCount)? onLoaded,
|
||||||
|
}) async {
|
||||||
|
setState(() {
|
||||||
|
isLoading = true;
|
||||||
|
errorMessage = null;
|
||||||
|
items = [];
|
||||||
|
resetPaginationState();
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
final initialPage = await loadInitialPageWithStatus(pageSize);
|
||||||
|
if (!initialPage.applied || !mounted) return;
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
items = loadedItems.values.toList();
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
onLoaded?.call(loadedItems.length, totalSize);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
final message = errorMessageFor(error, stackTrace);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
errorMessage = message;
|
||||||
|
isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [ItemUpdatable.updateItemInLists] for screens whose visible list is the
|
||||||
|
/// sparse `loadedItems` map rather than a flat `items` list — searching the
|
||||||
|
/// map is what keeps an item refreshed at a scrolled-in position, past the
|
||||||
|
/// first page.
|
||||||
|
mixin PaginatedItemUpdatable<W extends StatefulWidget> on PaginatedItemLoader<MediaItem, W>, ItemUpdatable<W> {
|
||||||
|
@override
|
||||||
|
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||||
|
for (final entry in loadedItems.entries) {
|
||||||
|
if (entry.value.globalKey == sourceGlobalKey) {
|
||||||
|
loadedItems[entry.key] = updatedItem;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
|
|||||||
|
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../utils/json_utils.dart';
|
import '../utils/json_utils.dart';
|
||||||
import 'mixins/multi_server_fields.dart';
|
|
||||||
|
|
||||||
part 'livetv_channel.g.dart';
|
part 'livetv_channel.g.dart';
|
||||||
|
|
||||||
@@ -49,7 +48,7 @@ List<LiveTvChannel> filterLiveTvChannelsForFavorites({
|
|||||||
}
|
}
|
||||||
|
|
||||||
@JsonSerializable(createToJson: false)
|
@JsonSerializable(createToJson: false)
|
||||||
class LiveTvChannel with MultiServerFields {
|
class LiveTvChannel {
|
||||||
@JsonKey(readValue: _readChannelKey)
|
@JsonKey(readValue: _readChannelKey)
|
||||||
final String key;
|
final String key;
|
||||||
@JsonKey(readValue: _readChannelIdentifier)
|
@JsonKey(readValue: _readChannelIdentifier)
|
||||||
@@ -68,10 +67,8 @@ class LiveTvChannel with MultiServerFields {
|
|||||||
@JsonKey(fromJson: flexibleBool)
|
@JsonKey(fromJson: flexibleBool)
|
||||||
final bool? drm;
|
final bool? drm;
|
||||||
|
|
||||||
@override
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final String? serverId;
|
final String? serverId;
|
||||||
@override
|
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
final String? serverName;
|
final String? serverName;
|
||||||
@JsonKey(includeFromJson: false, includeToJson: false)
|
@JsonKey(includeFromJson: false, includeToJson: false)
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -9,22 +9,35 @@ enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom }
|
|||||||
/// ArtCNN real-time model sizes.
|
/// ArtCNN real-time model sizes.
|
||||||
enum ArtCNNModel {
|
enum ArtCNNModel {
|
||||||
/// Lightweight real-time model
|
/// Lightweight real-time model
|
||||||
c4f16,
|
c4f16('C4F16'),
|
||||||
|
|
||||||
/// Higher-quality real-time model
|
/// Higher-quality real-time model
|
||||||
c4f32,
|
c4f32('C4F32');
|
||||||
|
|
||||||
|
const ArtCNNModel(this.label);
|
||||||
|
|
||||||
|
/// Display label for the model.
|
||||||
|
final String label;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ArtCNN luma doubler variants.
|
/// ArtCNN luma doubler variants.
|
||||||
enum ArtCNNVariant {
|
enum ArtCNNVariant {
|
||||||
/// Neutral luma doubler
|
/// Neutral luma doubler
|
||||||
neutral,
|
neutral('Neutral', 'neutral'),
|
||||||
|
|
||||||
/// Denoise and soften
|
/// Denoise and soften
|
||||||
denoise,
|
denoise('Denoise', 'dn'),
|
||||||
|
|
||||||
/// Denoise and sharpen
|
/// Denoise and sharpen
|
||||||
denoiseSharpen,
|
denoiseSharpen('Denoise + Sharpen', 'ds');
|
||||||
|
|
||||||
|
const ArtCNNVariant(this.label, this.slug);
|
||||||
|
|
||||||
|
/// Display label for the variant.
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
/// Stable slug used in built-in preset ids and shader asset keys.
|
||||||
|
final String slug;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Quality tiers for Anime4K presets
|
/// Quality tiers for Anime4K presets
|
||||||
@@ -39,22 +52,27 @@ enum Anime4KQuality {
|
|||||||
/// Anime4K modes that define shader combinations
|
/// Anime4K modes that define shader combinations
|
||||||
enum Anime4KMode {
|
enum Anime4KMode {
|
||||||
/// Mode A: Clamp + Restore
|
/// Mode A: Clamp + Restore
|
||||||
modeA,
|
modeA('A'),
|
||||||
|
|
||||||
/// Mode B: Clamp + Restore + Upscale + Downscale
|
/// Mode B: Clamp + Restore + Upscale + Downscale
|
||||||
modeB,
|
modeB('B'),
|
||||||
|
|
||||||
/// Mode C: Clamp + Upscale + Downscale
|
/// Mode C: Clamp + Upscale + Downscale
|
||||||
modeC,
|
modeC('C'),
|
||||||
|
|
||||||
/// Mode A+A: Clamp + Restore + Restore
|
/// Mode A+A: Clamp + Restore + Restore
|
||||||
modeAA,
|
modeAA('A+A'),
|
||||||
|
|
||||||
/// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale
|
/// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale
|
||||||
modeBB,
|
modeBB('B+B'),
|
||||||
|
|
||||||
/// Mode C+A: Clamp + Upscale + Restore + Downscale
|
/// Mode C+A: Clamp + Upscale + Restore + Downscale
|
||||||
modeCA,
|
modeCA('C+A');
|
||||||
|
|
||||||
|
const Anime4KMode(this.label);
|
||||||
|
|
||||||
|
/// Display label for the mode.
|
||||||
|
final String label;
|
||||||
}
|
}
|
||||||
|
|
||||||
@freezed
|
@freezed
|
||||||
@@ -120,93 +138,28 @@ class ShaderPreset {
|
|||||||
);
|
);
|
||||||
|
|
||||||
/// Create an ArtCNN preset with the specified model and variant
|
/// Create an ArtCNN preset with the specified model and variant
|
||||||
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) {
|
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) => ShaderPreset(
|
||||||
final modelName = _getArtCNNModelName(model);
|
id: 'artcnn_${model.name}_${variant.slug}',
|
||||||
final variantName = _getArtCNNVariantName(variant);
|
name: variant == ArtCNNVariant.neutral ? 'ArtCNN ${model.label}' : 'ArtCNN ${model.label} ${variant.label}',
|
||||||
final variantId = _getArtCNNVariantId(variant);
|
type: ShaderPresetType.artcnn,
|
||||||
|
artcnnConfig: ArtCNNConfig(model: model, variant: variant),
|
||||||
return ShaderPreset(
|
);
|
||||||
id: 'artcnn_${model.name}_$variantId',
|
|
||||||
name: variant == ArtCNNVariant.neutral ? 'ArtCNN $modelName' : 'ArtCNN $modelName $variantName',
|
|
||||||
type: ShaderPresetType.artcnn,
|
|
||||||
artcnnConfig: ArtCNNConfig(model: model, variant: variant),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create an Anime4K preset with the specified quality and mode
|
/// Create an Anime4K preset with the specified quality and mode
|
||||||
static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) {
|
static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) {
|
||||||
final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ';
|
final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ';
|
||||||
final modeName = _getModeName(mode);
|
|
||||||
|
|
||||||
return ShaderPreset(
|
return ShaderPreset(
|
||||||
id: 'anime4k_${quality.name}_${mode.name}',
|
id: 'anime4k_${quality.name}_${mode.name}',
|
||||||
name: 'Anime4K $qualityName $modeName',
|
name: 'Anime4K $qualityName ${mode.label}',
|
||||||
type: ShaderPresetType.anime4k,
|
type: ShaderPresetType.anime4k,
|
||||||
anime4kConfig: Anime4KConfig(quality: quality, mode: mode),
|
anime4kConfig: Anime4KConfig(quality: quality, mode: mode),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _getModeName(Anime4KMode mode) {
|
String get modeDisplayName => anime4kConfig?.mode.label ?? '';
|
||||||
switch (mode) {
|
|
||||||
case Anime4KMode.modeA:
|
|
||||||
return 'A';
|
|
||||||
case Anime4KMode.modeB:
|
|
||||||
return 'B';
|
|
||||||
case Anime4KMode.modeC:
|
|
||||||
return 'C';
|
|
||||||
case Anime4KMode.modeAA:
|
|
||||||
return 'A+A';
|
|
||||||
case Anime4KMode.modeBB:
|
|
||||||
return 'B+B';
|
|
||||||
case Anime4KMode.modeCA:
|
|
||||||
return 'C+A';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _getArtCNNModelName(ArtCNNModel model) {
|
String get artcnnModelDisplayName => artcnnConfig?.model.label ?? '';
|
||||||
switch (model) {
|
|
||||||
case ArtCNNModel.c4f16:
|
|
||||||
return 'C4F16';
|
|
||||||
case ArtCNNModel.c4f32:
|
|
||||||
return 'C4F32';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _getArtCNNVariantName(ArtCNNVariant variant) {
|
|
||||||
switch (variant) {
|
|
||||||
case ArtCNNVariant.neutral:
|
|
||||||
return 'Neutral';
|
|
||||||
case ArtCNNVariant.denoise:
|
|
||||||
return 'Denoise';
|
|
||||||
case ArtCNNVariant.denoiseSharpen:
|
|
||||||
return 'Denoise + Sharpen';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _getArtCNNVariantId(ArtCNNVariant variant) {
|
|
||||||
switch (variant) {
|
|
||||||
case ArtCNNVariant.neutral:
|
|
||||||
return 'neutral';
|
|
||||||
case ArtCNNVariant.denoise:
|
|
||||||
return 'dn';
|
|
||||||
case ArtCNNVariant.denoiseSharpen:
|
|
||||||
return 'ds';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
String get modeDisplayName {
|
|
||||||
if (anime4kConfig != null) {
|
|
||||||
return _getModeName(anime4kConfig!.mode);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
String get artcnnModelDisplayName {
|
|
||||||
if (artcnnConfig != null) {
|
|
||||||
return _getArtCNNModelName(artcnnConfig!.model);
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
static final List<ShaderPreset> _builtInPresets = List.unmodifiable([
|
static final List<ShaderPreset> _builtInPresets = List.unmodifiable([
|
||||||
none,
|
none,
|
||||||
|
|||||||
@@ -41,11 +41,12 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Build a `POST /sync/history` body that adds this item to history.
|
/// Build a `POST /sync/history[/remove]` body for this item. Both endpoints
|
||||||
|
/// take the same shape; only the removal path ignores [watchedAt].
|
||||||
///
|
///
|
||||||
/// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
|
/// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
|
||||||
/// to a specific point in time; defaults to "now" on Trakt's side.
|
/// to a specific point in time; defaults to "now" on Trakt's side.
|
||||||
Map<String, dynamic> toHistoryAddBody({String? watchedAt}) => switch (this) {
|
Map<String, dynamic> toHistoryBody({String? watchedAt}) => switch (this) {
|
||||||
TraktScrobbleMovieRequest(:final ids) => {
|
TraktScrobbleMovieRequest(:final ids) => {
|
||||||
'movies': [
|
'movies': [
|
||||||
{'watched_at': ?watchedAt, 'ids': ids.toJson()},
|
{'watched_at': ?watchedAt, 'ids': ids.toJson()},
|
||||||
@@ -67,28 +68,4 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Build a `POST /sync/history/remove` body that removes this item from history.
|
|
||||||
Map<String, dynamic> toHistoryRemoveBody() => switch (this) {
|
|
||||||
TraktScrobbleMovieRequest(:final ids) => {
|
|
||||||
'movies': [
|
|
||||||
{'ids': ids.toJson()},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
TraktScrobbleEpisodeRequest(:final showIds, :final season, :final number) => {
|
|
||||||
'shows': [
|
|
||||||
{
|
|
||||||
'ids': showIds.toJson(),
|
|
||||||
'seasons': [
|
|
||||||
{
|
|
||||||
'number': season,
|
|
||||||
'episodes': [
|
|
||||||
{'number': number},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ class PlexHomeService {
|
|||||||
this._storage,
|
this._storage,
|
||||||
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
|
||||||
this._refreshInterval = const Duration(hours: 1),
|
this._refreshInterval = const Duration(hours: 1),
|
||||||
}) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher;
|
}) : _fetchHomeUsers = plexHomeUserFetcher ?? fetchPlexHomeUsers;
|
||||||
|
|
||||||
final ConnectionRegistry _connections;
|
final ConnectionRegistry _connections;
|
||||||
final ProfileConnectionRegistry _profileConnections;
|
final ProfileConnectionRegistry _profileConnections;
|
||||||
@@ -498,13 +498,3 @@ class PlexHomeService {
|
|||||||
_started = false;
|
_started = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<List<PlexHomeUser>> _defaultHomeUserFetcher(String accountToken) async {
|
|
||||||
final auth = await PlexAuthService.create();
|
|
||||||
try {
|
|
||||||
final home = await auth.getHomeUsers(accountToken);
|
|
||||||
return home.users;
|
|
||||||
} finally {
|
|
||||||
auth.dispose();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -234,6 +234,8 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
|
|||||||
final connections = context.read<ConnectionRegistry>();
|
final connections = context.read<ConnectionRegistry>();
|
||||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
||||||
final binder = context.read<ActiveProfileBinder>();
|
final binder = context.read<ActiveProfileBinder>();
|
||||||
|
// Built before the await: capturing the prompt needs a live context.
|
||||||
|
final promptForPin = dialogPinPrompt(context, profile.displayName);
|
||||||
final all = await connections.list();
|
final all = await connections.list();
|
||||||
PlexAccountConnection? account;
|
PlexAccountConnection? account;
|
||||||
for (final c in all) {
|
for (final c in all) {
|
||||||
@@ -248,10 +250,7 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
|
|||||||
account: account,
|
account: account,
|
||||||
homeUserUuid: homeUuid,
|
homeUserUuid: homeUuid,
|
||||||
requiresPin: true,
|
requiresPin: true,
|
||||||
promptForPin: ({String? errorMessage}) async {
|
promptForPin: promptForPin,
|
||||||
if (!context.mounted) return null;
|
|
||||||
return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
|
|
||||||
},
|
|
||||||
persistTo: pcRegistry,
|
persistTo: pcRegistry,
|
||||||
persistProfileId: profile.id,
|
persistProfileId: profile.id,
|
||||||
logLabel: profile.displayName,
|
logLabel: profile.displayName,
|
||||||
|
|||||||
@@ -9,65 +9,6 @@ import 'profile_connection_registry.dart';
|
|||||||
import 'profile_merge.dart';
|
import 'profile_merge.dart';
|
||||||
import 'profile_registry.dart';
|
import 'profile_registry.dart';
|
||||||
|
|
||||||
Future<void> removeProfileConnectionAndCleanup({
|
|
||||||
required String profileId,
|
|
||||||
required Connection connection,
|
|
||||||
required ProfileConnectionRegistry profileConnections,
|
|
||||||
required ConnectionRegistry connections,
|
|
||||||
required StorageService storage,
|
|
||||||
MultiServerManager? serverManager,
|
|
||||||
}) async {
|
|
||||||
final removedServerIds = _serverIdsForConnection(connection);
|
|
||||||
await profileConnections.remove(profileId, connection.id);
|
|
||||||
await _clearProfileServerPrefsNoLongerReferenced(
|
|
||||||
profileId: profileId,
|
|
||||||
removedServerIds: removedServerIds,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
storage: storage,
|
|
||||||
clearEverywhereWhenUnreferenced: connection is JellyfinConnection,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (connection is JellyfinConnection) {
|
|
||||||
await _removeUnreferencedJellyfinConnection(
|
|
||||||
connection,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
storage: storage,
|
|
||||||
serverManager: serverManager,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> removeAllProfileConnectionsAndCleanup({
|
|
||||||
required String profileId,
|
|
||||||
required ProfileConnectionRegistry profileConnections,
|
|
||||||
required ConnectionRegistry connections,
|
|
||||||
required StorageService storage,
|
|
||||||
MultiServerManager? serverManager,
|
|
||||||
}) async {
|
|
||||||
final rows = await profileConnections.listForProfile(profileId);
|
|
||||||
if (rows.isEmpty) return;
|
|
||||||
|
|
||||||
final all = await connections.list();
|
|
||||||
final byId = {for (final connection in all) connection.id: connection};
|
|
||||||
for (final row in rows) {
|
|
||||||
final connection = byId[row.connectionId];
|
|
||||||
if (connection == null) {
|
|
||||||
await profileConnections.remove(profileId, row.connectionId);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await removeProfileConnectionAndCleanup(
|
|
||||||
profileId: profileId,
|
|
||||||
connection: connection,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
storage: storage,
|
|
||||||
serverManager: serverManager,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Profile ids affected by a Plex account removal. Planning is read-only so
|
/// Profile ids affected by a Plex account removal. Planning is read-only so
|
||||||
/// callers can finish failure-prone cleanup before committing join/account
|
/// callers can finish failure-prone cleanup before committing join/account
|
||||||
/// deletion.
|
/// deletion.
|
||||||
@@ -96,228 +37,201 @@ Future<PlexAccountRemoval> planPlexAccountConnectionRemoval({
|
|||||||
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sign out of a Plex account: remove the account [Connection], every join
|
|
||||||
/// row referencing it, and everything owned by its virtual Plex Home
|
|
||||||
/// profiles — including borrowed Jellyfin connections left unreferenced,
|
|
||||||
/// which previously survived as orphans and wedged the session (#1423).
|
|
||||||
///
|
|
||||||
/// Pass a read-only [plannedRemoval] from
|
|
||||||
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup
|
|
||||||
/// must finish before this destructive commit. Omitting it preserves the
|
|
||||||
/// atomic add/cancel-account cleanup path.
|
|
||||||
///
|
|
||||||
/// All cleanup is explicit and completes before this returns; correctness
|
|
||||||
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
|
|
||||||
/// runs later and no-ops.
|
|
||||||
Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
|
|
||||||
required PlexAccountConnection account,
|
|
||||||
required ProfileConnectionRegistry profileConnections,
|
|
||||||
required ConnectionRegistry connections,
|
|
||||||
required StorageService storage,
|
|
||||||
MultiServerManager? serverManager,
|
|
||||||
PlexAccountRemoval? plannedRemoval,
|
|
||||||
}) async {
|
|
||||||
final removal =
|
|
||||||
plannedRemoval ??
|
|
||||||
await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections);
|
|
||||||
final removedVirtualProfileIds = removal.removedVirtualProfileIds;
|
|
||||||
final borrowerProfileIds = removal.borrowerProfileIds;
|
|
||||||
final rows = await profileConnections.listAll();
|
|
||||||
// Remove direct join rows first so per-profile pref cleanup observes each
|
|
||||||
// row going away; the FK cascade from the connection delete is then a no-op.
|
|
||||||
for (final row in rows.where((r) => r.connectionId == account.id)) {
|
|
||||||
await removeProfileConnectionAndCleanup(
|
|
||||||
profileId: row.profileId,
|
|
||||||
connection: account,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
storage: storage,
|
|
||||||
serverManager: serverManager,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await connections.remove(account.id);
|
|
||||||
await storage.clearPlexHomeUsersCache(account.id);
|
|
||||||
|
|
||||||
// The account's virtual profiles die with the connection; their borrowed
|
|
||||||
// connections and per-profile prefs must go too.
|
|
||||||
for (final profileId in removedVirtualProfileIds) {
|
|
||||||
await removeAllProfileConnectionsAndCleanup(
|
|
||||||
profileId: profileId,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
storage: storage,
|
|
||||||
serverManager: serverManager,
|
|
||||||
);
|
|
||||||
await storage.clearProfileLastUsed(profileId);
|
|
||||||
await storage.clearUserScopedPreferencesForProfile(profileId);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Where the session should land after a profile or connection removal.
|
/// Where the session should land after a profile or connection removal.
|
||||||
enum PostRemovalRoute { signedOut, staySignedIn }
|
enum PostRemovalRoute { signedOut, staySignedIn }
|
||||||
|
|
||||||
/// In-session mirror of the boot guard (`main.dart`: "stored connections
|
/// Removal of profile↔connection join rows and everything they leave
|
||||||
/// exist but no profiles resolved — returning to auth"): prune orphaned
|
/// unreferenced, bound to one set of registries. Every flow resolves the same
|
||||||
/// Jellyfin connections, then decide whether any selectable profile remains.
|
/// instances from the provider tree, so callers construct this once and call
|
||||||
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
|
/// through it.
|
||||||
/// accounts are harmless because the connection map is re-read here.
|
class ProfileConnectionCleanup {
|
||||||
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
|
ProfileConnectionCleanup({
|
||||||
required ProfileRegistry profileRegistry,
|
required this.profileConnections,
|
||||||
required ProfileConnectionRegistry profileConnections,
|
required this.connections,
|
||||||
required ConnectionRegistry connections,
|
required this.storage,
|
||||||
required Map<String, List<PlexHomeUser>> plexHomeUsers,
|
this.serverManager,
|
||||||
required StorageService storage,
|
});
|
||||||
MultiServerManager? serverManager,
|
|
||||||
}) async {
|
|
||||||
await pruneUnreferencedJellyfinConnections(
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
storage: storage,
|
|
||||||
serverManager: serverManager,
|
|
||||||
);
|
|
||||||
final conns = await connections.list();
|
|
||||||
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
|
||||||
|
|
||||||
final merged = mergeLocalWithPlexHome(
|
final ProfileConnectionRegistry profileConnections;
|
||||||
locals: await profileRegistry.list(),
|
final ConnectionRegistry connections;
|
||||||
plexHomeByConnectionId: plexHomeUsers,
|
final StorageService storage;
|
||||||
connectionsById: {for (final c in conns) c.id: c},
|
final MultiServerManager? serverManager;
|
||||||
storage: storage,
|
|
||||||
);
|
|
||||||
if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
|
||||||
return (route: PostRemovalRoute.staySignedIn, profiles: merged);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<int> pruneUnreferencedJellyfinConnections({
|
Future<void> removeProfileConnection({required String profileId, required Connection connection}) async {
|
||||||
required ProfileConnectionRegistry profileConnections,
|
final removedServerIds = _serverIdsForConnection(connection);
|
||||||
required ConnectionRegistry connections,
|
await profileConnections.remove(profileId, connection.id);
|
||||||
required StorageService storage,
|
await _clearProfileServerPrefsNoLongerReferenced(
|
||||||
MultiServerManager? serverManager,
|
profileId: profileId,
|
||||||
}) async {
|
removedServerIds: removedServerIds,
|
||||||
final all = await connections.list();
|
clearEverywhereWhenUnreferenced: connection is JellyfinConnection,
|
||||||
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
|
);
|
||||||
var removed = 0;
|
|
||||||
|
|
||||||
for (final connection in all.whereType<JellyfinConnection>()) {
|
if (connection is JellyfinConnection) {
|
||||||
if (referencedConnectionIds.contains(connection.id)) continue;
|
await _removeUnreferencedJellyfinConnection(connection);
|
||||||
await _removeJellyfinConnection(
|
}
|
||||||
connection,
|
}
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
Future<void> removeAllProfileConnections(String profileId) async {
|
||||||
|
final rows = await profileConnections.listForProfile(profileId);
|
||||||
|
if (rows.isEmpty) return;
|
||||||
|
|
||||||
|
final all = await connections.list();
|
||||||
|
final byId = {for (final connection in all) connection.id: connection};
|
||||||
|
for (final row in rows) {
|
||||||
|
final connection = byId[row.connectionId];
|
||||||
|
if (connection == null) {
|
||||||
|
await profileConnections.remove(profileId, row.connectionId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await removeProfileConnection(profileId: profileId, connection: connection);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sign out of a Plex account: remove the account [Connection], every join
|
||||||
|
/// row referencing it, and everything owned by its virtual Plex Home
|
||||||
|
/// profiles — including borrowed Jellyfin connections left unreferenced,
|
||||||
|
/// which previously survived as orphans and wedged the session (#1423).
|
||||||
|
///
|
||||||
|
/// Pass a read-only [plannedRemoval] from
|
||||||
|
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup
|
||||||
|
/// must finish before this destructive commit. Omitting it preserves the
|
||||||
|
/// atomic add/cancel-account cleanup path.
|
||||||
|
///
|
||||||
|
/// All cleanup is explicit and completes before this returns; correctness
|
||||||
|
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
|
||||||
|
/// runs later and no-ops.
|
||||||
|
Future<PlexAccountRemoval> removePlexAccountConnection(
|
||||||
|
PlexAccountConnection account, {
|
||||||
|
PlexAccountRemoval? plannedRemoval,
|
||||||
|
}) async {
|
||||||
|
final removal =
|
||||||
|
plannedRemoval ??
|
||||||
|
await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections);
|
||||||
|
final removedVirtualProfileIds = removal.removedVirtualProfileIds;
|
||||||
|
final borrowerProfileIds = removal.borrowerProfileIds;
|
||||||
|
final rows = await profileConnections.listAll();
|
||||||
|
// Remove direct join rows first so per-profile pref cleanup observes each
|
||||||
|
// row going away; the FK cascade from the connection delete is then a no-op.
|
||||||
|
for (final row in rows.where((r) => r.connectionId == account.id)) {
|
||||||
|
await removeProfileConnection(profileId: row.profileId, connection: account);
|
||||||
|
}
|
||||||
|
await connections.remove(account.id);
|
||||||
|
await storage.clearPlexHomeUsersCache(account.id);
|
||||||
|
|
||||||
|
// The account's virtual profiles die with the connection; their borrowed
|
||||||
|
// connections and per-profile prefs must go too.
|
||||||
|
for (final profileId in removedVirtualProfileIds) {
|
||||||
|
await removeAllProfileConnections(profileId);
|
||||||
|
await storage.clearProfileLastUsed(profileId);
|
||||||
|
await storage.clearUserScopedPreferencesForProfile(profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// In-session mirror of the boot guard (`main.dart`: "stored connections
|
||||||
|
/// exist but no profiles resolved — returning to auth"): prune orphaned
|
||||||
|
/// Jellyfin connections, then decide whether any selectable profile remains.
|
||||||
|
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
|
||||||
|
/// accounts are harmless because the connection map is re-read here.
|
||||||
|
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
|
||||||
|
required ProfileRegistry profileRegistry,
|
||||||
|
required Map<String, List<PlexHomeUser>> plexHomeUsers,
|
||||||
|
}) async {
|
||||||
|
await pruneUnreferencedJellyfinConnections();
|
||||||
|
final conns = await connections.list();
|
||||||
|
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||||
|
|
||||||
|
final merged = mergeLocalWithPlexHome(
|
||||||
|
locals: await profileRegistry.list(),
|
||||||
|
plexHomeByConnectionId: plexHomeUsers,
|
||||||
|
connectionsById: {for (final c in conns) c.id: c},
|
||||||
storage: storage,
|
storage: storage,
|
||||||
serverManager: serverManager,
|
|
||||||
);
|
);
|
||||||
removed++;
|
if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
|
||||||
|
return (route: PostRemovalRoute.staySignedIn, profiles: merged);
|
||||||
}
|
}
|
||||||
|
|
||||||
return removed;
|
Future<int> pruneUnreferencedJellyfinConnections() async {
|
||||||
}
|
final all = await connections.list();
|
||||||
|
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
|
||||||
|
var removed = 0;
|
||||||
|
|
||||||
Future<void> _removeUnreferencedJellyfinConnection(
|
for (final connection in all.whereType<JellyfinConnection>()) {
|
||||||
JellyfinConnection connection, {
|
if (referencedConnectionIds.contains(connection.id)) continue;
|
||||||
required ProfileConnectionRegistry profileConnections,
|
await _removeJellyfinConnection(connection);
|
||||||
required ConnectionRegistry connections,
|
removed++;
|
||||||
required StorageService storage,
|
}
|
||||||
MultiServerManager? serverManager,
|
|
||||||
}) async {
|
|
||||||
if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return;
|
|
||||||
await _removeJellyfinConnection(
|
|
||||||
connection,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
storage: storage,
|
|
||||||
serverManager: serverManager,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _removeJellyfinConnection(
|
return removed;
|
||||||
JellyfinConnection connection, {
|
|
||||||
required ProfileConnectionRegistry profileConnections,
|
|
||||||
required ConnectionRegistry connections,
|
|
||||||
required StorageService storage,
|
|
||||||
MultiServerManager? serverManager,
|
|
||||||
}) async {
|
|
||||||
await connections.remove(connection.id);
|
|
||||||
serverManager?.removeJellyfinConnection(connection);
|
|
||||||
final serverId = ServerId.tryParse(connection.serverMachineId);
|
|
||||||
if (serverId != null &&
|
|
||||||
!await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) {
|
|
||||||
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _clearProfileServerPrefsNoLongerReferenced({
|
Future<void> _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async {
|
||||||
required String profileId,
|
if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return;
|
||||||
required Set<ServerId> removedServerIds,
|
await _removeJellyfinConnection(connection);
|
||||||
required ProfileConnectionRegistry profileConnections,
|
}
|
||||||
required ConnectionRegistry connections,
|
|
||||||
required StorageService storage,
|
|
||||||
required bool clearEverywhereWhenUnreferenced,
|
|
||||||
}) async {
|
|
||||||
if (removedServerIds.isEmpty) return;
|
|
||||||
final remainingProfileServerIds = await _serverIdsForProfile(
|
|
||||||
profileId,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
connections: connections,
|
|
||||||
);
|
|
||||||
final activeProfileId = storage.getActiveProfileId();
|
|
||||||
|
|
||||||
for (final serverId in removedServerIds) {
|
Future<void> _removeJellyfinConnection(JellyfinConnection connection) async {
|
||||||
if (remainingProfileServerIds.contains(serverId)) continue;
|
await connections.remove(connection.id);
|
||||||
final serverStillReferenced = await _isServerReferenced(
|
serverManager?.removeJellyfinConnection(connection);
|
||||||
serverId,
|
final serverId = ServerId.tryParse(connection.serverMachineId);
|
||||||
profileConnections: profileConnections,
|
if (serverId != null && !await _isServerReferenced(serverId)) {
|
||||||
connections: connections,
|
|
||||||
);
|
|
||||||
if (serverStillReferenced || !clearEverywhereWhenUnreferenced) {
|
|
||||||
await storage.clearLibraryPreferencesForServer(
|
|
||||||
serverId,
|
|
||||||
profileId: profileId,
|
|
||||||
includeLegacy: activeProfileId == profileId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
/// Server ids reachable through this profile's join rows. Narrower than
|
Future<void> _clearProfileServerPrefsNoLongerReferenced({
|
||||||
/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
|
required String profileId,
|
||||||
/// parent is not counted here, so folding the two together would change which
|
required Set<ServerId> removedServerIds,
|
||||||
/// per-profile prefs survive an unlink.
|
required bool clearEverywhereWhenUnreferenced,
|
||||||
Future<Set<ServerId>> _serverIdsForProfile(
|
}) async {
|
||||||
String profileId, {
|
if (removedServerIds.isEmpty) return;
|
||||||
required ProfileConnectionRegistry profileConnections,
|
final remainingProfileServerIds = await _serverIdsForProfile(profileId);
|
||||||
required ConnectionRegistry connections,
|
final activeProfileId = storage.getActiveProfileId();
|
||||||
}) async {
|
|
||||||
final rows = await profileConnections.listForProfile(profileId);
|
|
||||||
if (rows.isEmpty) return const {};
|
|
||||||
|
|
||||||
final all = await connections.list();
|
for (final serverId in removedServerIds) {
|
||||||
final byId = {for (final connection in all) connection.id: connection};
|
if (remainingProfileServerIds.contains(serverId)) continue;
|
||||||
return {
|
final serverStillReferenced = await _isServerReferenced(serverId);
|
||||||
for (final row in rows)
|
if (serverStillReferenced || !clearEverywhereWhenUnreferenced) {
|
||||||
if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection),
|
await storage.clearLibraryPreferencesForServer(
|
||||||
};
|
serverId,
|
||||||
}
|
profileId: profileId,
|
||||||
|
includeLegacy: activeProfileId == profileId,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await storage.clearLibraryPreferencesForServerEverywhere(serverId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _isServerReferenced(
|
/// Server ids reachable through this profile's join rows. Narrower than
|
||||||
ServerId serverId, {
|
/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
|
||||||
required ProfileConnectionRegistry profileConnections,
|
/// parent is not counted here, so folding the two together would change which
|
||||||
required ConnectionRegistry connections,
|
/// per-profile prefs survive an unlink.
|
||||||
}) async {
|
Future<Set<ServerId>> _serverIdsForProfile(String profileId) async {
|
||||||
final rows = await profileConnections.listAll();
|
final rows = await profileConnections.listForProfile(profileId);
|
||||||
if (rows.isEmpty) return false;
|
if (rows.isEmpty) return const {};
|
||||||
|
|
||||||
final all = await connections.list();
|
final all = await connections.list();
|
||||||
final byId = {for (final connection in all) connection.id: connection};
|
final byId = {for (final connection in all) connection.id: connection};
|
||||||
for (final row in rows) {
|
return {
|
||||||
final connection = byId[row.connectionId];
|
for (final row in rows)
|
||||||
if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true;
|
if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _isServerReferenced(ServerId serverId) async {
|
||||||
|
final rows = await profileConnections.listAll();
|
||||||
|
if (rows.isEmpty) return false;
|
||||||
|
|
||||||
|
final all = await connections.list();
|
||||||
|
final byId = {for (final connection in all) connection.id: connection};
|
||||||
|
for (final row in rows) {
|
||||||
|
final connection = byId[row.connectionId];
|
||||||
|
if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// [ServerId]-typed for the preference APIs, which drops ids that fail to
|
// [ServerId]-typed for the preference APIs, which drops ids that fail to
|
||||||
|
|||||||
@@ -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,57 +684,32 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
|
|
||||||
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
|
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
|
||||||
|
|
||||||
final candidate = _peerServiceFactory();
|
String? winner;
|
||||||
_pendingRemotePeer = candidate;
|
final connected = await _runRemoteConnect(
|
||||||
_session = RemoteSession(
|
generation: generation,
|
||||||
role: RemoteSessionRole.remote,
|
seedConnectingSession: true,
|
||||||
status: RemoteSessionStatus.connecting,
|
rethrowOnFailure: true,
|
||||||
createdAt: DateTime.now(),
|
join: (peer) async {
|
||||||
|
winner = await peer.joinSessionRacingWithContexts(
|
||||||
|
_deviceName,
|
||||||
|
_platform,
|
||||||
|
host.addresses,
|
||||||
|
_authContexts,
|
||||||
|
authContextId: authContext.id,
|
||||||
|
expectedHostClientId: host.clientId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onConnected: (peer) {
|
||||||
|
_lastHostAddresses = [winner!];
|
||||||
|
_lastAuthContextId = peer.selectedAuthContextId ?? authContext.id;
|
||||||
|
_lastHostClientId = peer.selectedHostClientId ?? host.clientId;
|
||||||
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||||
|
},
|
||||||
|
failureLog: 'CompanionRemote: Failed to connect to host',
|
||||||
|
onFailure: _failRemoteConnectSession,
|
||||||
);
|
);
|
||||||
_setupPeerServiceListeners(candidate, generation);
|
if (connected) {
|
||||||
safeNotifyListeners();
|
|
||||||
|
|
||||||
try {
|
|
||||||
final winner = await candidate.joinSessionRacingWithContexts(
|
|
||||||
_deviceName,
|
|
||||||
_platform,
|
|
||||||
host.addresses,
|
|
||||||
_authContexts,
|
|
||||||
authContextId: authContext.id,
|
|
||||||
expectedHostClientId: host.clientId,
|
|
||||||
);
|
|
||||||
if (!_ownsPeer(candidate, generation)) {
|
|
||||||
await _disposePeerOnce(candidate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_pendingRemotePeer = null;
|
|
||||||
_peerService = candidate;
|
|
||||||
_lastHostAddresses = [winner];
|
|
||||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id;
|
|
||||||
_lastHostClientId = candidate.selectedHostClientId ?? host.clientId;
|
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
|
||||||
safeNotifyListeners();
|
|
||||||
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
|
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
|
||||||
} catch (error, stackTrace) {
|
|
||||||
if (!_ownsPeer(candidate, generation)) {
|
|
||||||
await _disposePeerOnce(candidate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_pendingRemotePeer = null;
|
|
||||||
_cleanupSubscriptions();
|
|
||||||
await _disposePeerOnce(candidate);
|
|
||||||
appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace);
|
|
||||||
_session = _session?.copyWith(
|
|
||||||
status: RemoteSessionStatus.error,
|
|
||||||
errorMessage: _localizedRemoteError(
|
|
||||||
error,
|
|
||||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
safeNotifyListeners();
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -754,48 +729,84 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
|
|
||||||
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
|
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
|
||||||
|
|
||||||
|
await _runRemoteConnect(
|
||||||
|
generation: generation,
|
||||||
|
seedConnectingSession: true,
|
||||||
|
rethrowOnFailure: true,
|
||||||
|
join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts),
|
||||||
|
onConnected: (peer) {
|
||||||
|
_lastAuthContextId = peer.selectedAuthContextId;
|
||||||
|
_lastHostClientId = peer.selectedHostClientId ?? '';
|
||||||
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
||||||
|
},
|
||||||
|
failureLog: 'CompanionRemote: Failed to connect to manual host',
|
||||||
|
onFailure: _failRemoteConnectSession,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _failRemoteConnectSession(Object error) {
|
||||||
|
_session = _session?.copyWith(
|
||||||
|
status: RemoteSessionStatus.error,
|
||||||
|
errorMessage: _localizedRemoteError(
|
||||||
|
error,
|
||||||
|
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
safeNotifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the candidate-peer connect lifecycle shared by the discovered/manual
|
||||||
|
/// connect paths and by reconnect attempts: create a candidate, wire its
|
||||||
|
/// listeners, then promote it to [_peerService] or dispose it. The generation
|
||||||
|
/// guards live here so a candidate that lost ownership while joining is
|
||||||
|
/// disposed rather than promoted, in exactly one place. Returns true only
|
||||||
|
/// when the candidate was promoted.
|
||||||
|
Future<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();
|
final candidate = _peerServiceFactory();
|
||||||
_pendingRemotePeer = candidate;
|
_pendingRemotePeer = candidate;
|
||||||
_session = RemoteSession(
|
if (seedConnectingSession) {
|
||||||
role: RemoteSessionRole.remote,
|
_session = RemoteSession(
|
||||||
status: RemoteSessionStatus.connecting,
|
role: RemoteSessionRole.remote,
|
||||||
createdAt: DateTime.now(),
|
status: RemoteSessionStatus.connecting,
|
||||||
);
|
createdAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
}
|
||||||
_setupPeerServiceListeners(candidate, generation);
|
_setupPeerServiceListeners(candidate, generation);
|
||||||
safeNotifyListeners();
|
if (seedConnectingSession) safeNotifyListeners();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
|
await join(candidate);
|
||||||
if (!_ownsPeer(candidate, generation)) {
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
await _disposePeerOnce(candidate);
|
await _disposePeerOnce(candidate);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_pendingRemotePeer = null;
|
_pendingRemotePeer = null;
|
||||||
_peerService = candidate;
|
_peerService = candidate;
|
||||||
_lastAuthContextId = candidate.selectedAuthContextId;
|
onConnected(candidate);
|
||||||
_lastHostClientId = candidate.selectedHostClientId ?? '';
|
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected);
|
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
|
return true;
|
||||||
} catch (error, stackTrace) {
|
} catch (error, stackTrace) {
|
||||||
if (!_ownsPeer(candidate, generation)) {
|
if (!_ownsPeer(candidate, generation)) {
|
||||||
await _disposePeerOnce(candidate);
|
await _disposePeerOnce(candidate);
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_pendingRemotePeer = null;
|
_pendingRemotePeer = null;
|
||||||
_cleanupSubscriptions();
|
_cleanupSubscriptions();
|
||||||
await _disposePeerOnce(candidate);
|
await _disposePeerOnce(candidate);
|
||||||
appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace);
|
appLogger.e(failureLog, error: error, stackTrace: stackTrace);
|
||||||
_session = _session?.copyWith(
|
onFailure(error);
|
||||||
status: RemoteSessionStatus.error,
|
if (rethrowOnFailure) rethrow;
|
||||||
errorMessage: _localizedRemoteError(
|
return false;
|
||||||
error,
|
|
||||||
(details) => t.companionRemote.pairing.failedToConnect(error: details),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
safeNotifyListeners();
|
|
||||||
rethrow;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -981,47 +992,34 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
|
|||||||
}
|
}
|
||||||
if (generation != _remoteGeneration || isDisposed) return;
|
if (generation != _remoteGeneration || isDisposed) return;
|
||||||
|
|
||||||
final candidate = _peerServiceFactory();
|
|
||||||
_pendingRemotePeer = candidate;
|
|
||||||
_setupPeerServiceListeners(candidate, generation);
|
|
||||||
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
final authContextId = _authContextForId(_lastAuthContextId)?.id;
|
||||||
final expectedHostClientId = _lastHostClientId ?? '';
|
final expectedHostClientId = _lastHostClientId ?? '';
|
||||||
|
|
||||||
try {
|
final reconnected = await _runRemoteConnect(
|
||||||
await candidate.joinSessionWithContexts(
|
generation: generation,
|
||||||
|
join: (peer) => peer.joinSessionWithContexts(
|
||||||
_deviceName,
|
_deviceName,
|
||||||
_platform,
|
_platform,
|
||||||
hostAddresses.first,
|
hostAddresses.first,
|
||||||
_authContexts,
|
_authContexts,
|
||||||
authContextId: authContextId,
|
authContextId: authContextId,
|
||||||
expectedHostClientId: expectedHostClientId,
|
expectedHostClientId: expectedHostClientId,
|
||||||
);
|
),
|
||||||
if (!_ownsPeer(candidate, generation)) {
|
onConnected: (peer) {
|
||||||
await _disposePeerOnce(candidate);
|
_lastAuthContextId = peer.selectedAuthContextId ?? authContextId;
|
||||||
return;
|
_lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId;
|
||||||
}
|
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
||||||
|
_reconnectAttempts = 0;
|
||||||
_pendingRemotePeer = null;
|
},
|
||||||
_peerService = candidate;
|
failureLog: 'CompanionRemote: Reconnect failed',
|
||||||
_lastAuthContextId = candidate.selectedAuthContextId ?? authContextId;
|
onFailure: (_) {
|
||||||
_lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId;
|
if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
|
||||||
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
|
_scheduleReconnect(generation);
|
||||||
_reconnectAttempts = 0;
|
}
|
||||||
safeNotifyListeners();
|
},
|
||||||
|
);
|
||||||
|
if (reconnected) {
|
||||||
appLogger.d('CompanionRemote: Reconnected successfully');
|
appLogger.d('CompanionRemote: Reconnected successfully');
|
||||||
} catch (error, stackTrace) {
|
|
||||||
if (!_ownsPeer(candidate, generation)) {
|
|
||||||
await _disposePeerOnce(candidate);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_pendingRemotePeer = null;
|
|
||||||
_cleanupSubscriptions();
|
|
||||||
await _disposePeerOnce(candidate);
|
|
||||||
appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace);
|
|
||||||
if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
|
|
||||||
_scheduleReconnect(generation);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import '../services/system_shelf_service.dart';
|
|||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/coalesced_load_coordinator.dart';
|
import '../utils/coalesced_load_coordinator.dart';
|
||||||
import '../utils/deletion_notifier.dart';
|
import '../utils/deletion_notifier.dart';
|
||||||
import '../utils/global_key_utils.dart';
|
import '../utils/media_event_keys.dart';
|
||||||
import '../utils/media_hub_ordering.dart';
|
import '../utils/media_hub_ordering.dart';
|
||||||
import '../utils/watch_state_notifier.dart';
|
import '../utils/watch_state_notifier.dart';
|
||||||
import 'hidden_libraries_provider.dart';
|
import 'hidden_libraries_provider.dart';
|
||||||
@@ -467,28 +467,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
|
|
||||||
/// Watch on-deck items and their parent shows/seasons (an episode's watch
|
/// Watch on-deck items and their parent shows/seasons (an episode's watch
|
||||||
/// flip changes what Continue Watching should show for its series).
|
/// flip changes what Continue Watching should show for its series).
|
||||||
Set<String>? get _watchedIds {
|
Set<String>? get _watchedIds => hierarchicalEventIds(_onDeck);
|
||||||
final keys = <String>{};
|
|
||||||
for (final item in _onDeck) {
|
|
||||||
keys.add(item.id);
|
|
||||||
if (item.parentId != null) keys.add(item.parentId!);
|
|
||||||
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<String>? get _watchedGlobalKeys {
|
Set<String>? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck);
|
||||||
final keys = <String>{};
|
|
||||||
for (final item in _onDeck) {
|
|
||||||
final serverId = item.serverId;
|
|
||||||
if (serverId == null) return null;
|
|
||||||
|
|
||||||
keys.add(buildGlobalKey(ServerId(serverId), item.id));
|
|
||||||
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
|
|
||||||
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onWatchStateChanged(WatchStateEvent event) {
|
void _onWatchStateChanged(WatchStateEvent event) {
|
||||||
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
|
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
|
||||||
@@ -512,46 +493,15 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
unawaited(refreshContinueWatching());
|
unawaited(refreshContinueWatching());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Everything on screen: the Continue Watching row plus every hub row.
|
||||||
|
Iterable<MediaItem> get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items));
|
||||||
|
|
||||||
/// Deletions can affect any visible list, so the filter covers on-deck and
|
/// Deletions can affect any visible list, so the filter covers on-deck and
|
||||||
/// hub items plus their parents (a deleted season/show takes its visible
|
/// hub items plus their parents (a deleted season/show takes its visible
|
||||||
/// episodes with it).
|
/// episodes with it).
|
||||||
Set<String>? get _deletionIds {
|
Set<String>? get _deletionIds => hierarchicalEventIds(_visibleItems);
|
||||||
final keys = <String>{};
|
|
||||||
void addItem(MediaItem item) {
|
|
||||||
keys.add(item.id);
|
|
||||||
if (item.parentId != null) keys.add(item.parentId!);
|
|
||||||
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
|
||||||
}
|
|
||||||
|
|
||||||
_onDeck.forEach(addItem);
|
Set<String>? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems);
|
||||||
for (final hub in _hubs) {
|
|
||||||
hub.items.forEach(addItem);
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
Set<String>? get _deletionGlobalKeys {
|
|
||||||
final keys = <String>{};
|
|
||||||
bool addItem(MediaItem item) {
|
|
||||||
final serverId = item.serverId;
|
|
||||||
if (serverId == null) return false;
|
|
||||||
|
|
||||||
keys.add(buildGlobalKey(ServerId(serverId), item.id));
|
|
||||||
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
|
|
||||||
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (final item in _onDeck) {
|
|
||||||
if (!addItem(item)) return null;
|
|
||||||
}
|
|
||||||
for (final hub in _hubs) {
|
|
||||||
for (final item in hub.items) {
|
|
||||||
if (!addItem(item)) return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onDeletion(DeletionEvent event) {
|
void _onDeletion(DeletionEvent event) {
|
||||||
// On-deck and hubs are server-backed: a download-only deletion leaves the
|
// On-deck and hubs are server-backed: a download-only deletion leaves the
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
|
|||||||
hydrated.add(
|
hydrated.add(
|
||||||
HydratedWatchStatePatch(
|
HydratedWatchStatePatch(
|
||||||
globalKey: scopedKey,
|
globalKey: scopedKey,
|
||||||
patch: WatchStatePatch.fromSnapshot(snapshot),
|
patch: snapshot,
|
||||||
updatedAt: latest.updatedAt,
|
updatedAt: latest.updatedAt,
|
||||||
order: latest.id,
|
order: latest.id,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
import 'dart:io';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../media/media_backend.dart';
|
import '../media/media_backend.dart';
|
||||||
@@ -17,6 +16,7 @@ import '../services/download_manager_service.dart';
|
|||||||
import '../services/api_cache.dart';
|
import '../services/api_cache.dart';
|
||||||
import '../services/download_artwork_service.dart';
|
import '../services/download_artwork_service.dart';
|
||||||
import '../services/download_storage_service.dart';
|
import '../services/download_storage_service.dart';
|
||||||
|
import '../services/downloaded_video_source.dart';
|
||||||
import '../services/multi_server_manager.dart';
|
import '../services/multi_server_manager.dart';
|
||||||
import '../services/offline_mode_source.dart';
|
import '../services/offline_mode_source.dart';
|
||||||
import '../services/watch_state_resolver.dart';
|
import '../services/watch_state_resolver.dart';
|
||||||
@@ -25,7 +25,6 @@ import '../media/media_server_client.dart';
|
|||||||
import '../services/sync_rule_executor.dart';
|
import '../services/sync_rule_executor.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/deletion_notifier.dart';
|
import '../utils/deletion_notifier.dart';
|
||||||
import '../utils/downloaded_version_match.dart';
|
|
||||||
import '../media/episode_collection.dart';
|
import '../media/episode_collection.dart';
|
||||||
import '../utils/global_key_utils.dart';
|
import '../utils/global_key_utils.dart';
|
||||||
import '../utils/watch_state_notifier.dart';
|
import '../utils/watch_state_notifier.dart';
|
||||||
@@ -925,46 +924,13 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
appLogger.w('No downloaded item found for globalKey: $globalKey');
|
appLogger.w('No downloaded item found for globalKey: $globalKey');
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (downloadedItem.status != DownloadStatus.completed.index) {
|
|
||||||
appLogger.w('Download not complete. Status: ${downloadedItem.status}');
|
final source = await resolveDownloadedVideoSource(
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!downloadedVersionMatches(
|
|
||||||
downloadedItem,
|
downloadedItem,
|
||||||
requestedMediaIndex: mediaIndex,
|
requestedMediaIndex: mediaIndex,
|
||||||
requestedMediaSourceId: mediaSourceId,
|
requestedMediaSourceId: mediaSourceId,
|
||||||
)) {
|
);
|
||||||
appLogger.w(
|
return source?.path;
|
||||||
'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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Queue a download for a media item.
|
/// Queue a download for a media item.
|
||||||
|
|||||||
@@ -108,24 +108,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
|||||||
/// filter to a one-element set when no filter is currently set.
|
/// filter to a one-element set when no filter is currently set.
|
||||||
void addToVisibleServerIds(ServerId serverId) {
|
void addToVisibleServerIds(ServerId serverId) {
|
||||||
final current = _visibleServerIds;
|
final current = _visibleServerIds;
|
||||||
if (current == null) {
|
if (current != null && current.contains(serverId)) return;
|
||||||
_serverManager.setVisibleServerIds({serverId});
|
_serverManager.setVisibleServerIds({...?current, serverId});
|
||||||
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
|
||||||
safeNotifyListeners();
|
|
||||||
_refreshLiveTvAvailabilitySoon();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (current.contains(serverId)) return;
|
|
||||||
_serverManager.setVisibleServerIds({...current, serverId});
|
|
||||||
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
_refreshLiveTvAvailabilitySoon();
|
_refreshLiveTvAvailabilitySoon();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Keep only ids the manager considers visible under the active filter.
|
||||||
|
List<String> _visible(List<String> ids) =>
|
||||||
|
ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList();
|
||||||
|
|
||||||
void _pruneLiveTvServersForVisibility() {
|
void _pruneLiveTvServersForVisibility() {
|
||||||
final filter = _visibleServerIds;
|
if (_visibleServerIds == null) return;
|
||||||
if (filter == null) return;
|
_liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId)));
|
||||||
_liveTvServers.removeWhere((s) => !filter.contains(s.serverId));
|
|
||||||
_hasLiveTv = _liveTvServers.isNotEmpty;
|
_hasLiveTv = _liveTvServers.isNotEmpty;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,20 +195,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Get all online server IDs (visibility-filtered).
|
/// Get all online server IDs (visibility-filtered).
|
||||||
List<String> get onlineServerIds {
|
List<String> get onlineServerIds => _visible(_serverManager.onlineServerIds);
|
||||||
final all = _serverManager.onlineServerIds;
|
|
||||||
final filter = _visibleServerIds;
|
|
||||||
if (filter == null) return all;
|
|
||||||
return all.where(filter.contains).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get all server IDs (visibility-filtered).
|
/// Get all server IDs (visibility-filtered).
|
||||||
List<String> get serverIds {
|
List<String> get serverIds => _visible(_serverManager.serverIds);
|
||||||
final all = _serverManager.serverIds;
|
|
||||||
final filter = _visibleServerIds;
|
|
||||||
if (filter == null) return all;
|
|
||||||
return all.where(filter.contains).toList();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Server ids the active profile is expected to have, including unreachable
|
/// Server ids the active profile is expected to have, including unreachable
|
||||||
/// Plex servers that have no live client yet.
|
/// Plex servers that have no live client yet.
|
||||||
@@ -223,11 +209,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Check if a server is online (and visible under the active profile).
|
/// Check if a server is online (and visible under the active profile).
|
||||||
bool isServerOnline(ServerId serverId) {
|
bool isServerOnline(ServerId serverId) =>
|
||||||
final filter = _visibleServerIds;
|
_serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId);
|
||||||
if (filter != null && !filter.contains(serverId)) return false;
|
|
||||||
return _serverManager.isServerOnline(serverId);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get number of online servers
|
/// Get number of online servers
|
||||||
int get onlineServerCount => onlineServerIds.length;
|
int get onlineServerCount => onlineServerIds.length;
|
||||||
@@ -312,10 +295,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final filter = _visibleServerIds;
|
final visibleLiveTvServers = newLiveTvServers
|
||||||
final visibleLiveTvServers = filter == null
|
.where((s) => _serverManager.isServerVisible(ServerId(s.serverId)))
|
||||||
? newLiveTvServers
|
.toList();
|
||||||
: newLiveTvServers.where((s) => filter.contains(s.serverId)).toList();
|
|
||||||
|
|
||||||
final hadLiveTv = _hasLiveTv;
|
final hadLiveTv = _hasLiveTv;
|
||||||
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
|
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
|
||||||
|
|||||||
@@ -11,36 +11,10 @@ import '../services/watch_state_resolver.dart';
|
|||||||
import '../utils/global_key_utils.dart';
|
import '../utils/global_key_utils.dart';
|
||||||
import '../utils/watch_state_notifier.dart';
|
import '../utils/watch_state_notifier.dart';
|
||||||
|
|
||||||
@immutable
|
|
||||||
class WatchStatePatch {
|
|
||||||
final bool? isWatched;
|
|
||||||
final bool hasViewOffsetMs;
|
|
||||||
final int? viewOffsetMs;
|
|
||||||
|
|
||||||
const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
|
|
||||||
|
|
||||||
factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch(
|
|
||||||
isWatched: snapshot.isWatched,
|
|
||||||
hasViewOffsetMs: snapshot.hasViewOffsetMs,
|
|
||||||
viewOffsetMs: snapshot.viewOffsetMs,
|
|
||||||
);
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool operator ==(Object other) =>
|
|
||||||
identical(this, other) ||
|
|
||||||
other is WatchStatePatch &&
|
|
||||||
other.isWatched == isWatched &&
|
|
||||||
other.hasViewOffsetMs == hasViewOffsetMs &&
|
|
||||||
other.viewOffsetMs == viewOffsetMs;
|
|
||||||
|
|
||||||
@override
|
|
||||||
int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
@immutable
|
@immutable
|
||||||
class HydratedWatchStatePatch {
|
class HydratedWatchStatePatch {
|
||||||
final String globalKey;
|
final String globalKey;
|
||||||
final WatchStatePatch patch;
|
final WatchStateSnapshot patch;
|
||||||
final int updatedAt;
|
final int updatedAt;
|
||||||
final int order;
|
final int order;
|
||||||
|
|
||||||
@@ -53,7 +27,7 @@ class HydratedWatchStatePatch {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _WatchStatePatchEntry {
|
class _WatchStatePatchEntry {
|
||||||
final WatchStatePatch patch;
|
final WatchStateSnapshot patch;
|
||||||
final int updatedAt;
|
final int updatedAt;
|
||||||
final int sequence;
|
final int sequence;
|
||||||
final bool isSessionEvent;
|
final bool isSessionEvent;
|
||||||
@@ -123,9 +97,9 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
return _exactEntryFor(globalKey);
|
return _exactEntryFor(globalKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
|
WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch;
|
||||||
|
|
||||||
WatchStatePatch? patchForItem(MediaItem item) {
|
WatchStateSnapshot? patchForItem(MediaItem item) {
|
||||||
var best = _entryFor(item.globalKey);
|
var best = _entryFor(item.globalKey);
|
||||||
if (item.parentChain.isNotEmpty) {
|
if (item.parentChain.isNotEmpty) {
|
||||||
final serverId = serverIdOrNull(item.serverId);
|
final serverId = serverIdOrNull(item.serverId);
|
||||||
@@ -147,14 +121,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
return [for (final item in items) apply(item)];
|
return [for (final item in items) apply(item)];
|
||||||
}
|
}
|
||||||
|
|
||||||
static MediaItem applyPatch(MediaItem item, WatchStatePatch? patch) {
|
static MediaItem applyPatch(MediaItem item, WatchStateSnapshot? patch) => patch == null ? item : patch.apply(item);
|
||||||
if (patch == null) return item;
|
|
||||||
return WatchStateSnapshot(
|
|
||||||
isWatched: patch.isWatched,
|
|
||||||
hasViewOffsetMs: patch.hasViewOffsetMs,
|
|
||||||
viewOffsetMs: patch.viewOffsetMs,
|
|
||||||
).apply(item);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setActiveProfileId(String? profileId) {
|
void setActiveProfileId(String? profileId) {
|
||||||
if (_activeProfileId == profileId) return;
|
if (_activeProfileId == profileId) return;
|
||||||
@@ -216,7 +183,7 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
? buildGlobalKey(ServerId(resolvedScope), event.itemId)
|
? buildGlobalKey(ServerId(resolvedScope), event.itemId)
|
||||||
: event.globalKey;
|
: event.globalKey;
|
||||||
_patches[key] = _WatchStatePatchEntry(
|
_patches[key] = _WatchStatePatchEntry(
|
||||||
WatchStatePatch.fromSnapshot(snapshot),
|
snapshot,
|
||||||
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
updatedAt: DateTime.now().millisecondsSinceEpoch,
|
||||||
sequence: ++_sequence,
|
sequence: ++_sequence,
|
||||||
isSessionEvent: true,
|
isSessionEvent: true,
|
||||||
@@ -240,7 +207,7 @@ extension WatchStateResolution on BuildContext {
|
|||||||
/// ancestor). Use in `build`.
|
/// ancestor). Use in `build`.
|
||||||
MediaItem withFreshWatchState(MediaItem item) {
|
MediaItem withFreshWatchState(MediaItem item) {
|
||||||
try {
|
try {
|
||||||
final patch = select<WatchStateStore, WatchStatePatch?>((store) => store.patchForItem(item));
|
final patch = select<WatchStateStore, WatchStateSnapshot?>((store) => store.patchForItem(item));
|
||||||
return WatchStateStore.applyPatch(item, patch);
|
return WatchStateStore.applyPatch(item, patch);
|
||||||
} on ProviderNotFoundException {
|
} on ProviderNotFoundException {
|
||||||
return item;
|
return item;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../media/media_item.dart';
|
|||||||
import '../media/media_kind.dart';
|
import '../media/media_kind.dart';
|
||||||
import '../media/media_server_client.dart';
|
import '../media/media_server_client.dart';
|
||||||
import '../mixins/paginated_item_loader.dart';
|
import '../mixins/paginated_item_loader.dart';
|
||||||
|
import '../mixins/standard_paginated_view.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/media_server_http_client.dart';
|
import '../utils/media_server_http_client.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
@@ -48,7 +49,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
|||||||
with
|
with
|
||||||
GridFocusNodeMixin<ActorMediaScreen>,
|
GridFocusNodeMixin<ActorMediaScreen>,
|
||||||
FocusableDetailScreenMixin<ActorMediaScreen>,
|
FocusableDetailScreenMixin<ActorMediaScreen>,
|
||||||
PaginatedItemLoader<MediaItem, ActorMediaScreen> {
|
PaginatedItemLoader<MediaItem, ActorMediaScreen>,
|
||||||
|
PaginatedItemUpdatable<ActorMediaScreen>,
|
||||||
|
StandardPaginatedView<MediaItem, ActorMediaScreen> {
|
||||||
static const int _pageSize = 200;
|
static const int _pageSize = 200;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
Future<void> loadItems() {
|
||||||
for (final entry in loadedItems.entries) {
|
return loadStandardPaginatedItems(
|
||||||
if (entry.value.globalKey == sourceGlobalKey) {
|
|
||||||
loadedItems[entry.key] = updatedItem;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> loadItems() async {
|
|
||||||
await loadInitialPaginatedItems(
|
|
||||||
pageSize: _pageSize,
|
pageSize: _pageSize,
|
||||||
resetViewState: () {
|
errorMessageFor: (error, stackTrace) {
|
||||||
isLoading = true;
|
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
|
||||||
errorMessage = null;
|
return t.messages.errorLoading(error: error.toString());
|
||||||
items = [];
|
|
||||||
},
|
|
||||||
applyLoadedItems: (loaded) {
|
|
||||||
items = loaded;
|
|
||||||
isLoading = false;
|
|
||||||
},
|
|
||||||
applyError: (error, _) {
|
|
||||||
errorMessage = t.messages.errorLoading(error: error.toString());
|
|
||||||
isLoading = false;
|
|
||||||
},
|
},
|
||||||
onLoaded: (loadedCount, totalCount) {
|
onLoaded: (loadedCount, totalCount) {
|
||||||
appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}');
|
appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}');
|
||||||
autoFocusFirstItemAfterLoad();
|
autoFocusFirstItemAfterLoad();
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
|
||||||
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import '../profiles/active_profile_provider.dart';
|
|||||||
import '../profiles/plex_home_service.dart';
|
import '../profiles/plex_home_service.dart';
|
||||||
import '../profiles/profile.dart';
|
import '../profiles/profile.dart';
|
||||||
import '../profiles/profile_connection_registry.dart';
|
import '../profiles/profile_connection_registry.dart';
|
||||||
|
import '../profiles/profile_selection_policy.dart';
|
||||||
import '../services/plex_auth_service.dart';
|
import '../services/plex_auth_service.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/storage_service.dart';
|
import '../services/storage_service.dart';
|
||||||
@@ -196,8 +197,7 @@ class _AuthScreenState extends State<AuthScreen> {
|
|||||||
activeProfile: activeProfiles.active,
|
activeProfile: activeProfiles.active,
|
||||||
hasProfiles: activeProfiles.profiles.isNotEmpty,
|
hasProfiles: activeProfiles.profiles.isNotEmpty,
|
||||||
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
|
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
|
||||||
requireProfileSelectionOnOpen:
|
requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings),
|
||||||
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles,
|
|
||||||
);
|
);
|
||||||
if (promptHandled) {
|
if (promptHandled) {
|
||||||
final selected = await Navigator.of(
|
final selected = await Navigator.of(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
|||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_playlist.dart';
|
import '../media/media_playlist.dart';
|
||||||
import '../media/media_server_client.dart';
|
import '../media/media_server_client.dart';
|
||||||
|
import '../providers/download_provider.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../services/media_list_playback_launcher.dart';
|
import '../services/media_list_playback_launcher.dart';
|
||||||
@@ -41,19 +42,34 @@ abstract class BaseMediaListDetailScreen<T extends StatefulWidget> extends State
|
|||||||
/// Optional icon to show when list is empty
|
/// Optional icon to show when list is empty
|
||||||
IconData? get emptyIcon => null;
|
IconData? get emptyIcon => null;
|
||||||
|
|
||||||
|
/// Server the displayed item was tagged with, if any.
|
||||||
|
String? get _mediaItemServerId => switch (mediaItem) {
|
||||||
|
MediaItem(:final serverId) => serverId,
|
||||||
|
MediaPlaylist(:final serverId) => serverId,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Sync-rule global key for the displayed collection/playlist, keyed to the
|
||||||
|
/// item's own server when it has one and to the resolved client's otherwise.
|
||||||
|
String get syncRuleKey {
|
||||||
|
final client = mediaClient;
|
||||||
|
final id = switch (mediaItem) {
|
||||||
|
MediaItem(:final id) => id,
|
||||||
|
MediaPlaylist(:final id) => id,
|
||||||
|
_ => '',
|
||||||
|
};
|
||||||
|
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
||||||
|
client,
|
||||||
|
id,
|
||||||
|
serverId: ServerId(_mediaItemServerId ?? client.serverId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
String? _resolveMediaItemServerId() {
|
String? _resolveMediaItemServerId() {
|
||||||
final item = mediaItem;
|
final serverId = _mediaItemServerId;
|
||||||
String? serverId;
|
if (serverId != null) return serverId;
|
||||||
if (item is MediaItem) {
|
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
||||||
serverId = item.serverId;
|
return multiServerProvider.onlineServerIds.firstOrNull;
|
||||||
} else if (item is MediaPlaylist) {
|
|
||||||
serverId = item.serverId;
|
|
||||||
}
|
|
||||||
if (serverId == null) {
|
|
||||||
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
|
|
||||||
serverId = multiServerProvider.onlineServerIds.firstOrNull;
|
|
||||||
}
|
|
||||||
return serverId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
MediaServerClient _getMediaClientForMediaItem() {
|
MediaServerClient _getMediaClientForMediaItem() {
|
||||||
|
|||||||
@@ -1,17 +1,16 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../media/ids.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../focus/focusable_action_bar.dart';
|
import '../focus/focusable_action_bar.dart';
|
||||||
import '../media/library_query.dart';
|
import '../media/library_query.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../mixins/paginated_item_loader.dart';
|
import '../mixins/paginated_item_loader.dart';
|
||||||
|
import '../mixins/standard_paginated_view.dart';
|
||||||
import '../providers/download_provider.dart';
|
import '../providers/download_provider.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
import '../utils/error_message_utils.dart';
|
import '../utils/error_message_utils.dart';
|
||||||
import '../utils/download_utils.dart';
|
import '../utils/download_utils.dart';
|
||||||
import '../utils/platform_detector.dart';
|
|
||||||
import '../utils/media_server_http_client.dart';
|
import '../utils/media_server_http_client.dart';
|
||||||
import '../utils/snackbar_helper.dart';
|
import '../utils/snackbar_helper.dart';
|
||||||
import '../widgets/desktop_app_bar.dart';
|
import '../widgets/desktop_app_bar.dart';
|
||||||
@@ -35,7 +34,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
|||||||
with
|
with
|
||||||
GridFocusNodeMixin<CollectionDetailScreen>,
|
GridFocusNodeMixin<CollectionDetailScreen>,
|
||||||
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
FocusableDetailScreenMixin<CollectionDetailScreen>,
|
||||||
PaginatedItemLoader<MediaItem, CollectionDetailScreen> {
|
PaginatedItemLoader<MediaItem, CollectionDetailScreen>,
|
||||||
|
PaginatedItemUpdatable<CollectionDetailScreen>,
|
||||||
|
StandardPaginatedView<MediaItem, CollectionDetailScreen> {
|
||||||
static const int _pageSize = 200;
|
static const int _pageSize = 200;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -75,49 +76,21 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
Future<void> loadItems() {
|
||||||
// Search [loadedItems] (not the flat [items] snapshot, which only has
|
return loadStandardPaginatedItems(
|
||||||
// the first page) so refreshing an item at a scrolled-in position updates
|
|
||||||
// the grid in place.
|
|
||||||
for (final entry in loadedItems.entries) {
|
|
||||||
if (entry.value.globalKey == sourceGlobalKey) {
|
|
||||||
loadedItems[entry.key] = updatedItem;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Future<void> loadItems() async {
|
|
||||||
String? loadErrorMessage;
|
|
||||||
await loadInitialPaginatedItems(
|
|
||||||
pageSize: _pageSize,
|
pageSize: _pageSize,
|
||||||
resetViewState: () {
|
errorMessageFor: (error, stackTrace) =>
|
||||||
isLoading = true;
|
localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection),
|
||||||
errorMessage = null;
|
|
||||||
items = [];
|
|
||||||
},
|
|
||||||
applyLoadedItems: (loaded) {
|
|
||||||
items = loaded;
|
|
||||||
isLoading = false;
|
|
||||||
},
|
|
||||||
applyError: (error, stackTrace) {
|
|
||||||
errorMessage = loadErrorMessage ?? t.errors.unableToLoad(context: t.collections.collection);
|
|
||||||
isLoading = false;
|
|
||||||
},
|
|
||||||
onLoaded: (loadedCount, totalCount) {
|
onLoaded: (loadedCount, totalCount) {
|
||||||
appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}');
|
appLogger.d('Loaded $loadedCount of $totalCount items for collection: ${widget.collection.title}');
|
||||||
autoFocusFirstItemAfterLoad();
|
autoFocusFirstItemAfterLoad();
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
|
||||||
loadErrorMessage = localizedLoadErrorMessage(error, stackTrace, context: t.collections.collection);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
List<FocusableAction> getAppBarActions() {
|
List<FocusableAction> getAppBarActions() {
|
||||||
final ruleKey = _collectionSyncRuleKey();
|
final ruleKey = syncRuleKey;
|
||||||
// Select the specific bool we care about so unrelated DownloadProvider
|
// Select the specific bool we care about so unrelated DownloadProvider
|
||||||
// ticks (e.g. active download progress) don't rebuild the app bar.
|
// ticks (e.g. active download progress) don't rebuild the app bar.
|
||||||
final hasRule = context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
|
final hasRule = context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
|
||||||
@@ -127,19 +100,16 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
|||||||
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
|
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
|
||||||
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
||||||
],
|
],
|
||||||
if (!PlatformDetector.isAppleTV())
|
// Emptiness is handled inside [_downloadCollection], so the download
|
||||||
FocusableAction(
|
// entry stays visible for empty collections.
|
||||||
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
|
...buildSyncRuleActions(
|
||||||
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
|
context,
|
||||||
onPressed: hasRule ? _manageCollectionSyncRule : _downloadCollection,
|
ruleKey: ruleKey,
|
||||||
iconColor: hasRule ? Colors.teal : null,
|
displayTitle: widget.collection.displayTitle,
|
||||||
),
|
hasRule: hasRule,
|
||||||
if (!PlatformDetector.isAppleTV() && hasRule)
|
showDownload: true,
|
||||||
FocusableAction(
|
onDownload: _downloadCollection,
|
||||||
icon: Symbols.sync_disabled_rounded,
|
),
|
||||||
tooltip: t.downloads.removeSyncRule,
|
|
||||||
onPressed: _removeCollectionSyncRule,
|
|
||||||
),
|
|
||||||
FocusableAction(
|
FocusableAction(
|
||||||
icon: Symbols.delete_rounded,
|
icon: Symbols.delete_rounded,
|
||||||
tooltip: t.common.delete,
|
tooltip: t.common.delete,
|
||||||
@@ -181,25 +151,6 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _manageCollectionSyncRule() =>
|
|
||||||
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _collectionSyncRuleKey());
|
|
||||||
|
|
||||||
Future<void> _removeCollectionSyncRule() => removeSyncRuleAndSnack(
|
|
||||||
context,
|
|
||||||
downloadProvider: context.read<DownloadProvider>(),
|
|
||||||
globalKey: _collectionSyncRuleKey(),
|
|
||||||
displayTitle: widget.collection.displayTitle,
|
|
||||||
);
|
|
||||||
|
|
||||||
String _collectionSyncRuleKey() {
|
|
||||||
final serverId = widget.collection.serverId ?? mediaClient.serverId;
|
|
||||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
|
||||||
mediaClient,
|
|
||||||
widget.collection.id,
|
|
||||||
serverId: ServerId(serverId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _deleteCollection() async {
|
Future<void> _deleteCollection() async {
|
||||||
final confirmed = await showDeleteConfirmation(
|
final confirmed = await showDeleteConfirmation(
|
||||||
context,
|
context,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import '../i18n/strings.g.dart';
|
|||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
import '../utils/formatters.dart';
|
import '../utils/formatters.dart';
|
||||||
|
import '../utils/hub_icons.dart';
|
||||||
import '../utils/media_navigation_helper.dart';
|
import '../utils/media_navigation_helper.dart';
|
||||||
import '../utils/provider_extensions.dart';
|
import '../utils/provider_extensions.dart';
|
||||||
import '../utils/video_player_navigation.dart';
|
import '../utils/video_player_navigation.dart';
|
||||||
@@ -180,17 +181,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!;
|
if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!;
|
||||||
final hubs = <MediaHub>[];
|
final hubs = <MediaHub>[];
|
||||||
if (_onDeck.isNotEmpty) {
|
if (_onDeck.isNotEmpty) {
|
||||||
hubs.add(
|
hubs.add(_continueWatchingHub);
|
||||||
MediaHub(
|
|
||||||
id: 'continue_watching',
|
|
||||||
title: t.discover.continueWatching,
|
|
||||||
type: 'mixed',
|
|
||||||
identifier: '_continue_watching_',
|
|
||||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
|
||||||
more: _hasMoreContinueWatching,
|
|
||||||
items: _onDeck,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
|
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
|
||||||
_tvBrowseHubsCache = hubs;
|
_tvBrowseHubsCache = hubs;
|
||||||
@@ -198,6 +189,18 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
return hubs;
|
return hubs;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The synthesized Continue Watching row, rendered ahead of the backend hubs
|
||||||
|
/// on both the mobile list and the TV rail.
|
||||||
|
MediaHub get _continueWatchingHub => MediaHub(
|
||||||
|
id: 'continue_watching',
|
||||||
|
title: t.discover.continueWatching,
|
||||||
|
type: 'mixed',
|
||||||
|
identifier: '_continue_watching_',
|
||||||
|
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
||||||
|
more: _hasMoreContinueWatching,
|
||||||
|
items: _onDeck,
|
||||||
|
);
|
||||||
|
|
||||||
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
|
void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
|
||||||
|
|
||||||
void _scrollToTop() {
|
void _scrollToTop() {
|
||||||
@@ -615,101 +618,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
unawaited(_discover.load());
|
unawaited(_discover.load());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get icon for hub based on its title
|
|
||||||
IconData _getHubIcon(String title) {
|
|
||||||
final lowerTitle = title.toLowerCase();
|
|
||||||
|
|
||||||
// Trending/Popular content
|
|
||||||
if (lowerTitle.contains('trending')) {
|
|
||||||
return Symbols.trending_up_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('popular') || lowerTitle.contains('imdb')) {
|
|
||||||
return Symbols.whatshot_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Seasonal/Time-based
|
|
||||||
if (lowerTitle.contains('seasonal')) {
|
|
||||||
return Symbols.calendar_month_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('newly') || lowerTitle.contains('new release')) {
|
|
||||||
return Symbols.new_releases_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('recently released') || lowerTitle.contains('recent')) {
|
|
||||||
return Symbols.schedule_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Top/Rated content
|
|
||||||
if (lowerTitle.contains('top rated') || lowerTitle.contains('highest rated')) {
|
|
||||||
return Symbols.star_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('top ')) {
|
|
||||||
return Symbols.military_tech_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Genre-specific
|
|
||||||
if (lowerTitle.contains('thriller')) {
|
|
||||||
return Symbols.warning_amber_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('comedy') || lowerTitle.contains('comedier')) {
|
|
||||||
return Symbols.mood_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('action')) {
|
|
||||||
return Symbols.flash_on_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('drama')) {
|
|
||||||
return Symbols.theater_comedy_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('fantasy')) {
|
|
||||||
return Symbols.auto_fix_high_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('science') || lowerTitle.contains('sci-fi')) {
|
|
||||||
return Symbols.rocket_launch_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('horror') || lowerTitle.contains('skräck')) {
|
|
||||||
return Symbols.nights_stay_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('romance') || lowerTitle.contains('romantic')) {
|
|
||||||
return Symbols.favorite_border_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('adventure') || lowerTitle.contains('äventyr')) {
|
|
||||||
return Symbols.explore_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Watchlist/Playlists
|
|
||||||
if (lowerTitle.contains('playlist') || lowerTitle.contains('watchlist')) {
|
|
||||||
return Symbols.playlist_play_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('unwatched') || lowerTitle.contains('unplayed')) {
|
|
||||||
return Symbols.visibility_off_rounded;
|
|
||||||
}
|
|
||||||
if (lowerTitle.contains('watched') || lowerTitle.contains('played')) {
|
|
||||||
return Symbols.visibility_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Network/Studio
|
|
||||||
if (lowerTitle.contains('network') || lowerTitle.contains('more from')) {
|
|
||||||
return Symbols.tv_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Actor/Director
|
|
||||||
if (lowerTitle.contains('actor') || lowerTitle.contains('director')) {
|
|
||||||
return Symbols.person_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Year-based (80s, 90s, etc.)
|
|
||||||
if (lowerTitle.contains('80') || lowerTitle.contains('90') || lowerTitle.contains('00')) {
|
|
||||||
return Symbols.history_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rediscover/Start Watching
|
|
||||||
if (lowerTitle.contains('rediscover') || lowerTitle.contains('start watching')) {
|
|
||||||
return Symbols.play_arrow_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default icon for other hubs
|
|
||||||
return Symbols.auto_awesome_rounded;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether the loaded hubs span more than one connected server.
|
/// Whether the loaded hubs span more than one connected server.
|
||||||
bool _hubsSpanMultipleServers() {
|
bool _hubsSpanMultipleServers() {
|
||||||
final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet();
|
final serverIds = _hubs.where((hub) => hub.serverId != null).map((hub) => hub.serverId).toSet();
|
||||||
@@ -1011,6 +919,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
|
|
||||||
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
final bottomPadding = MediaQuery.paddingOf(context).bottom;
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
|
final continueWatchingHub = _onDeck.isEmpty ? null : _continueWatchingHub;
|
||||||
return Material(
|
return Material(
|
||||||
color: theme.scaffoldBackgroundColor,
|
color: theme.scaffoldBackgroundColor,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
@@ -1034,21 +943,13 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
||||||
if (!_isLoading && _errorMessage == null) ...[
|
if (!_isLoading && _errorMessage == null) ...[
|
||||||
// On Deck / Continue Watching
|
// On Deck / Continue Watching
|
||||||
if (_onDeck.isNotEmpty)
|
if (continueWatchingHub != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: HubSection(
|
child: HubSection(
|
||||||
key: _continueWatchingHubKey,
|
key: _continueWatchingHubKey,
|
||||||
hub: MediaHub(
|
hub: continueWatchingHub,
|
||||||
id: 'continue_watching',
|
|
||||||
title: t.discover.continueWatching,
|
|
||||||
type: 'mixed',
|
|
||||||
identifier: '_continue_watching_',
|
|
||||||
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
|
|
||||||
more: _hasMoreContinueWatching,
|
|
||||||
items: _onDeck,
|
|
||||||
),
|
|
||||||
focusMemory: _hubFocusMemory,
|
focusMemory: _hubFocusMemory,
|
||||||
icon: Symbols.play_circle_rounded,
|
icon: hubIconFor(continueWatchingHub),
|
||||||
onRefresh: _discover.updateItem,
|
onRefresh: _discover.updateItem,
|
||||||
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
||||||
isInContinueWatching: true,
|
isInContinueWatching: true,
|
||||||
@@ -1066,7 +967,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null,
|
key: i < _orderedHubKeys.length ? _orderedHubKeys[i] : null,
|
||||||
hub: _hubs[i],
|
hub: _hubs[i],
|
||||||
focusMemory: _hubFocusMemory,
|
focusMemory: _hubFocusMemory,
|
||||||
icon: _getHubIcon(_hubs[i].title),
|
icon: hubIconFor(_hubs[i]),
|
||||||
showServerName: showServerNameOnHubs || hubsSpanMultipleServers,
|
showServerName: showServerNameOnHubs || hubsSpanMultipleServers,
|
||||||
onRefresh: _discover.updateItem,
|
onRefresh: _discover.updateItem,
|
||||||
// Hub index is i + 1 if continue watching exists, otherwise i
|
// Hub index is i + 1 if continue watching exists, otherwise i
|
||||||
@@ -1152,7 +1053,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
hubs: browseHubs,
|
hubs: browseHubs,
|
||||||
focusMemory: _hubFocusMemory,
|
focusMemory: _hubFocusMemory,
|
||||||
showServerName: showServerName,
|
showServerName: showServerName,
|
||||||
iconForHub: (hub, _) => hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title),
|
iconForHub: (hub, _) => hubIconFor(hub),
|
||||||
onFocusedItemChanged: _setSpotlightItem,
|
onFocusedItemChanged: _setSpotlightItem,
|
||||||
onRefresh: _discover.updateItem,
|
onRefresh: _discover.updateItem,
|
||||||
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
onRemoveFromContinueWatching: _discover.refreshContinueWatching,
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import '../focus/input_mode_tracker.dart';
|
|||||||
import '../focus/key_event_utils.dart';
|
import '../focus/key_event_utils.dart';
|
||||||
import '../i18n/strings.g.dart';
|
import '../i18n/strings.g.dart';
|
||||||
import '../media/media_item.dart';
|
import '../media/media_item.dart';
|
||||||
import '../media/media_playlist.dart';
|
|
||||||
import '../mixins/grid_focus_node_mixin.dart';
|
import '../mixins/grid_focus_node_mixin.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
@@ -15,14 +14,6 @@ import '../widgets/media_card_sliver_layout.dart';
|
|||||||
import '../widgets/overlay_sheet.dart';
|
import '../widgets/overlay_sheet.dart';
|
||||||
import '../widgets/skeleton_media_card.dart';
|
import '../widgets/skeleton_media_card.dart';
|
||||||
|
|
||||||
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
|
|
||||||
/// Flutter widget Key.
|
|
||||||
String _idForItem(Object item) {
|
|
||||||
if (item is MediaItem) return item.id;
|
|
||||||
if (item is MediaPlaylist) return item.id;
|
|
||||||
return identityHashCode(item).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Mixin that provides common focus navigation functionality for detail screens.
|
/// Mixin that provides common focus navigation functionality for detail screens.
|
||||||
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
|
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
|
||||||
///
|
///
|
||||||
@@ -164,56 +155,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
|||||||
/// Used by collection, smart playlist, and music artist detail screens.
|
/// Used by collection, smart playlist, and music artist detail screens.
|
||||||
/// [shape] overrides the grid cell silhouette (e.g. [CardShape.square]
|
/// [shape] overrides the grid cell silhouette (e.g. [CardShape.square]
|
||||||
/// for album grids); null keeps the stock poster geometry.
|
/// for album grids); null keeps the stock poster geometry.
|
||||||
|
///
|
||||||
|
/// Fully-loaded case of [buildSparseFocusableGrid]: every slot resolves to an
|
||||||
|
/// item, so the skeleton branch is unreachable.
|
||||||
Widget buildFocusableGrid({
|
Widget buildFocusableGrid({
|
||||||
required List<dynamic> items,
|
required List<MediaItem> items,
|
||||||
required void Function(MediaItem source) onRefresh,
|
required void Function(MediaItem source) onRefresh,
|
||||||
String? collectionId,
|
String? collectionId,
|
||||||
VoidCallback? onListRefresh,
|
VoidCallback? onListRefresh,
|
||||||
CardShape? shape,
|
CardShape? shape,
|
||||||
}) {
|
}) {
|
||||||
return SettingsBuilder(
|
return buildSparseFocusableGrid(
|
||||||
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout],
|
totalItems: items.length,
|
||||||
builder: (context) {
|
itemAt: (index) => items[index],
|
||||||
final svc = SettingsService.instance;
|
onRefresh: onRefresh,
|
||||||
final viewMode = svc.read(SettingsService.viewMode);
|
collectionId: collectionId,
|
||||||
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
onListRefresh: onListRefresh,
|
||||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
shape: shape,
|
||||||
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
|
|
||||||
|
|
||||||
return MediaCardSliverLayout(
|
|
||||||
viewMode: viewMode,
|
|
||||||
itemCount: items.length,
|
|
||||||
density: libraryDensity,
|
|
||||||
padding: const EdgeInsets.all(8),
|
|
||||||
fullBleedImage: useFullCardLayout,
|
|
||||||
shape: shape,
|
|
||||||
itemBuilder: (context, position) {
|
|
||||||
final index = position.index;
|
|
||||||
final item = items[index];
|
|
||||||
final focusNode = _focusNodeForIndex(index);
|
|
||||||
|
|
||||||
return FocusableMediaCard(
|
|
||||||
key: Key(_idForItem(item)),
|
|
||||||
item: item,
|
|
||||||
focusNode: focusNode,
|
|
||||||
semanticValue: _semanticPosition(position),
|
|
||||||
disableScale: position.disableScale,
|
|
||||||
onRefresh: onRefresh,
|
|
||||||
collectionId: collectionId,
|
|
||||||
onListRefresh: onListRefresh,
|
|
||||||
fullBleedImage: useFullCardLayout && position.isGrid,
|
|
||||||
cardShapeOverride: shape,
|
|
||||||
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
|
|
||||||
onBack: handleBackFromContent,
|
|
||||||
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems]
|
/// Sparse-loading counterpart of [buildFocusableGrid]. Renders [totalItems]
|
||||||
/// slots; for each, [itemAt] returns the loaded item or null if not yet
|
/// slots; for each, [itemAt] returns the loaded item or null if not yet
|
||||||
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
|
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
|
||||||
/// the caller can kick off a page fetch containing that index.
|
/// the caller can kick off a page fetch containing that index.
|
||||||
@@ -242,7 +204,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
|
|||||||
onSkeletonVisible?.call(index);
|
onSkeletonVisible?.call(index);
|
||||||
return const SkeletonMediaCard();
|
return const SkeletonMediaCard();
|
||||||
}
|
}
|
||||||
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item');
|
final focusNode = _focusNodeForIndex(index);
|
||||||
return FocusableMediaCard(
|
return FocusableMediaCard(
|
||||||
key: Key(item.id),
|
key: Key(item.id),
|
||||||
item: item,
|
item: item,
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart';
|
|||||||
import '../widgets/loading_indicator_box.dart';
|
import '../widgets/loading_indicator_box.dart';
|
||||||
import '../widgets/overlay_sheet.dart';
|
import '../widgets/overlay_sheet.dart';
|
||||||
import '../focus/focusable_action_bar.dart';
|
import '../focus/focusable_action_bar.dart';
|
||||||
import '../focus/focusable_button.dart';
|
|
||||||
import '../focus/key_event_utils.dart';
|
import '../focus/key_event_utils.dart';
|
||||||
import '../mixins/grid_focus_node_mixin.dart';
|
import '../mixins/grid_focus_node_mixin.dart';
|
||||||
import '../mixins/paginated_item_loader.dart';
|
import '../mixins/paginated_item_loader.dart';
|
||||||
@@ -493,34 +492,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error;
|
Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error;
|
||||||
bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading;
|
bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading;
|
||||||
|
|
||||||
Widget _buildContinuationStatusSliver() {
|
|
||||||
final exception = _pageLoadError;
|
|
||||||
final error = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
|
||||||
return SliverToBoxAdapter(
|
|
||||||
child: Padding(
|
|
||||||
padding: const EdgeInsets.all(24),
|
|
||||||
child: Center(
|
|
||||||
child: error == null
|
|
||||||
? const CircularProgressIndicator()
|
|
||||||
: Column(
|
|
||||||
mainAxisSize: .min,
|
|
||||||
children: [
|
|
||||||
Text(error, textAlign: TextAlign.center),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
FocusableButton(
|
|
||||||
focusNode: _continuationRetryFocusNode,
|
|
||||||
onPressed: _retryHubContinuation,
|
|
||||||
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
|
|
||||||
onBack: handleBackFromContent,
|
|
||||||
child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void refresh() {
|
void refresh() {
|
||||||
_loadMoreItems();
|
_loadMoreItems();
|
||||||
@@ -633,7 +604,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
},
|
},
|
||||||
),
|
),
|
||||||
if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null))
|
if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null))
|
||||||
_buildContinuationStatusSliver(),
|
ContinuationStatusSliver(
|
||||||
|
error: _pageLoadError,
|
||||||
|
onRetry: _retryHubContinuation,
|
||||||
|
retryFocusNode: _continuationRetryFocusNode,
|
||||||
|
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
|
||||||
|
onBack: handleBackFromContent,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
|
import '../../focus/focusable_button.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import 'state_messages.dart';
|
import 'state_messages.dart';
|
||||||
|
|
||||||
@@ -101,6 +102,55 @@ class SliverEmptyState extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Footer sliver for continuation (append-to-list) pagination: a spinner while
|
||||||
|
/// the next page loads, or the error message with a focusable retry button.
|
||||||
|
class ContinuationStatusSliver extends StatelessWidget {
|
||||||
|
/// Failure from the last page load; null while the page is still loading.
|
||||||
|
final Object? error;
|
||||||
|
final VoidCallback onRetry;
|
||||||
|
final FocusNode retryFocusNode;
|
||||||
|
final VoidCallback? onNavigateUp;
|
||||||
|
final VoidCallback? onBack;
|
||||||
|
|
||||||
|
const ContinuationStatusSliver({
|
||||||
|
super.key,
|
||||||
|
required this.error,
|
||||||
|
required this.onRetry,
|
||||||
|
required this.retryFocusNode,
|
||||||
|
this.onNavigateUp,
|
||||||
|
this.onBack,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final exception = error;
|
||||||
|
final message = exception == null ? null : t.messages.errorLoading(error: exception.toString());
|
||||||
|
return SliverToBoxAdapter(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: message == null
|
||||||
|
? const CircularProgressIndicator()
|
||||||
|
: Column(
|
||||||
|
mainAxisSize: .min,
|
||||||
|
children: [
|
||||||
|
Text(message, textAlign: TextAlign.center),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
FocusableButton(
|
||||||
|
focusNode: retryFocusNode,
|
||||||
|
onPressed: onRetry,
|
||||||
|
onNavigateUp: onNavigateUp,
|
||||||
|
onBack: onBack,
|
||||||
|
child: TextButton(onPressed: onRetry, child: Text(t.common.retry)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// A widget that handles loading, error, empty, and content states
|
/// A widget that handles loading, error, empty, and content states
|
||||||
/// Provides a consistent UI pattern across the app for data-driven screens
|
/// Provides a consistent UI pattern across the app for data-driven screens
|
||||||
class ContentStateBuilder<T> extends StatelessWidget {
|
class ContentStateBuilder<T> extends StatelessWidget {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import '../../media/media_item.dart';
|
|||||||
import '../../media/media_kind.dart';
|
import '../../media/media_kind.dart';
|
||||||
import '../../media/media_server_client.dart';
|
import '../../media/media_server_client.dart';
|
||||||
import '../../services/jellyfin_sequential_launcher.dart';
|
import '../../services/jellyfin_sequential_launcher.dart';
|
||||||
|
import '../../services/media_list_playback_launcher.dart';
|
||||||
import '../../services/play_queue_launcher.dart';
|
import '../../services/play_queue_launcher.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../utils/error_message_utils.dart';
|
import '../../utils/error_message_utils.dart';
|
||||||
@@ -243,42 +244,19 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleFolderPlay(MediaItem folder) async {
|
/// Play (or shuffle) a folder row through the backend's launcher. Built
|
||||||
|
/// here rather than via [MediaListPlaybackLauncher.forItem] because this
|
||||||
|
/// tree is pinned to one server: the Plex client must be the one backing
|
||||||
|
/// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution.
|
||||||
|
Future<void> _launchFolder(MediaItem folder, {required bool shuffle}) async {
|
||||||
|
final MediaListPlaybackLauncher launcher;
|
||||||
if (folder.backend == MediaBackend.jellyfin) {
|
if (folder.backend == MediaBackend.jellyfin) {
|
||||||
final launcher = JellyfinSequentialLauncher(context: context);
|
launcher = JellyfinSequentialLauncher(context: context);
|
||||||
await launcher.launchFromFolder(folder: folder, shuffle: false);
|
} else {
|
||||||
return;
|
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
||||||
|
launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
||||||
}
|
}
|
||||||
|
await launcher.launchFromFolder(folder: folder, shuffle: shuffle);
|
||||||
final folderKey = folder.backendFolderKey;
|
|
||||||
if (folderKey == null) return;
|
|
||||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
|
||||||
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
|
||||||
await launcher.launchFromFolder(
|
|
||||||
folderKey: folderKey,
|
|
||||||
shuffle: false,
|
|
||||||
libraryId: folder.libraryId,
|
|
||||||
libraryTitle: folder.libraryTitle,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _handleFolderShuffle(MediaItem folder) async {
|
|
||||||
if (folder.backend == MediaBackend.jellyfin) {
|
|
||||||
final launcher = JellyfinSequentialLauncher(context: context);
|
|
||||||
await launcher.launchFromFolder(folder: folder, shuffle: true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final folderKey = folder.backendFolderKey;
|
|
||||||
if (folderKey == null) return;
|
|
||||||
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
|
|
||||||
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
|
|
||||||
await launcher.launchFromFolder(
|
|
||||||
folderKey: folderKey,
|
|
||||||
shuffle: true,
|
|
||||||
libraryId: folder.libraryId,
|
|
||||||
libraryTitle: folder.libraryTitle,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Expandable rows: directory rows plus Jellyfin media containers whose
|
/// Expandable rows: directory rows plus Jellyfin media containers whose
|
||||||
@@ -400,8 +378,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
|
|||||||
serverId: widget.serverId,
|
serverId: widget.serverId,
|
||||||
onExpand: isExpandable ? () => _toggleFolder(item) : null,
|
onExpand: isExpandable ? () => _toggleFolder(item) : null,
|
||||||
onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null,
|
onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null,
|
||||||
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null,
|
onPlayAll: canPlayFolder ? () => _launchFolder(item, shuffle: false) : null,
|
||||||
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null,
|
onShuffle: canPlayFolder ? () => _launchFolder(item, shuffle: true) : null,
|
||||||
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
|
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
|
||||||
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
|
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
|
||||||
onNavigateLeft: widget.onNavigateLeft,
|
onNavigateLeft: widget.onNavigateLeft,
|
||||||
|
|||||||
@@ -216,6 +216,20 @@ abstract class BaseLibraryTabState<T, W extends BaseLibraryTab<T>> extends State
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Post-load bookkeeping for tabs that replace [loadItems] with their own
|
||||||
|
/// (paginated) fetch: mark the tab loaded, take focus if it's due, and let
|
||||||
|
/// the parent know once the frame carrying the items is in.
|
||||||
|
@protected
|
||||||
|
void markItemsLoaded() {
|
||||||
|
_hasLoadedData = true;
|
||||||
|
tryFocus();
|
||||||
|
if (widget.onDataLoaded != null) {
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (mounted) widget.onDataLoaded!();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether [focusFirstItem] has a real content target to focus.
|
/// Whether [focusFirstItem] has a real content target to focus.
|
||||||
@protected
|
@protected
|
||||||
bool get hasFocusableContent => _items.isNotEmpty;
|
bool get hasFocusableContent => _items.isNotEmpty;
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ import '../../../mixins/item_updatable.dart';
|
|||||||
import '../../../mixins/watch_state_aware.dart';
|
import '../../../mixins/watch_state_aware.dart';
|
||||||
import '../../../mixins/deletion_aware.dart';
|
import '../../../mixins/deletion_aware.dart';
|
||||||
import '../../../mixins/paginated_item_loader.dart';
|
import '../../../mixins/paginated_item_loader.dart';
|
||||||
|
import '../../../mixins/standard_paginated_view.dart';
|
||||||
import '../../../widgets/card_inflation_budget.dart';
|
import '../../../widgets/card_inflation_budget.dart';
|
||||||
import '../../../widgets/skeleton_media_card.dart';
|
import '../../../widgets/skeleton_media_card.dart';
|
||||||
import '../../../widgets/sliver_child_memo.dart';
|
import '../../../widgets/sliver_child_memo.dart';
|
||||||
@@ -104,13 +105,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
GridFocusNodeMixin,
|
GridFocusNodeMixin,
|
||||||
WatchStateAware,
|
WatchStateAware,
|
||||||
DeletionAware,
|
DeletionAware,
|
||||||
|
DeletionMirrorsWatchState,
|
||||||
PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
|
PaginatedItemLoader<MediaItem, LibraryBrowseTab>,
|
||||||
|
PaginatedItemUpdatable<LibraryBrowseTab>,
|
||||||
SkeletonUpgradeScheduler {
|
SkeletonUpgradeScheduler {
|
||||||
String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey);
|
String _toGlobalKey(String ratingKey, {required ServerId serverId}) => buildGlobalKey(serverId, ratingKey);
|
||||||
|
|
||||||
@override
|
// DeletionMirrorsWatchState points the deletion filters at these three: the
|
||||||
String? get deletionServerId => widget.library.serverId;
|
// grid shows the same loaded items for both event families.
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String? get watchStateServerId => widget.library.serverId;
|
String? get watchStateServerId => widget.library.serverId;
|
||||||
|
|
||||||
@@ -130,22 +132,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Set<String>? get deletionIds => loadedItems.values.map((e) => e.id).toSet();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<String>? get deletionGlobalKeys {
|
|
||||||
if (loadedItems.isEmpty) return <String>{};
|
|
||||||
|
|
||||||
final keys = <String>{};
|
|
||||||
for (final item in loadedItems.values) {
|
|
||||||
final serverId = serverIdOrNull(item.serverId ?? widget.library.serverId);
|
|
||||||
if (serverId == null) return null;
|
|
||||||
keys.add(_toGlobalKey(item.id, serverId: serverId));
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onWatchStateChanged(WatchStateEvent event) {
|
void onWatchStateChanged(WatchStateEvent event) {
|
||||||
if (event.changeType == WatchStateChangeType.progressUpdate ||
|
if (event.changeType == WatchStateChangeType.progressUpdate ||
|
||||||
@@ -213,16 +199,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
|
|||||||
@override
|
@override
|
||||||
int get itemCount => totalSize;
|
int get itemCount => totalSize;
|
||||||
|
|
||||||
@override
|
|
||||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedMetadata) {
|
|
||||||
for (final entry in loadedItems.entries) {
|
|
||||||
if (entry.value.globalKey == sourceGlobalKey) {
|
|
||||||
loadedItems[entry.key] = updatedMetadata;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Browse-specific state (not in base class)
|
// Browse-specific state (not in base class)
|
||||||
List<MediaFilter> _filters = [];
|
List<MediaFilter> _filters = [];
|
||||||
List<MediaSort> _sortOptions = [];
|
List<MediaSort> _sortOptions = [];
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import '../../../media/library_query.dart';
|
|||||||
import '../../../media/media_item.dart';
|
import '../../../media/media_item.dart';
|
||||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||||
import '../../../mixins/paginated_item_loader.dart';
|
import '../../../mixins/paginated_item_loader.dart';
|
||||||
|
import '../../../mixins/standard_paginated_view.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../utils/error_message_utils.dart';
|
import '../../../utils/error_message_utils.dart';
|
||||||
import '../../../utils/layout_constants.dart';
|
import '../../../utils/layout_constants.dart';
|
||||||
@@ -43,6 +44,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
|||||||
with
|
with
|
||||||
LibraryTabFocusMixin<LibraryCollectionsTab>,
|
LibraryTabFocusMixin<LibraryCollectionsTab>,
|
||||||
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
|
PaginatedItemLoader<MediaItem, LibraryCollectionsTab>,
|
||||||
|
StandardPaginatedView<MediaItem, LibraryCollectionsTab>,
|
||||||
SkeletonUpgradeScheduler {
|
SkeletonUpgradeScheduler {
|
||||||
static const int _pageSize = 36;
|
static const int _pageSize = 36;
|
||||||
|
|
||||||
@@ -78,35 +80,11 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> loadItems() async {
|
Future<void> loadItems() {
|
||||||
String? loadErrorMessage;
|
return loadStandardPaginatedItems(
|
||||||
await loadInitialPaginatedItems(
|
|
||||||
pageSize: _pageSize,
|
pageSize: _pageSize,
|
||||||
resetViewState: () {
|
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
|
||||||
isLoading = true;
|
onLoaded: (_, _) => markItemsLoaded(),
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import '../../../media/media_kind.dart';
|
|||||||
import '../../../media/media_playlist.dart';
|
import '../../../media/media_playlist.dart';
|
||||||
import '../../../mixins/library_tab_focus_mixin.dart';
|
import '../../../mixins/library_tab_focus_mixin.dart';
|
||||||
import '../../../mixins/paginated_item_loader.dart';
|
import '../../../mixins/paginated_item_loader.dart';
|
||||||
|
import '../../../mixins/standard_paginated_view.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../utils/error_message_utils.dart';
|
import '../../../utils/error_message_utils.dart';
|
||||||
import '../../../utils/layout_constants.dart';
|
import '../../../utils/layout_constants.dart';
|
||||||
@@ -45,6 +46,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
|||||||
with
|
with
|
||||||
LibraryTabFocusMixin<LibraryPlaylistsTab>,
|
LibraryTabFocusMixin<LibraryPlaylistsTab>,
|
||||||
PaginatedItemLoader<MediaPlaylist, LibraryPlaylistsTab>,
|
PaginatedItemLoader<MediaPlaylist, LibraryPlaylistsTab>,
|
||||||
|
StandardPaginatedView<MediaPlaylist, LibraryPlaylistsTab>,
|
||||||
SkeletonUpgradeScheduler {
|
SkeletonUpgradeScheduler {
|
||||||
static const int _pageSize = 200;
|
static const int _pageSize = 200;
|
||||||
|
|
||||||
@@ -84,35 +86,11 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<void> loadItems() async {
|
Future<void> loadItems() {
|
||||||
String? loadErrorMessage;
|
return loadStandardPaginatedItems(
|
||||||
await loadInitialPaginatedItems(
|
|
||||||
pageSize: _pageSize,
|
pageSize: _pageSize,
|
||||||
resetViewState: () {
|
errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext),
|
||||||
isLoading = true;
|
onLoaded: (_, _) => markItemsLoaded(),
|
||||||
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);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ import '../../../mixins/item_updatable.dart';
|
|||||||
import '../../../mixins/watch_state_aware.dart';
|
import '../../../mixins/watch_state_aware.dart';
|
||||||
import '../../../services/settings_service.dart';
|
import '../../../services/settings_service.dart';
|
||||||
import '../../../utils/deletion_notifier.dart';
|
import '../../../utils/deletion_notifier.dart';
|
||||||
import '../../../utils/global_key_utils.dart';
|
import '../../../utils/hub_icons.dart';
|
||||||
|
import '../../../utils/media_event_keys.dart';
|
||||||
import '../../../utils/platform_detector.dart';
|
import '../../../utils/platform_detector.dart';
|
||||||
import '../../../utils/provider_extensions.dart';
|
import '../../../utils/provider_extensions.dart';
|
||||||
import '../../../utils/watch_state_notifier.dart';
|
import '../../../utils/watch_state_notifier.dart';
|
||||||
@@ -46,7 +47,7 @@ class LibraryRecommendedTab extends BaseLibraryTab<MediaHub> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
|
class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryRecommendedTab>
|
||||||
with ItemUpdatable, WatchStateAware, DeletionAware {
|
with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState {
|
||||||
/// GlobalKeys for each hub section to enable vertical navigation
|
/// GlobalKeys for each hub section to enable vertical navigation
|
||||||
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
final List<GlobalKey<HubSectionState>> _hubKeys = [];
|
||||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||||
@@ -72,45 +73,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
|||||||
@override
|
@override
|
||||||
String? get watchStateServerId => widget.library.serverId;
|
String? get watchStateServerId => widget.library.serverId;
|
||||||
|
|
||||||
@override
|
/// Every item on screen, across all hubs.
|
||||||
String? get deletionServerId => widget.library.serverId;
|
Iterable<MediaItem> get _visibleItems => items.expand((hub) => hub.items);
|
||||||
|
|
||||||
// Deletion filtering needs the same id sets as watch state: each visible
|
// Deletion mirrors these via DeletionMirrorsWatchState: each visible item
|
||||||
// item plus its parents, so deleting a season/show also matches the
|
// plus its parents, so deleting a season/show also matches the episodes it
|
||||||
// episodes it contains here.
|
// contains here.
|
||||||
@override
|
@override
|
||||||
Set<String>? get deletionIds => watchedIds;
|
Set<String>? get watchedIds => hierarchicalEventIds(_visibleItems);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
|
Set<String>? get watchedGlobalKeys =>
|
||||||
|
hierarchicalEventGlobalKeys(_visibleItems, fallbackServerId: widget.library.serverId);
|
||||||
@override
|
|
||||||
Set<String>? get watchedIds {
|
|
||||||
final keys = <String>{};
|
|
||||||
for (final hub in items) {
|
|
||||||
for (final item in hub.items) {
|
|
||||||
keys.add(item.id);
|
|
||||||
if (item.parentId != null) keys.add(item.parentId!);
|
|
||||||
if (item.grandparentId != null) keys.add(item.grandparentId!);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<String>? get watchedGlobalKeys {
|
|
||||||
final keys = <String>{};
|
|
||||||
for (final hub in items) {
|
|
||||||
for (final item in hub.items) {
|
|
||||||
final serverId = item.serverId ?? widget.library.serverId;
|
|
||||||
if (serverId == null) return null;
|
|
||||||
keys.add(buildGlobalKey(ServerId(serverId), item.id));
|
|
||||||
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
|
|
||||||
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
|
||||||
@@ -316,7 +290,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
|||||||
key: index < _hubKeys.length ? _hubKeys[index] : null,
|
key: index < _hubKeys.length ? _hubKeys[index] : null,
|
||||||
hub: hub,
|
hub: hub,
|
||||||
focusMemory: _hubFocusMemory,
|
focusMemory: _hubFocusMemory,
|
||||||
icon: _getHubIcon(hub),
|
icon: hubIconFor(hub),
|
||||||
isInContinueWatching: isContinueWatching,
|
isInContinueWatching: isContinueWatching,
|
||||||
usesContinueWatchingAction: usesContinueWatchingAction,
|
usesContinueWatchingAction: usesContinueWatchingAction,
|
||||||
onRefresh: updateItem,
|
onRefresh: updateItem,
|
||||||
@@ -351,7 +325,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
|||||||
key: _tvBrowseRailKey,
|
key: _tvBrowseRailKey,
|
||||||
hubs: tvHubs,
|
hubs: tvHubs,
|
||||||
focusMemory: _hubFocusMemory,
|
focusMemory: _hubFocusMemory,
|
||||||
iconForHub: (hub, _) => _getHubIcon(hub),
|
iconForHub: (hub, _) => hubIconFor(hub),
|
||||||
onFocusedItemChanged: _setSpotlightItem,
|
onFocusedItemChanged: _setSpotlightItem,
|
||||||
onRefresh: updateItem,
|
onRefresh: updateItem,
|
||||||
onRemoveFromContinueWatching: _refreshContinueWatching,
|
onRemoveFromContinueWatching: _refreshContinueWatching,
|
||||||
@@ -371,24 +345,4 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState<MediaHub, LibraryR
|
|||||||
// Reload all data to refresh the continue watching section
|
// Reload all data to refresh the continue watching section
|
||||||
loadItems();
|
loadItems();
|
||||||
}
|
}
|
||||||
|
|
||||||
IconData _getHubIcon(MediaHub hub) {
|
|
||||||
final title = hub.title.toLowerCase();
|
|
||||||
if (title.contains('continue watching') || title.contains('on deck')) {
|
|
||||||
return Symbols.play_circle_rounded;
|
|
||||||
} else if (title.contains('recently') || title.contains('new')) {
|
|
||||||
return Symbols.fiber_new_rounded;
|
|
||||||
} else if (title.contains('popular') || title.contains('trending')) {
|
|
||||||
return Symbols.trending_up_rounded;
|
|
||||||
} else if (title.contains('top') || title.contains('rated')) {
|
|
||||||
return Symbols.star_rounded;
|
|
||||||
} else if (title.contains('recommended')) {
|
|
||||||
return Symbols.thumb_up_rounded;
|
|
||||||
} else if (title.contains('unwatched')) {
|
|
||||||
return Symbols.visibility_off_rounded;
|
|
||||||
} else if (title.contains('genre')) {
|
|
||||||
return Symbols.category_rounded;
|
|
||||||
}
|
|
||||||
return Symbols.movie_rounded;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -166,61 +166,56 @@ class _LiveTvScreenState extends State<LiveTvScreen>
|
|||||||
await _recordingsTabKey.currentState?.reload();
|
await _recordingsTabKey.currentState?.reload();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await _serverReloadGuide();
|
await _broadcastToDvrs(
|
||||||
|
actionLabel: 'Reload guide',
|
||||||
|
successMessage: t.liveTv.guideReloadRequested,
|
||||||
|
action: (dvr, serverInfo) => dvr.reloadGuide(serverInfo.dvrKey),
|
||||||
|
);
|
||||||
await _loadChannels();
|
await _loadChannels();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _serverReloadGuide() async {
|
/// Runs [action] on every DVR-capable Live TV server in parallel, then reports
|
||||||
|
/// [successMessage]. Per-DVR failures are non-fatal — 403 (admin only) and
|
||||||
|
/// transient errors are logged under [actionLabel] and swallowed, since
|
||||||
|
/// callers re-fetch their own client-side state regardless. Returns `true`
|
||||||
|
/// once at least one DVR was reached and this widget is still mounted.
|
||||||
|
Future<bool> _broadcastToDvrs({
|
||||||
|
required String actionLabel,
|
||||||
|
required String successMessage,
|
||||||
|
required Future<void> Function(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) action,
|
||||||
|
}) async {
|
||||||
final multiServer = context.read<MultiServerProvider>();
|
final multiServer = context.read<MultiServerProvider>();
|
||||||
|
Future<void> runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async {
|
||||||
|
try {
|
||||||
|
await action(dvr, serverInfo);
|
||||||
|
} catch (e) {
|
||||||
|
appLogger.d('$actionLabel failed for DVR ${serverInfo.dvrKey}: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final futures = <Future<void>>[];
|
final futures = <Future<void>>[];
|
||||||
for (final serverInfo in multiServer.liveTvServers) {
|
for (final serverInfo in multiServer.liveTvServers) {
|
||||||
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
|
final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr;
|
||||||
if (client == null || client.liveTvDvr == null) continue;
|
if (dvr == null) continue;
|
||||||
futures.add(_reloadGuideSafe(client, serverInfo.dvrKey));
|
futures.add(runSafely(dvr, serverInfo));
|
||||||
}
|
}
|
||||||
if (futures.isEmpty) return;
|
if (futures.isEmpty) return false;
|
||||||
await Future.wait(futures);
|
await Future.wait(futures);
|
||||||
if (!mounted) return;
|
if (!mounted) return false;
|
||||||
showSnackBar(context, t.liveTv.guideReloadRequested);
|
showSnackBar(context, successMessage);
|
||||||
}
|
return true;
|
||||||
|
|
||||||
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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _processRecordingRules() async {
|
Future<void> _processRecordingRules() async {
|
||||||
final multiServer = context.read<MultiServerProvider>();
|
final reached = await _broadcastToDvrs(
|
||||||
final futures = <Future<void>>[];
|
actionLabel: 'processRecordingRules',
|
||||||
for (final serverInfo in multiServer.liveTvServers) {
|
successMessage: t.liveTv.rulesProcessRequested,
|
||||||
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId));
|
action: (dvr, _) => dvr.processRecordingRules(),
|
||||||
if (client == null || client.liveTvDvr == null) continue;
|
);
|
||||||
futures.add(_processRulesSafe(client));
|
if (!reached) return;
|
||||||
}
|
|
||||||
if (futures.isEmpty) return;
|
|
||||||
await Future.wait(futures);
|
|
||||||
if (!mounted) return;
|
|
||||||
showSnackBar(context, t.liveTv.rulesProcessRequested);
|
|
||||||
await _recordingsTabKey.currentState?.reload();
|
await _recordingsTabKey.currentState?.reload();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _processRulesSafe(MediaServerClient client) async {
|
|
||||||
try {
|
|
||||||
final dvr = client.liveTvDvr;
|
|
||||||
if (dvr == null) return;
|
|
||||||
await dvr.processRecordingRules();
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.d('processRecordingRules failed: $e');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Recompute visible tabs from the current MultiServerProvider state.
|
/// Recompute visible tabs from the current MultiServerProvider state.
|
||||||
/// Re-inits the tab controller when the visible set changes (matches the
|
/// Re-inits the tab controller when the visible set changes (matches the
|
||||||
/// libraries-screen pattern at libraries_screen.dart:365).
|
/// libraries-screen pattern at libraries_screen.dart:365).
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../media/ids.dart';
|
import '../../media/ids.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../focus/dpad_navigator.dart';
|
import '../../focus/dpad_reorder_mixin.dart';
|
||||||
import '../../focus/focus_theme.dart';
|
import '../../focus/focus_theme.dart';
|
||||||
import '../../focus/input_mode_tracker.dart';
|
import '../../focus/input_mode_tracker.dart';
|
||||||
import '../../focus/key_event_utils.dart';
|
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../models/livetv_channel.dart';
|
import '../../models/livetv_channel.dart';
|
||||||
import '../../providers/multi_server_provider.dart';
|
import '../../providers/multi_server_provider.dart';
|
||||||
@@ -34,18 +32,33 @@ class ReorderFavoritesSheet extends StatefulWidget {
|
|||||||
State<ReorderFavoritesSheet> createState() => _ReorderFavoritesSheetState();
|
State<ReorderFavoritesSheet> createState() => _ReorderFavoritesSheetState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet>
|
||||||
|
with DpadReorderListMixin<FavoriteChannel, ReorderFavoritesSheet> {
|
||||||
late List<FavoriteChannel> _tempFavorites;
|
late List<FavoriteChannel> _tempFavorites;
|
||||||
|
|
||||||
// Keyboard navigation state
|
|
||||||
int _focusedIndex = 0;
|
|
||||||
int _focusedColumn = 0; // 0 = row, 1 = remove button
|
|
||||||
int? _movingIndex;
|
|
||||||
int? _originalIndex;
|
|
||||||
List<FavoriteChannel>? _originalOrder;
|
|
||||||
final FocusNode _listFocusNode = FocusNode();
|
final FocusNode _listFocusNode = FocusNode();
|
||||||
final ScrollController _scrollController = ScrollController();
|
final ScrollController _scrollController = ScrollController();
|
||||||
bool _backKeyDownSeen = false;
|
|
||||||
|
// Keyboard navigation: column 0 = row, column 1 = remove button.
|
||||||
|
@override
|
||||||
|
List<FavoriteChannel> get reorderItems => _tempFavorites;
|
||||||
|
|
||||||
|
@override
|
||||||
|
set reorderItems(List<FavoriteChannel> value) => _tempFavorites = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get lastReorderColumn => 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ScrollController? get reorderScrollController => _scrollController;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onReorderMoveConfirmed() => widget.onReorder(_tempFavorites);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onReorderColumnActivated(int column, int index) {
|
||||||
|
if (column == 1) _removeItem(index);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@@ -60,139 +73,6 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _ensureFocusedVisible() {
|
|
||||||
if (!_scrollController.hasClients) return;
|
|
||||||
|
|
||||||
const double itemHeight = 72.0;
|
|
||||||
const double listTopPadding = 8.0;
|
|
||||||
final double targetTop = listTopPadding + (_focusedIndex * itemHeight);
|
|
||||||
final double targetBottom = targetTop + itemHeight;
|
|
||||||
|
|
||||||
final double viewportTop = _scrollController.offset;
|
|
||||||
final double viewportHeight = _scrollController.position.viewportDimension;
|
|
||||||
final double viewportBottom = viewportTop + viewportHeight;
|
|
||||||
|
|
||||||
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
|
||||||
|
|
||||||
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
|
||||||
0.0,
|
|
||||||
_scrollController.position.maxScrollExtent,
|
|
||||||
);
|
|
||||||
|
|
||||||
_scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
|
|
||||||
}
|
|
||||||
|
|
||||||
KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
|
|
||||||
final key = event.logicalKey;
|
|
||||||
|
|
||||||
if (key.isBackKey) {
|
|
||||||
if (event is KeyDownEvent) {
|
|
||||||
_backKeyDownSeen = true;
|
|
||||||
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (event is KeyUpEvent) {
|
|
||||||
_backKeyDownSeen = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
final backResult = handleBackKeyAction(event, () {
|
|
||||||
if (_movingIndex != null) {
|
|
||||||
setState(() {
|
|
||||||
if (_originalOrder != null) {
|
|
||||||
_tempFavorites = List.from(_originalOrder!);
|
|
||||||
}
|
|
||||||
_focusedIndex = _originalIndex ?? 0;
|
|
||||||
_movingIndex = null;
|
|
||||||
_originalIndex = null;
|
|
||||||
_originalOrder = null;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
OverlaySheetController.popAdaptive(context);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (backResult != KeyEventResult.ignored) {
|
|
||||||
return backResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!event.isActionable) return KeyEventResult.ignored;
|
|
||||||
|
|
||||||
if (_movingIndex != null) {
|
|
||||||
if (key.isUpKey && _movingIndex! > 0) {
|
|
||||||
setState(() {
|
|
||||||
final item = _tempFavorites.removeAt(_movingIndex!);
|
|
||||||
_tempFavorites.insert(_movingIndex! - 1, item);
|
|
||||||
_movingIndex = _movingIndex! - 1;
|
|
||||||
_focusedIndex = _movingIndex!;
|
|
||||||
});
|
|
||||||
_ensureFocusedVisible();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (key.isDownKey && _movingIndex! < _tempFavorites.length - 1) {
|
|
||||||
setState(() {
|
|
||||||
final item = _tempFavorites.removeAt(_movingIndex!);
|
|
||||||
_tempFavorites.insert(_movingIndex! + 1, item);
|
|
||||||
_movingIndex = _movingIndex! + 1;
|
|
||||||
_focusedIndex = _movingIndex!;
|
|
||||||
});
|
|
||||||
_ensureFocusedVisible();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (key.isSelectKey) {
|
|
||||||
widget.onReorder(_tempFavorites);
|
|
||||||
setState(() {
|
|
||||||
_movingIndex = null;
|
|
||||||
_originalIndex = null;
|
|
||||||
_originalOrder = null;
|
|
||||||
});
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (key.isUpKey && _focusedIndex > 0) {
|
|
||||||
setState(() {
|
|
||||||
_focusedIndex--;
|
|
||||||
_focusedColumn = 0;
|
|
||||||
});
|
|
||||||
_ensureFocusedVisible();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (key.isDownKey && _focusedIndex < _tempFavorites.length - 1) {
|
|
||||||
setState(() {
|
|
||||||
_focusedIndex++;
|
|
||||||
_focusedColumn = 0;
|
|
||||||
});
|
|
||||||
_ensureFocusedVisible();
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (key.isLeftKey && _focusedColumn > 0) {
|
|
||||||
setState(() => _focusedColumn--);
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (key.isRightKey && _focusedColumn < 1) {
|
|
||||||
setState(() => _focusedColumn++);
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
if (key.isSelectKey) {
|
|
||||||
if (_focusedColumn == 0) {
|
|
||||||
setState(() {
|
|
||||||
_movingIndex = _focusedIndex;
|
|
||||||
_originalIndex = _focusedIndex;
|
|
||||||
_originalOrder = List.from(_tempFavorites);
|
|
||||||
});
|
|
||||||
} else if (_focusedColumn == 1) {
|
|
||||||
_removeItem(_focusedIndex);
|
|
||||||
}
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (key.isDpadDirection) {
|
|
||||||
return KeyEventResult.handled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return KeyEventResult.ignored;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onReorder(int oldIndex, int newIndex) {
|
void _onReorder(int oldIndex, int newIndex) {
|
||||||
setState(() {
|
setState(() {
|
||||||
final item = _tempFavorites.removeAt(oldIndex);
|
final item = _tempFavorites.removeAt(oldIndex);
|
||||||
@@ -205,8 +85,8 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
|||||||
final removed = _tempFavorites[index];
|
final removed = _tempFavorites[index];
|
||||||
setState(() {
|
setState(() {
|
||||||
_tempFavorites.removeAt(index);
|
_tempFavorites.removeAt(index);
|
||||||
if (_focusedIndex >= _tempFavorites.length) {
|
if (focusedIndex >= _tempFavorites.length) {
|
||||||
_focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length);
|
focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
widget.onRemove(removed);
|
widget.onRemove(removed);
|
||||||
@@ -229,7 +109,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
|||||||
focusNode: _listFocusNode,
|
focusNode: _listFocusNode,
|
||||||
descendantsAreFocusable: false,
|
descendantsAreFocusable: false,
|
||||||
autofocus: isKeyboardMode,
|
autofocus: isKeyboardMode,
|
||||||
onKeyEvent: _handleKeyEvent,
|
onKeyEvent: handleReorderKeyEvent,
|
||||||
child: ReorderableListView.builder(
|
child: ReorderableListView.builder(
|
||||||
scrollController: _scrollController,
|
scrollController: _scrollController,
|
||||||
onReorderItem: _onReorder,
|
onReorderItem: _onReorder,
|
||||||
@@ -239,8 +119,8 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
|||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
final fav = _tempFavorites[index];
|
final fav = _tempFavorites[index];
|
||||||
final channel = widget.channelMap[fav.stableKey];
|
final channel = widget.channelMap[fav.stableKey];
|
||||||
final isFocused = isKeyboardMode && index == _focusedIndex;
|
final isFocused = isKeyboardMode && index == focusedIndex;
|
||||||
final isMoving = index == _movingIndex;
|
final isMoving = index == movingIndex;
|
||||||
|
|
||||||
return _buildFavoriteTile(
|
return _buildFavoriteTile(
|
||||||
key: ValueKey(fav.stableKey),
|
key: ValueKey(fav.stableKey),
|
||||||
@@ -249,7 +129,7 @@ class _ReorderFavoritesSheetState extends State<ReorderFavoritesSheet> {
|
|||||||
index: index,
|
index: index,
|
||||||
isFocused: isFocused,
|
isFocused: isFocused,
|
||||||
isMoving: isMoving,
|
isMoving: isMoving,
|
||||||
focusedColumn: isFocused ? _focusedColumn : null,
|
focusedColumn: isFocused ? focusedColumn : null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
+87
-142
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../media/ids.dart';
|
import '../media/ids.dart';
|
||||||
|
import '../media/media_server_client.dart';
|
||||||
import '../navigation/main_screen_scope.dart';
|
import '../navigation/main_screen_scope.dart';
|
||||||
import 'dart:io' show Platform, exit;
|
import 'dart:io' show Platform, exit;
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ import '../profiles/active_profile_binder.dart';
|
|||||||
import '../connection/connection_registry.dart';
|
import '../connection/connection_registry.dart';
|
||||||
import '../profiles/active_profile_provider.dart';
|
import '../profiles/active_profile_provider.dart';
|
||||||
import '../profiles/plex_home_service.dart';
|
import '../profiles/plex_home_service.dart';
|
||||||
|
import '../profiles/profile_selection_policy.dart';
|
||||||
import '../providers/catalog_sources_provider.dart';
|
import '../providers/catalog_sources_provider.dart';
|
||||||
import '../providers/download_provider.dart';
|
import '../providers/download_provider.dart';
|
||||||
import '../providers/multi_server_provider.dart';
|
import '../providers/multi_server_provider.dart';
|
||||||
@@ -233,13 +235,12 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
bool _isShowingProfileSelection = false;
|
bool _isShowingProfileSelection = false;
|
||||||
|
|
||||||
late List<Widget> _screens;
|
late List<Widget> _screens;
|
||||||
final GlobalKey<State<DiscoverScreen>> _discoverKey = GlobalKey();
|
|
||||||
final GlobalKey<State<ExploreScreen>> _exploreKey = GlobalKey();
|
/// One [GlobalKey] per tab, so a tab's live [State] can be reached from
|
||||||
final GlobalKey<State<LibrariesScreen>> _librariesKey = GlobalKey();
|
/// anywhere in this class via [_onScreen]. Deliberately untyped: every
|
||||||
final GlobalKey<State<LiveTvScreen>> _liveTvKey = GlobalKey();
|
/// consumer discards the concrete `State<X>` type and pattern-matches on a
|
||||||
final GlobalKey<State<SearchScreen>> _searchKey = GlobalKey();
|
/// capability mixin (Refreshable, FocusableTab, …) instead.
|
||||||
final GlobalKey<State<DownloadsScreen>> _downloadsKey = GlobalKey();
|
final Map<NavigationTabId, GlobalKey> _screenKeys = {for (final id in NavigationTabId.values) id: GlobalKey()};
|
||||||
final GlobalKey<State<SettingsScreen>> _settingsKey = GlobalKey();
|
|
||||||
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
|
final GlobalKey<SideNavigationRailState> _sideNavKey = GlobalKey();
|
||||||
|
|
||||||
/// Measures the mobile bottom navigation area for the music mini-player.
|
/// Measures the mobile bottom navigation area for the music mini-player.
|
||||||
@@ -441,23 +442,11 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void tryDownloadResume() {
|
void tryDownloadResume() {
|
||||||
if (_downloadResumeFired || !mounted) return;
|
|
||||||
// Wait for any online client before firing the resume — the download
|
// Wait for any online client before firing the resume — the download
|
||||||
// pipeline is backend-neutral (resumeQueuedDownloads accepts a
|
// pipeline is backend-neutral (resumeQueuedDownloads accepts a
|
||||||
// MediaServerClient and per-item resolution picks up the right
|
// MediaServerClient and per-item resolution picks up the right
|
||||||
// backend), so a Jellyfin-only setup can resume too.
|
// backend), so a Jellyfin-only setup can resume too.
|
||||||
final onlineClient = manager.onlineClients.values.firstOrNull;
|
_resumeQueuedDownloadsOnce(manager.onlineClients.values.firstOrNull);
|
||||||
if (onlineClient == null) return;
|
|
||||||
_downloadResumeFired = true;
|
|
||||||
_serverStatusSub?.cancel();
|
|
||||||
_serverStatusSub = null;
|
|
||||||
final downloadProvider = context.read<DownloadProvider>();
|
|
||||||
unawaited(
|
|
||||||
downloadProvider.ensureInitialized().then((_) {
|
|
||||||
if (!mounted) return;
|
|
||||||
downloadProvider.resumeQueuedDownloads(onlineClient);
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Listen for binding-settle so the once-only priming runs after both
|
// Listen for binding-settle so the once-only priming runs after both
|
||||||
@@ -495,36 +484,35 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
context.read<OfflineWatchSyncService>().onServersConnected();
|
context.read<OfflineWatchSyncService>().onServersConnected();
|
||||||
unawaited(context.read<DownloadProvider>().refreshMetadataFromCache());
|
unawaited(context.read<DownloadProvider>().refreshMetadataFromCache());
|
||||||
_resumeQueuedDownloadsIfPossible(mp);
|
_resumeQueuedDownloadsOnce(
|
||||||
|
mp.onlineServerIds.map((id) => mp.getClientForServer(ServerId(id))).nonNulls.firstOrNull,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (_discoverKey.currentState case final FullRefreshable refreshable) {
|
_fullRefreshContentTabs();
|
||||||
refreshable.fullRefresh();
|
|
||||||
}
|
|
||||||
if (_librariesKey.currentState case final FullRefreshable refreshable) {
|
|
||||||
refreshable.fullRefresh();
|
|
||||||
}
|
|
||||||
if (_searchKey.currentState case final FullRefreshable refreshable) {
|
|
||||||
refreshable.fullRefresh();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _resumeQueuedDownloadsIfPossible(MultiServerProvider mp) {
|
/// Single-shot "resume queued downloads once any client is online" rule,
|
||||||
|
/// shared by the startup status-stream path and [_primeOnlineServices] —
|
||||||
|
/// each caller resolves its own candidate client (unfiltered manager view
|
||||||
|
/// vs the visibility-filtered provider) and hands it here. No-op once the
|
||||||
|
/// resume has fired, or while no client is online yet.
|
||||||
|
void _resumeQueuedDownloadsOnce(MediaServerClient? onlineClient) {
|
||||||
if (_downloadResumeFired || !mounted) return;
|
if (_downloadResumeFired || !mounted) return;
|
||||||
for (final serverId in mp.onlineServerIds) {
|
if (onlineClient == null) return;
|
||||||
final onlineClient = mp.getClientForServer(ServerId(serverId));
|
_downloadResumeFired = true;
|
||||||
if (onlineClient == null) continue;
|
// The status subscription exists only to drive this one-shot.
|
||||||
_downloadResumeFired = true;
|
_serverStatusSub?.cancel();
|
||||||
unawaited(
|
_serverStatusSub = null;
|
||||||
context.read<DownloadProvider>().ensureInitialized().then((_) {
|
final downloadProvider = context.read<DownloadProvider>();
|
||||||
if (!mounted) return;
|
unawaited(
|
||||||
context.read<DownloadProvider>().resumeQueuedDownloads(onlineClient);
|
downloadProvider.ensureInitialized().then((_) {
|
||||||
}),
|
if (!mounted) return;
|
||||||
);
|
downloadProvider.resumeQueuedDownloads(onlineClient);
|
||||||
return;
|
}),
|
||||||
}
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onActiveProfileChanged() {
|
void _onActiveProfileChanged() {
|
||||||
@@ -594,11 +582,15 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
// has no profile to bind, and the user lands on an empty screen with
|
// has no profile to bind, and the user lands on an empty screen with
|
||||||
// no way back to the picker.
|
// no way back to the picker.
|
||||||
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty;
|
||||||
final requireOnOpen =
|
|
||||||
settingsService.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles;
|
|
||||||
|
|
||||||
if (!hasNoActive && !requireOnOpen) return;
|
if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return;
|
||||||
|
|
||||||
|
await _pushProfileSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Push the picker in "must choose" mode, suppressing the tvOS menu-button
|
||||||
|
/// passthrough for as long as it is up.
|
||||||
|
Future<void> _pushProfileSelection() async {
|
||||||
_isShowingProfileSelection = true;
|
_isShowingProfileSelection = true;
|
||||||
_setTvosMenuPassthrough(false);
|
_setTvosMenuPassthrough(false);
|
||||||
await Navigator.of(
|
await Navigator.of(
|
||||||
@@ -838,9 +830,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
_selectTab(NavigationTabId.search, focusSearchInput: !hasQuery);
|
_selectTab(NavigationTabId.search, focusSearchInput: !hasQuery);
|
||||||
if (hasQuery) {
|
if (hasQuery) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
_onScreen<SearchInputFocusable>(NavigationTabId.search, (screen) => screen.submitSearchQuery(trimmed));
|
||||||
searchable.submitSearchQuery(trimmed);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -925,21 +915,11 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
Future<void> _showProfileSelectionOnResume() async {
|
Future<void> _showProfileSelectionOnResume() async {
|
||||||
final settingsService = await SettingsService.getInstance();
|
final settingsService = await SettingsService.getInstance();
|
||||||
if (!settingsService.read(SettingsService.requireProfileSelectionOnOpen)) return;
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
|
|
||||||
final activeProfile = context.read<ActiveProfileProvider>();
|
if (!context.read<ActiveProfileProvider>().requiresSelectionOnOpen(settingsService)) return;
|
||||||
if (!activeProfile.hasMultipleProfiles) return;
|
|
||||||
|
|
||||||
_isShowingProfileSelection = true;
|
await _pushProfileSelection();
|
||||||
_setTvosMenuPassthrough(false);
|
|
||||||
await Navigator.of(
|
|
||||||
context,
|
|
||||||
rootNavigator: true,
|
|
||||||
).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true)));
|
|
||||||
if (!mounted) return;
|
|
||||||
_isShowingProfileSelection = false;
|
|
||||||
_updateTvosMenuPassthrough();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// IndexedStack that disables tickers for offscreen children to prevent
|
/// IndexedStack that disables tickers for offscreen children to prevent
|
||||||
@@ -965,17 +945,17 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
return [
|
return [
|
||||||
for (final tab in _getVisibleTabs(offline))
|
for (final tab in _getVisibleTabs(offline))
|
||||||
switch (tab.id) {
|
switch (tab.id) {
|
||||||
NavigationTabId.discover => DiscoverScreen(key: _discoverKey),
|
NavigationTabId.discover => DiscoverScreen(key: _screenKeys[tab.id]),
|
||||||
NavigationTabId.explore => ExploreScreen(key: _exploreKey),
|
NavigationTabId.explore => ExploreScreen(key: _screenKeys[tab.id]),
|
||||||
NavigationTabId.libraries => LibrariesScreen(
|
NavigationTabId.libraries => LibrariesScreen(
|
||||||
key: _librariesKey,
|
key: _screenKeys[tab.id],
|
||||||
onLibraryOrderChanged: _onLibraryOrderChanged,
|
onLibraryOrderChanged: _onLibraryOrderChanged,
|
||||||
onLibrarySelected: _handleLibrariesScreenSelected,
|
onLibrarySelected: _handleLibrariesScreenSelected,
|
||||||
),
|
),
|
||||||
NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey),
|
NavigationTabId.liveTv => LiveTvScreen(key: _screenKeys[tab.id]),
|
||||||
NavigationTabId.search => SearchScreen(key: _searchKey),
|
NavigationTabId.search => SearchScreen(key: _screenKeys[tab.id]),
|
||||||
NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey),
|
NavigationTabId.downloads => DownloadsScreen(key: _screenKeys[tab.id]),
|
||||||
NavigationTabId.settings => SettingsScreen(key: _settingsKey),
|
NavigationTabId.settings => SettingsScreen(key: _screenKeys[tab.id]),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -1030,16 +1010,22 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
}());
|
}());
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleLiveTvChanged() {
|
/// Rebuilds navigation after a tab's availability flipped: _currentTab may
|
||||||
final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false;
|
/// need normalizing, and passthrough depends on it being the first tab.
|
||||||
if (hasLiveTv == _lastHasLiveTv) return;
|
void _handleTabAvailabilityChanged() {
|
||||||
_lastHasLiveTv = hasLiveTv;
|
|
||||||
|
|
||||||
setState(() {
|
setState(() {
|
||||||
_screens = _buildScreens(_isOffline);
|
_screens = _buildScreens(_isOffline);
|
||||||
_currentTab = _normalizeTabForMode(_currentTab, _isOffline);
|
_currentTab = _normalizeTabForMode(_currentTab, _isOffline);
|
||||||
});
|
});
|
||||||
_updateTvosMenuPassthrough();
|
_updateTvosMenuPassthrough();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleLiveTvChanged() {
|
||||||
|
final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false;
|
||||||
|
if (hasLiveTv == _lastHasLiveTv) return;
|
||||||
|
_lastHasLiveTv = hasLiveTv;
|
||||||
|
|
||||||
|
_handleTabAvailabilityChanged();
|
||||||
|
|
||||||
// A preferred startup section (only Live TV can be deferred) just became
|
// A preferred startup section (only Live TV can be deferred) just became
|
||||||
// available — switch to it via _selectTab so it gets the usual visibility
|
// available — switch to it via _selectTab so it gets the usual visibility
|
||||||
@@ -1055,13 +1041,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
if (hasExplore == _lastHasExplore) return;
|
if (hasExplore == _lastHasExplore) return;
|
||||||
_lastHasExplore = hasExplore;
|
_lastHasExplore = hasExplore;
|
||||||
|
|
||||||
setState(() {
|
_handleTabAvailabilityChanged();
|
||||||
_screens = _buildScreens(_isOffline);
|
|
||||||
_currentTab = _normalizeTabForMode(_currentTab, _isOffline);
|
|
||||||
});
|
|
||||||
// Same as the live-TV handler: the passthrough flag depends on whether
|
|
||||||
// _currentTab is the first tab, which the normalize above can change.
|
|
||||||
_updateTvosMenuPassthrough();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleOfflineStatusChanged() {
|
void _handleOfflineStatusChanged() {
|
||||||
@@ -1154,17 +1134,8 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
// This preserves the user's focus position when returning from sidebar.
|
// This preserves the user's focus position when returning from sidebar.
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
if (restorePreviousFocus) {
|
if (restorePreviousFocus && _contentFocusScope.focusedChild != null) return;
|
||||||
if (_contentFocusScope.focusedChild == null) {
|
_onScreen<FocusableTab>(_currentTab, (screen) => screen.focusActiveTabIfReady());
|
||||||
if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) {
|
|
||||||
focusable.focusActiveTabIfReady();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) {
|
|
||||||
focusable.focusActiveTabIfReady();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1363,9 +1334,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
if (_isSidebarFocused) _focusContent();
|
if (_isSidebarFocused) _focusContent();
|
||||||
// Schedule focus after the frame so the search screen is visible in the IndexedStack
|
// Schedule focus after the frame so the search screen is visible in the IndexedStack
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
_onScreen<SearchInputFocusable>(NavigationTabId.search, (screen) => screen.focusSearchInput());
|
||||||
searchable.focusSearchInput();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
@@ -1386,9 +1355,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
_miniPlayerInsets?.setNavBarSuspended(true);
|
_miniPlayerInsets?.setNavBarSuspended(true);
|
||||||
// Called when a child route is pushed on top (e.g., video player)
|
// Called when a child route is pushed on top (e.g., video player)
|
||||||
if (_currentTab == NavigationTabId.discover) {
|
if (_currentTab == NavigationTabId.discover) {
|
||||||
if (_discoverKey.currentState case final TabVisibilityAware aware) {
|
_onScreen<TabVisibilityAware>(NavigationTabId.discover, (screen) => screen.onTabHidden());
|
||||||
aware.onTabHidden();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1407,9 +1374,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
_updateTvosMenuPassthrough();
|
_updateTvosMenuPassthrough();
|
||||||
_miniPlayerInsets?.setNavBarSuspended(false);
|
_miniPlayerInsets?.setNavBarSuspended(false);
|
||||||
if (_currentTab == NavigationTabId.discover) {
|
if (_currentTab == NavigationTabId.discover) {
|
||||||
if (_discoverKey.currentState case final TabVisibilityAware aware) {
|
_onScreen<TabVisibilityAware>(NavigationTabId.discover, (screen) => screen.onTabShown());
|
||||||
aware.onTabShown();
|
|
||||||
}
|
|
||||||
_onDiscoverBecameVisible();
|
_onDiscoverBecameVisible();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1417,9 +1382,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
void _onDiscoverBecameVisible() {
|
void _onDiscoverBecameVisible() {
|
||||||
appLogger.d('Navigated to home');
|
appLogger.d('Navigated to home');
|
||||||
// Refresh content when returning to discover page
|
// Refresh content when returning to discover page
|
||||||
if (_discoverKey.currentState case final Refreshable refreshable) {
|
_onScreen<Refreshable>(NavigationTabId.discover, (screen) => screen.refresh());
|
||||||
refreshable.refresh();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onLibraryOrderChanged() {
|
void _onLibraryOrderChanged() {
|
||||||
@@ -1464,15 +1427,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
playbackStateProvider.clearShuffle();
|
playbackStateProvider.clearShuffle();
|
||||||
|
|
||||||
if (_discoverKey.currentState case final FullRefreshable refreshable) {
|
_fullRefreshContentTabs();
|
||||||
refreshable.fullRefresh();
|
|
||||||
}
|
|
||||||
if (_librariesKey.currentState case final FullRefreshable refreshable) {
|
|
||||||
refreshable.fullRefresh();
|
|
||||||
}
|
|
||||||
if (_searchKey.currentState case final FullRefreshable refreshable) {
|
|
||||||
refreshable.fullRefresh();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh user-level settings (audio/sub defaults) for the new identity.
|
// Refresh user-level settings (audio/sub defaults) for the new identity.
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
@@ -1500,14 +1455,9 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
|
|
||||||
if (previousTab != tab) {
|
if (previousTab != tab) {
|
||||||
// Notify previous screen it's being hidden
|
// Notify previous screen it's being hidden
|
||||||
if (_screenKeyFor(previousTab)?.currentState case final TabVisibilityAware aware) {
|
_onScreen<TabVisibilityAware>(previousTab, (screen) => screen.onTabHidden());
|
||||||
aware.onTabHidden();
|
|
||||||
}
|
|
||||||
// Notify and focus new screen
|
// Notify and focus new screen
|
||||||
final newState = _screenKeyFor(tab)?.currentState;
|
_onScreen<TabVisibilityAware>(tab, (screen) => screen.onTabShown());
|
||||||
if (newState case final TabVisibilityAware aware) {
|
|
||||||
aware.onTabShown();
|
|
||||||
}
|
|
||||||
// Back-to-home keeps the sidebar focused (chain: content → sidebar →
|
// Back-to-home keeps the sidebar focused (chain: content → sidebar →
|
||||||
// home → exit); stealing focus here left _isSidebarFocused stuck true
|
// home → exit); stealing focus here left _isSidebarFocused stuck true
|
||||||
// while real focus sat on a content card (#1411).
|
// while real focus sat on a content card (#1411).
|
||||||
@@ -1515,9 +1465,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
// search input, since focusing it auto-opens the on-screen keyboard; the
|
// search input, since focusing it auto-opens the on-screen keyboard; the
|
||||||
// query submit focuses results instead.
|
// query submit focuses results instead.
|
||||||
if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) {
|
if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) {
|
||||||
if (newState case final FocusableTab focusable) {
|
_onScreen<FocusableTab>(tab, (screen) => screen.focusActiveTabIfReady());
|
||||||
focusable.focusActiveTabIfReady();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1531,9 +1479,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
// submit runs the search and focuses results without opening the keyboard.
|
// submit runs the search and focuses results without opening the keyboard.
|
||||||
if (tab == NavigationTabId.search && focusSearchInput) {
|
if (tab == NavigationTabId.search && focusSearchInput) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (_searchKey.currentState case final SearchInputFocusable searchable) {
|
_onScreen<SearchInputFocusable>(NavigationTabId.search, (screen) => screen.focusSearchInput());
|
||||||
searchable.focusSearchInput();
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1543,12 +1489,8 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
_selectedLibraryGlobalKey = libraryGlobalKey;
|
_selectedLibraryGlobalKey = libraryGlobalKey;
|
||||||
_selectTab(NavigationTabId.libraries);
|
_selectTab(NavigationTabId.libraries);
|
||||||
// Tell LibrariesScreen to load this library after tab switch
|
// Tell LibrariesScreen to load this library after tab switch
|
||||||
if (_librariesKey.currentState case final LibraryLoadable loadable) {
|
_onScreen<LibraryLoadable>(NavigationTabId.libraries, (screen) => screen.loadLibraryByKey(libraryGlobalKey));
|
||||||
loadable.loadLibraryByKey(libraryGlobalKey);
|
_onScreen<FocusableTab>(NavigationTabId.libraries, (screen) => screen.focusActiveTabIfReady());
|
||||||
}
|
|
||||||
if (_librariesKey.currentState case final FocusableTab focusable) {
|
|
||||||
focusable.focusActiveTabIfReady();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openSettings() {
|
void _openSettings() {
|
||||||
@@ -1637,17 +1579,20 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the GlobalKey for a given tab.
|
/// Invoke [fn] on the tab's current [State] when it exists and implements
|
||||||
GlobalKey? _screenKeyFor(NavigationTabId tab) {
|
/// the capability [T]. Screens are only built for visible tabs and mount a
|
||||||
return switch (tab) {
|
/// frame later, so a missing key or a non-matching state is a no-op.
|
||||||
NavigationTabId.discover => _discoverKey,
|
void _onScreen<T>(NavigationTabId tab, void Function(T state) fn) {
|
||||||
NavigationTabId.explore => _exploreKey,
|
if (_screenKeys[tab]?.currentState case final T state) fn(state);
|
||||||
NavigationTabId.libraries => _librariesKey,
|
}
|
||||||
NavigationTabId.liveTv => _liveTvKey,
|
|
||||||
NavigationTabId.search => _searchKey,
|
/// Full-refresh the primary content tabs. Shared by the online-entry hook
|
||||||
NavigationTabId.downloads => _downloadsKey,
|
/// ([_primeOnlineServices]) and the profile-switch invalidation
|
||||||
NavigationTabId.settings => _settingsKey,
|
/// ([_invalidateAllScreens]), which refresh the same set.
|
||||||
};
|
void _fullRefreshContentTabs() {
|
||||||
|
for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) {
|
||||||
|
_onScreen<FullRefreshable>(tab, (screen) => screen.fullRefresh());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) {
|
Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) {
|
||||||
|
|||||||
@@ -267,7 +267,13 @@ PageRoute<bool> mediaDetailRoute({
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MediaDetailScreenState extends State<MediaDetailScreen>
|
class _MediaDetailScreenState extends State<MediaDetailScreen>
|
||||||
with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware {
|
with
|
||||||
|
WatchStateAware,
|
||||||
|
DeletionAware,
|
||||||
|
DeletionMirrorsWatchState,
|
||||||
|
MountedSetStateMixin,
|
||||||
|
ServerBoundMediaMixin,
|
||||||
|
RouteAware {
|
||||||
/// Public input alias — used as the live source of truth until the detail
|
/// Public input alias — used as the live source of truth until the detail
|
||||||
/// fetch returns. Holds backend-neutral [MediaItem] data.
|
/// fetch returns. Holds backend-neutral [MediaItem] data.
|
||||||
MediaItem get _metadata => _fullMetadata ?? widget.metadata;
|
MediaItem get _metadata => _fullMetadata ?? widget.metadata;
|
||||||
@@ -393,7 +399,9 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
@override
|
@override
|
||||||
bool get isServerBoundOffline => widget.isOffline;
|
bool get isServerBoundOffline => widget.isOffline;
|
||||||
|
|
||||||
// WatchStateAware: watch the show/movie and all season/episode ratingKeys
|
// WatchStateAware: watch the show/movie and all season/episode ratingKeys.
|
||||||
|
// DeletionMirrorsWatchState reuses these three getters for deletion events —
|
||||||
|
// the same items are on screen either way.
|
||||||
@override
|
@override
|
||||||
Set<String>? get watchedIds {
|
Set<String>? get watchedIds {
|
||||||
final keys = <String>{_metadata.id};
|
final keys = <String>{_metadata.id};
|
||||||
@@ -533,36 +541,6 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
|
||||||
Set<String>? get deletionIds {
|
|
||||||
final keys = <String>{_metadata.id};
|
|
||||||
for (final season in _seasons) {
|
|
||||||
keys.add(season.id);
|
|
||||||
}
|
|
||||||
for (final ep in _episodes) {
|
|
||||||
keys.add(ep.id);
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
String? get deletionServerId => serverBoundServerId;
|
|
||||||
|
|
||||||
@override
|
|
||||||
Set<String>? get deletionGlobalKeys {
|
|
||||||
final serverId = serverBoundServerId;
|
|
||||||
if (serverId == null) return null;
|
|
||||||
|
|
||||||
final keys = <String>{toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))};
|
|
||||||
for (final season in _seasons) {
|
|
||||||
keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId)));
|
|
||||||
}
|
|
||||||
for (final ep in _episodes) {
|
|
||||||
keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId)));
|
|
||||||
}
|
|
||||||
return keys;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onDeletionEvent(DeletionEvent event) {
|
void onDeletionEvent(DeletionEvent event) {
|
||||||
// Download-only deletions should only remove items when viewing offline content
|
// Download-only deletions should only remove items when viewing offline content
|
||||||
|
|||||||
@@ -122,23 +122,14 @@ class _MetadataEditScreenState extends State<MetadataEditScreen> {
|
|||||||
final draft = _draft;
|
final draft = _draft;
|
||||||
if (draft == null || _isCommitting) return;
|
if (draft == null || _isCommitting) return;
|
||||||
final currentValue = draft.value<String>(field.id) ?? '';
|
final currentValue = draft.value<String>(field.id) ?? '';
|
||||||
final result = multiline
|
final result = await showTextInputDialog(
|
||||||
? await showTextInputDialog(
|
context,
|
||||||
context,
|
title: field.label,
|
||||||
title: field.label,
|
labelText: field.label,
|
||||||
labelText: field.label,
|
initialValue: currentValue,
|
||||||
initialValue: currentValue,
|
allowEmpty: true,
|
||||||
allowEmpty: true,
|
multiline: multiline,
|
||||||
multiline: true,
|
);
|
||||||
)
|
|
||||||
: await showTextInputDialog(
|
|
||||||
context,
|
|
||||||
title: field.label,
|
|
||||||
labelText: field.label,
|
|
||||||
hintText: '',
|
|
||||||
initialValue: currentValue,
|
|
||||||
allowEmpty: true,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result != null && mounted && !_isCommitting && identical(_draft, draft)) {
|
if (result != null && mounted && !_isCommitting && identical(_draft, draft)) {
|
||||||
setState(() => draft.setValue(field.id, result));
|
setState(() => draft.setValue(field.id, result));
|
||||||
|
|||||||
@@ -27,14 +27,12 @@ import '../../utils/snackbar_helper.dart';
|
|||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
import '../../widgets/desktop_app_bar.dart';
|
import '../../widgets/desktop_app_bar.dart';
|
||||||
import '../../widgets/download_status_icon.dart';
|
import '../../widgets/download_status_icon.dart';
|
||||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
|
||||||
import '../../widgets/media_context_menu.dart';
|
import '../../widgets/media_context_menu.dart';
|
||||||
import '../../widgets/music/mini_player.dart';
|
import '../../widgets/music/mini_player.dart';
|
||||||
import '../../widgets/music/music_detail_header.dart';
|
import '../../widgets/music/music_detail_header.dart';
|
||||||
import '../../widgets/music/music_actions.dart';
|
import '../../widgets/music/music_actions.dart';
|
||||||
import '../../widgets/music/track_row.dart';
|
import '../../widgets/music/track_row.dart';
|
||||||
import '../../widgets/optimized_media_image.dart';
|
import '../../widgets/optimized_media_image.dart';
|
||||||
import '../../widgets/overlay_sheet.dart';
|
|
||||||
import '../base_media_list_detail_screen.dart';
|
import '../base_media_list_detail_screen.dart';
|
||||||
import '../focusable_detail_screen_mixin.dart';
|
import '../focusable_detail_screen_mixin.dart';
|
||||||
|
|
||||||
@@ -388,35 +386,15 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen<AlbumDetailScree
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PrimaryScrollController(
|
return buildDetailScaffold(
|
||||||
controller: scrollController,
|
slivers: [
|
||||||
child: IosStatusBarTapScrollToTop(
|
CustomAppBar(title: Text(widget.album.displayTitle)),
|
||||||
controller: scrollController,
|
SliverToBoxAdapter(child: _buildHeader()),
|
||||||
child: OverlaySheetHost(
|
...buildStateSlivers(),
|
||||||
// Host owns sheet + system back: a back with a sheet open closes it;
|
if (hasItems) _buildTrackList(),
|
||||||
// otherwise focus the action row first, then pop.
|
// Keep the last rows reachable above the floating mini-player.
|
||||||
canPop: PlatformDetector.isHandheldIOS(context),
|
SliverToBoxAdapter(child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0)),
|
||||||
onSystemBack: () {
|
],
|
||||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
|
||||||
if (handleBackNavigation() && mounted) Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
body: CustomScrollView(
|
|
||||||
primary: true,
|
|
||||||
slivers: [
|
|
||||||
CustomAppBar(title: Text(widget.album.displayTitle)),
|
|
||||||
SliverToBoxAdapter(child: _buildHeader()),
|
|
||||||
...buildStateSlivers(),
|
|
||||||
if (hasItems) _buildTrackList(),
|
|
||||||
// Keep the last rows reachable above the floating mini-player.
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../focus/focusable_action_bar.dart';
|
import '../../focus/focusable_action_bar.dart';
|
||||||
import '../../focus/key_event_utils.dart';
|
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../media/ids.dart';
|
import '../../media/ids.dart';
|
||||||
import '../../media/media_item.dart';
|
import '../../media/media_item.dart';
|
||||||
@@ -16,17 +15,14 @@ import '../../utils/formatters.dart';
|
|||||||
import '../../utils/error_message_utils.dart';
|
import '../../utils/error_message_utils.dart';
|
||||||
import '../../utils/media_image_helper.dart';
|
import '../../utils/media_image_helper.dart';
|
||||||
import '../../utils/music_navigation.dart';
|
import '../../utils/music_navigation.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
|
||||||
import '../../utils/provider_extensions.dart';
|
import '../../utils/provider_extensions.dart';
|
||||||
import '../../utils/snackbar_helper.dart';
|
import '../../utils/snackbar_helper.dart';
|
||||||
import '../../widgets/collapsible_text.dart';
|
import '../../widgets/collapsible_text.dart';
|
||||||
import '../../widgets/desktop_app_bar.dart';
|
import '../../widgets/desktop_app_bar.dart';
|
||||||
import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
|
||||||
import '../../widgets/music/mini_player.dart';
|
import '../../widgets/music/mini_player.dart';
|
||||||
import '../../widgets/music/music_detail_header.dart';
|
import '../../widgets/music/music_detail_header.dart';
|
||||||
import '../../widgets/music/music_actions.dart';
|
import '../../widgets/music/music_actions.dart';
|
||||||
import '../../widgets/optimized_media_image.dart';
|
import '../../widgets/optimized_media_image.dart';
|
||||||
import '../../widgets/overlay_sheet.dart';
|
|
||||||
import '../base_media_list_detail_screen.dart';
|
import '../base_media_list_detail_screen.dart';
|
||||||
import '../focusable_detail_screen_mixin.dart';
|
import '../focusable_detail_screen_mixin.dart';
|
||||||
|
|
||||||
@@ -81,31 +77,17 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
|||||||
/// listing this screen loads, so this costs one extra server round-trip —
|
/// listing this screen loads, so this costs one extra server round-trip —
|
||||||
/// gated on playback availability first so the stub never fetches.
|
/// gated on playback availability first so the stub never fetches.
|
||||||
Future<void> _playAll({bool shuffle = false}) async {
|
Future<void> _playAll({bool shuffle = false}) async {
|
||||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
await playFetchedTracks(
|
||||||
final service = context.read<MusicPlaybackService>();
|
|
||||||
final intent = service.beginPlayIntent();
|
|
||||||
List<MediaItem> tracks;
|
|
||||||
try {
|
|
||||||
tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id);
|
|
||||||
} catch (e, stackTrace) {
|
|
||||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
|
||||||
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle);
|
|
||||||
showErrorSnackBar(context, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
|
||||||
if (tracks.isEmpty) {
|
|
||||||
showAppSnackBar(context, emptyMessage);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await playTracks(
|
|
||||||
context,
|
context,
|
||||||
tracks: tracks,
|
fetch: () => mediaClient.fetchPlayableDescendants(widget.artist.id),
|
||||||
playContext: MusicPlayContext(
|
playContext: MusicPlayContext(
|
||||||
id: widget.artist.id,
|
id: widget.artist.id,
|
||||||
title: widget.artist.displayTitle,
|
title: widget.artist.displayTitle,
|
||||||
kind: MusicPlayContextKind.artist,
|
kind: MusicPlayContextKind.artist,
|
||||||
),
|
),
|
||||||
|
onError: (e, stackTrace) =>
|
||||||
|
showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle)),
|
||||||
|
onEmpty: () => showAppSnackBar(context, emptyMessage),
|
||||||
shuffle: shuffle,
|
shuffle: shuffle,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -193,36 +175,16 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return PrimaryScrollController(
|
return buildDetailScaffold(
|
||||||
controller: scrollController,
|
slivers: [
|
||||||
child: IosStatusBarTapScrollToTop(
|
CustomAppBar(title: Text(widget.artist.displayTitle)),
|
||||||
controller: scrollController,
|
SliverToBoxAdapter(child: _buildHeader()),
|
||||||
child: OverlaySheetHost(
|
...buildStateSlivers(),
|
||||||
// Host owns sheet + system back: a back with a sheet open closes it;
|
// Albums arrive newest-first from both backends — no client-side sort.
|
||||||
// otherwise focus the action row first, then pop.
|
if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square),
|
||||||
canPop: PlatformDetector.isHandheldIOS(context),
|
// Keep the last rows reachable above the floating mini-player.
|
||||||
onSystemBack: () {
|
SliverToBoxAdapter(child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0)),
|
||||||
if (BackKeyCoordinator.consumeIfHandled()) return;
|
],
|
||||||
if (handleBackNavigation() && mounted) Navigator.pop(context);
|
|
||||||
},
|
|
||||||
child: Scaffold(
|
|
||||||
body: CustomScrollView(
|
|
||||||
primary: true,
|
|
||||||
slivers: [
|
|
||||||
CustomAppBar(title: Text(widget.artist.displayTitle)),
|
|
||||||
SliverToBoxAdapter(child: _buildHeader()),
|
|
||||||
...buildStateSlivers(),
|
|
||||||
// Albums arrive newest-first from both backends — no client-side sort.
|
|
||||||
if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square),
|
|
||||||
// Keep the last rows reachable above the floating mini-player.
|
|
||||||
SliverToBoxAdapter(
|
|
||||||
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../../media/ids.dart';
|
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:material_symbols_icons/symbols.dart';
|
import 'package:material_symbols_icons/symbols.dart';
|
||||||
import '../../focus/focusable_action_bar.dart';
|
import '../../focus/focusable_action_bar.dart';
|
||||||
import '../../focus/focusable_button.dart';
|
|
||||||
import '../../media/library_query.dart';
|
import '../../media/library_query.dart';
|
||||||
import '../../media/media_item.dart';
|
import '../../media/media_item.dart';
|
||||||
import '../../media/media_kind.dart';
|
import '../../media/media_kind.dart';
|
||||||
@@ -34,6 +32,7 @@ import '../../widgets/ios_status_bar_tap_scroll_to_top.dart';
|
|||||||
import '../../widgets/listenable_selector.dart';
|
import '../../widgets/listenable_selector.dart';
|
||||||
import '../base_media_list_detail_screen.dart';
|
import '../base_media_list_detail_screen.dart';
|
||||||
import '../focusable_detail_screen_mixin.dart';
|
import '../focusable_detail_screen_mixin.dart';
|
||||||
|
import '../libraries/content_state_builder.dart';
|
||||||
import '../../mixins/grid_focus_node_mixin.dart';
|
import '../../mixins/grid_focus_node_mixin.dart';
|
||||||
import '../../widgets/overlay_sheet.dart';
|
import '../../widgets/overlay_sheet.dart';
|
||||||
|
|
||||||
@@ -92,24 +91,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
showAppSnackBar(context, emptyMessage);
|
showAppSnackBar(context, emptyMessage);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!ensureMusicPlaybackAvailable(context)) return;
|
await playFetchedTracks(
|
||||||
final service = context.read<MusicPlaybackService>();
|
context,
|
||||||
final intent = service.beginPlayIntent();
|
fetch: () async => _isPlaylistFullyLoaded ? items : await fetchAllPlaylistItems(mediaClient, widget.playlist.id),
|
||||||
List<MediaItem> tracks;
|
playContext: _musicPlayContext,
|
||||||
if (_isPlaylistFullyLoaded) {
|
onError: (e, stackTrace) =>
|
||||||
tracks = items;
|
showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title)),
|
||||||
} else {
|
startTrack: startTrack,
|
||||||
try {
|
shuffle: shuffle,
|
||||||
tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id);
|
);
|
||||||
} catch (e, stackTrace) {
|
|
||||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
|
||||||
final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title);
|
|
||||||
showErrorSnackBar(context, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!mounted || !service.isPlayIntentCurrent(intent)) return;
|
|
||||||
await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -117,7 +107,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
// Video AND audio playlists download (tracks queue through the same list
|
// Video AND audio playlists download (tracks queue through the same list
|
||||||
// pipeline); photo/mixed playlists keep the affordance hidden.
|
// pipeline); photo/mixed playlists keep the affordance hidden.
|
||||||
final isDownloadablePlaylist = widget.playlist.playlistType == 'video' || _isAudioPlaylist;
|
final isDownloadablePlaylist = widget.playlist.playlistType == 'video' || _isAudioPlaylist;
|
||||||
final ruleKey = _playlistSyncRuleKey();
|
final ruleKey = syncRuleKey;
|
||||||
// Select the specific bool we care about so unrelated DownloadProvider
|
// Select the specific bool we care about so unrelated DownloadProvider
|
||||||
// ticks (e.g. active download progress) don't rebuild the app bar.
|
// ticks (e.g. active download progress) don't rebuild the app bar.
|
||||||
final hasRule = isDownloadablePlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
|
final hasRule = isDownloadablePlaylist && context.select<DownloadProvider, bool>((p) => p.hasSyncRule(ruleKey));
|
||||||
@@ -127,19 +117,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
|
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
|
||||||
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
|
||||||
],
|
],
|
||||||
if (!PlatformDetector.isAppleTV() && isDownloadablePlaylist && (items.isNotEmpty || hasRule))
|
...buildSyncRuleActions(
|
||||||
FocusableAction(
|
context,
|
||||||
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded,
|
ruleKey: ruleKey,
|
||||||
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow,
|
displayTitle: widget.playlist.title,
|
||||||
onPressed: hasRule ? _managePlaylistSyncRule : _downloadPlaylist,
|
hasRule: hasRule,
|
||||||
iconColor: hasRule ? Colors.teal : null,
|
showDownload: isDownloadablePlaylist && (items.isNotEmpty || hasRule),
|
||||||
),
|
onDownload: _downloadPlaylist,
|
||||||
if (!PlatformDetector.isAppleTV() && hasRule)
|
),
|
||||||
FocusableAction(
|
|
||||||
icon: Symbols.sync_disabled_rounded,
|
|
||||||
tooltip: t.downloads.removeSyncRule,
|
|
||||||
onPressed: _removePlaylistSyncRule,
|
|
||||||
),
|
|
||||||
// Delete works on both backends now (Jellyfin uses /Items/{id} DELETE,
|
// Delete works on both backends now (Jellyfin uses /Items/{id} DELETE,
|
||||||
// wrapped in the neutral [MediaServerClient.deletePlaylist]). Smart
|
// wrapped in the neutral [MediaServerClient.deletePlaylist]). Smart
|
||||||
// playlists are still skipped — they're a Plex concept and are
|
// playlists are still skipped — they're a Plex concept and are
|
||||||
@@ -166,25 +151,6 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
serverName: widget.playlist.serverName,
|
serverName: widget.playlist.serverName,
|
||||||
);
|
);
|
||||||
|
|
||||||
String _playlistSyncRuleKey() {
|
|
||||||
final serverId = widget.playlist.serverId ?? mediaClient.serverId;
|
|
||||||
return context.read<DownloadProvider>().syncRuleKeyForClient(
|
|
||||||
mediaClient,
|
|
||||||
widget.playlist.id,
|
|
||||||
serverId: ServerId(serverId),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _managePlaylistSyncRule() =>
|
|
||||||
manageSyncRule(context, downloadProvider: context.read<DownloadProvider>(), globalKey: _playlistSyncRuleKey());
|
|
||||||
|
|
||||||
Future<void> _removePlaylistSyncRule() => removeSyncRuleAndSnack(
|
|
||||||
context,
|
|
||||||
downloadProvider: context.read<DownloadProvider>(),
|
|
||||||
globalKey: _playlistSyncRuleKey(),
|
|
||||||
displayTitle: widget.playlist.title,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Focus management for regular (non-smart) reorderable lists
|
// Focus management for regular (non-smart) reorderable lists
|
||||||
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
|
final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list');
|
||||||
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry');
|
final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry');
|
||||||
@@ -780,7 +746,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
else
|
else
|
||||||
// Plex regular playlists: sliver reorderable list
|
// Plex regular playlists: sliver reorderable list
|
||||||
_buildReorderableList(isKeyboardMode),
|
_buildReorderableList(isKeyboardMode),
|
||||||
if (_continuation.isLoading || _continuation.error != null) _buildPlaylistContinuationStatusSliver(),
|
if (_continuation.isLoading || _continuation.error != null)
|
||||||
|
ContinuationStatusSliver(
|
||||||
|
error: _continuation.error,
|
||||||
|
onRetry: _retryPlaylistContinuation,
|
||||||
|
retryFocusNode: _continuationRetryFocusNode,
|
||||||
|
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
|
||||||
|
onBack: handleBackFromContent,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -860,32 +833,4 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen<PlaylistDetai
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPlaylistContinuationStatusSliver() {
|
|
||||||
final exception = _continuation.error;
|
|
||||||
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: _retryPlaylistContinuation,
|
|
||||||
onNavigateUp: _isReadOnly ? navigateToGrid : _listFocusNode.requestFocus,
|
|
||||||
onBack: handleBackFromContent,
|
|
||||||
child: TextButton(onPressed: _retryPlaylistContinuation, child: Text(t.common.retry)),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -201,13 +201,8 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
|||||||
candidateSliver = SliverList(
|
candidateSliver = SliverList(
|
||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final cand = candidates[index];
|
final cand = candidates[index];
|
||||||
// M3E connected-group geometry: large outer corners, small
|
|
||||||
// inner corners, hairline gaps between tiles.
|
|
||||||
final tokensRef = tokens(context);
|
final tokensRef = tokens(context);
|
||||||
final tileRadii = BorderRadius.vertical(
|
final tileRadii = groupItemRadii(context, index, candidates.length);
|
||||||
top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
bottom: Radius.circular(index == candidates.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
);
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0),
|
padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0),
|
||||||
child: FocusableWrapper(
|
child: FocusableWrapper(
|
||||||
@@ -299,6 +294,8 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
|||||||
final parentId = cand.source.parentConnectionId;
|
final parentId = cand.source.parentConnectionId;
|
||||||
final homeUuid = cand.source.plexHomeUserUuid;
|
final homeUuid = cand.source.plexHomeUserUuid;
|
||||||
if (parentId == null || homeUuid == null) return false;
|
if (parentId == null || homeUuid == null) return false;
|
||||||
|
// Built before the await: capturing the prompt needs a live element.
|
||||||
|
final promptForPin = dialogPinPrompt(context, cand.source.displayName);
|
||||||
final parent = await context.read<ConnectionRegistry>().getPlexAccount(parentId);
|
final parent = await context.read<ConnectionRegistry>().getPlexAccount(parentId);
|
||||||
if (parent == null) {
|
if (parent == null) {
|
||||||
if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount);
|
if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount);
|
||||||
@@ -308,10 +305,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
|||||||
account: parent,
|
account: parent,
|
||||||
homeUserUuid: homeUuid,
|
homeUserUuid: homeUuid,
|
||||||
requiresPin: true,
|
requiresPin: true,
|
||||||
promptForPin: ({String? errorMessage}) async {
|
promptForPin: promptForPin,
|
||||||
if (!mounted) return null;
|
|
||||||
return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage);
|
|
||||||
},
|
|
||||||
logLabel: cand.source.displayName,
|
logLabel: cand.source.displayName,
|
||||||
);
|
);
|
||||||
if (!result.succeeded) {
|
if (!result.succeeded) {
|
||||||
@@ -330,10 +324,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
|||||||
account: account,
|
account: account,
|
||||||
homeUserUuid: cand.pc.userIdentifier,
|
homeUserUuid: cand.pc.userIdentifier,
|
||||||
requiresPin: cand.source.plexProtected,
|
requiresPin: cand.source.plexProtected,
|
||||||
promptForPin: ({String? errorMessage}) async {
|
promptForPin: dialogPinPrompt(context, cand.source.displayName),
|
||||||
if (!mounted) return null;
|
|
||||||
return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage);
|
|
||||||
},
|
|
||||||
persistTo: pcRegistry,
|
persistTo: pcRegistry,
|
||||||
persistProfileId: widget.targetProfile.id,
|
persistProfileId: widget.targetProfile.id,
|
||||||
logLabel: cand.source.displayName,
|
logLabel: cand.source.displayName,
|
||||||
@@ -344,14 +335,7 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (mounted) {
|
_finishBorrow();
|
||||||
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
|
|
||||||
if (widget.popOnSuccess) {
|
|
||||||
Navigator.of(context).pop(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _borrowJellyfin(_BorrowCandidate cand) async {
|
Future<void> _borrowJellyfin(_BorrowCandidate cand) async {
|
||||||
@@ -366,14 +350,19 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
|
|||||||
tokenAcquiredAt: DateTime.now(),
|
tokenAcquiredAt: DateTime.now(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
if (mounted) {
|
_finishBorrow();
|
||||||
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
|
}
|
||||||
if (widget.popOnSuccess) {
|
|
||||||
Navigator.of(context).pop(true);
|
/// Shared tail of every successful borrow: rebind the target profile when
|
||||||
return;
|
/// it is the active one, then pop with the result or confirm in place.
|
||||||
}
|
void _finishBorrow() {
|
||||||
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
|
if (!mounted) return;
|
||||||
|
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
|
||||||
|
if (widget.popOnSuccess) {
|
||||||
|
Navigator.of(context).pop(true);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import '../../focus/key_event_utils.dart';
|
|||||||
import '../../focus/focusable_button.dart';
|
import '../../focus/focusable_button.dart';
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
import '../../mixins/controller_disposer_mixin.dart';
|
import '../../mixins/controller_disposer_mixin.dart';
|
||||||
|
import '../../profiles/plex_home_switch.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
import '../../widgets/clickable_cursor.dart';
|
import '../../widgets/clickable_cursor.dart';
|
||||||
@@ -698,6 +699,14 @@ Future<String?> showPinEntryDialog(BuildContext context, String userName, {Strin
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The [PlexHomeSwitchPinPrompt] every UI-side `mintPlexHomeUserToken` caller
|
||||||
|
/// needs: show [showPinEntryDialog] for [displayName], or cancel the switch
|
||||||
|
/// once [context] is gone. Only the *use* is guarded — build it before the
|
||||||
|
/// caller's first await, while [context] is still live.
|
||||||
|
PlexHomeSwitchPinPrompt dialogPinPrompt(BuildContext context, String displayName) =>
|
||||||
|
({String? errorMessage}) async =>
|
||||||
|
context.mounted ? showPinEntryDialog(context, displayName, errorMessage: errorMessage) : null;
|
||||||
|
|
||||||
/// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null
|
/// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null
|
||||||
/// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch]
|
/// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch]
|
||||||
/// (or no-op if not provided) and returns null — the helper keeps the UX
|
/// (or no-op if not provided) and returns null — the helper keeps the UX
|
||||||
|
|||||||
@@ -10,21 +10,13 @@ import '../../i18n/strings.g.dart';
|
|||||||
import '../../mixins/controller_disposer_mixin.dart';
|
import '../../mixins/controller_disposer_mixin.dart';
|
||||||
import '../../models/plex/plex_home_user.dart';
|
import '../../models/plex/plex_home_user.dart';
|
||||||
import '../../profiles/active_profile_binder.dart';
|
import '../../profiles/active_profile_binder.dart';
|
||||||
import '../../profiles/active_profile_provider.dart';
|
|
||||||
import '../../profiles/plex_home_service.dart';
|
import '../../profiles/plex_home_service.dart';
|
||||||
import '../../profiles/profile.dart';
|
import '../../profiles/profile.dart';
|
||||||
import '../../profiles/profile_avatar.dart';
|
import '../../profiles/profile_avatar.dart';
|
||||||
import '../../profiles/profile_connection_cleanup.dart';
|
|
||||||
import '../../profiles/profile_connection.dart';
|
import '../../profiles/profile_connection.dart';
|
||||||
import '../../profiles/profile_connection_registry.dart';
|
import '../../profiles/profile_connection_registry.dart';
|
||||||
import '../../profiles/profile_registry.dart';
|
import '../../profiles/profile_registry.dart';
|
||||||
import '../../profiles/profiles_view.dart';
|
import '../../profiles/profiles_view.dart';
|
||||||
import '../../providers/download_provider.dart';
|
|
||||||
import '../../providers/discover_provider.dart';
|
|
||||||
import '../../providers/hidden_libraries_provider.dart';
|
|
||||||
import '../../providers/multi_server_provider.dart';
|
|
||||||
import '../../services/storage_service.dart';
|
|
||||||
import '../../services/system_shelf_service.dart';
|
|
||||||
import '../../utils/snackbar_helper.dart';
|
import '../../utils/snackbar_helper.dart';
|
||||||
import '../../focus/focusable_button.dart';
|
import '../../focus/focusable_button.dart';
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
@@ -167,20 +159,11 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
|||||||
isDestructive: true,
|
isDestructive: true,
|
||||||
);
|
);
|
||||||
if (!confirmed || !mounted) return;
|
if (!confirmed || !mounted) return;
|
||||||
final downloads = context.read<DownloadProvider>();
|
final scope = SessionTeardownScope.of(context);
|
||||||
final pcRegistry = context.read<ProfileConnectionRegistry>();
|
final endedOwner = scope.active.activeId == _profile.id ? _profile.id : null;
|
||||||
final connRegistry = context.read<ConnectionRegistry>();
|
|
||||||
final storage = context.read<StorageService>();
|
|
||||||
final multiServer = context.read<MultiServerProvider>();
|
|
||||||
final hiddenLibraries = context.read<HiddenLibrariesProvider?>();
|
|
||||||
final discover = context.read<DiscoverProvider?>();
|
|
||||||
final binder = context.read<ActiveProfileBinder>();
|
|
||||||
final active = context.read<ActiveProfileProvider>();
|
|
||||||
final shelf = SystemShelfService();
|
|
||||||
final endedOwner = active.activeId == _profile.id ? _profile.id : null;
|
|
||||||
|
|
||||||
if (endedOwner != null) {
|
if (endedOwner != null) {
|
||||||
await shelf.endProfileSession(endedOwner);
|
await scope.shelf.endProfileSession(endedOwner);
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -189,38 +172,26 @@ class _ProfileDetailScreenState extends State<ProfileDetailScreen> with Controll
|
|||||||
// Plex account sharing the server, another Jellyfin user).
|
// Plex account sharing the server, another Jellyfin user).
|
||||||
final retainedServerIds = await _retainedServerIds(
|
final retainedServerIds = await _retainedServerIds(
|
||||||
excludingConnectionId: conn.id,
|
excludingConnectionId: conn.id,
|
||||||
profileConnections: pcRegistry,
|
profileConnections: scope.profileConnections,
|
||||||
connections: connRegistry,
|
connections: scope.connections,
|
||||||
);
|
);
|
||||||
await downloads.releaseDownloadsForProfileServers(
|
await scope.downloads.releaseDownloadsForProfileServers(
|
||||||
_profile.id,
|
_profile.id,
|
||||||
_serverIdsForConnection(conn).difference(retainedServerIds),
|
_serverIdsForConnection(conn).difference(retainedServerIds),
|
||||||
);
|
);
|
||||||
await removeProfileConnectionAndCleanup(
|
await scope.cleanup.removeProfileConnection(profileId: _profile.id, connection: conn);
|
||||||
profileId: _profile.id,
|
await scope.hiddenLibraries?.refresh();
|
||||||
connection: conn,
|
// Deliberately not `resumeFreshSystemShelf`: a rebind failure on the
|
||||||
profileConnections: pcRegistry,
|
// success path must reach the catch below so the recovery attempt —
|
||||||
connections: connRegistry,
|
// and the rethrow — still run.
|
||||||
storage: storage,
|
await scope.binder.rebindIfActive(_profile.id);
|
||||||
serverManager: multiServer.serverManager,
|
if (endedOwner != null && scope.active.activeId == endedOwner) {
|
||||||
);
|
scope.shelf.beginProfileSession(endedOwner);
|
||||||
await hiddenLibraries?.refresh();
|
if (scope.multiServer.hasConnectedServers) await scope.discover?.load();
|
||||||
await binder.rebindIfActive(_profile.id);
|
|
||||||
if (endedOwner != null && active.activeId == endedOwner) {
|
|
||||||
shelf.beginProfileSession(endedOwner);
|
|
||||||
if (multiServer.hasConnectedServers) await discover?.load();
|
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
if (endedOwner != null && active.activeId == endedOwner) {
|
if (endedOwner != null) {
|
||||||
try {
|
await resumeFreshSystemShelf(scope, endedOwner);
|
||||||
await binder.rebindIfActive(endedOwner);
|
|
||||||
if (active.activeId == endedOwner) {
|
|
||||||
shelf.beginProfileSession(endedOwner);
|
|
||||||
if (multiServer.hasConnectedServers) await discover?.load();
|
|
||||||
}
|
|
||||||
} catch (_) {
|
|
||||||
// Keep the shelf empty when the surviving profile cannot be rebound.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
rethrow;
|
rethrow;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,13 +224,8 @@ class _ProfileSwitchScreenState extends State<ProfileSwitchScreen> with MountedS
|
|||||||
delegate: SliverChildBuilderDelegate((context, index) {
|
delegate: SliverChildBuilderDelegate((context, index) {
|
||||||
final profile = profiles[index];
|
final profile = profiles[index];
|
||||||
final isActive = profile.id == activeId;
|
final isActive = profile.id == activeId;
|
||||||
// M3E connected-group geometry: large outer corners, small inner
|
|
||||||
// corners, hairline gaps between tiles.
|
|
||||||
final tokensRef = tokens(context);
|
final tokensRef = tokens(context);
|
||||||
final tileRadii = BorderRadius.vertical(
|
final tileRadii = groupItemRadii(context, index, profiles.length);
|
||||||
top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
bottom: Radius.circular(index == profiles.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
);
|
|
||||||
final isFirstSelectable = autofocusFirst && index == 0;
|
final isFirstSelectable = autofocusFirst && index == 0;
|
||||||
final profileFocusNode = _profileFocusNode(profile);
|
final profileFocusNode = _profileFocusNode(profile);
|
||||||
final menuFocusNode = _profileMenuFocusNode(profile);
|
final menuFocusNode = _profileMenuFocusNode(profile);
|
||||||
|
|||||||
@@ -49,6 +49,13 @@ class SessionTeardownScope {
|
|||||||
|
|
||||||
MultiServerManager get serverManager => multiServer.serverManager;
|
MultiServerManager get serverManager => multiServer.serverManager;
|
||||||
|
|
||||||
|
ProfileConnectionCleanup get cleanup => ProfileConnectionCleanup(
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
connections: connections,
|
||||||
|
storage: storage,
|
||||||
|
serverManager: serverManager,
|
||||||
|
);
|
||||||
|
|
||||||
SessionTeardownScope.of(BuildContext context)
|
SessionTeardownScope.of(BuildContext context)
|
||||||
: active = context.read<ActiveProfileProvider>(),
|
: active = context.read<ActiveProfileProvider>(),
|
||||||
binder = context.read<ActiveProfileBinder>(),
|
binder = context.read<ActiveProfileBinder>(),
|
||||||
@@ -80,13 +87,9 @@ Future<bool> settleSessionAfterRemoval(
|
|||||||
bool rebindIfActiveKept = false,
|
bool rebindIfActiveKept = false,
|
||||||
String? endedShelfOwner,
|
String? endedShelfOwner,
|
||||||
}) async {
|
}) async {
|
||||||
final result = await resolvePostRemovalState(
|
final result = await scope.cleanup.resolvePostRemovalState(
|
||||||
profileRegistry: scope.profileRegistry,
|
profileRegistry: scope.profileRegistry,
|
||||||
profileConnections: scope.profileConnections,
|
|
||||||
connections: scope.connections,
|
|
||||||
plexHomeUsers: scope.plexHome.current,
|
plexHomeUsers: scope.plexHome.current,
|
||||||
storage: scope.storage,
|
|
||||||
serverManager: scope.serverManager,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (result.route == PostRemovalRoute.signedOut) {
|
if (result.route == PostRemovalRoute.signedOut) {
|
||||||
@@ -199,13 +202,7 @@ Future<void> deleteProfile(BuildContext context, Profile profile) async {
|
|||||||
await scope.downloads.deleteDownloadsForProfile(profile.id);
|
await scope.downloads.deleteDownloadsForProfile(profile.id);
|
||||||
await scope.database.deleteSyncRulesForProfile(profile.id);
|
await scope.database.deleteSyncRulesForProfile(profile.id);
|
||||||
await scope.database.deleteWatchActionsForProfile(profile.id);
|
await scope.database.deleteWatchActionsForProfile(profile.id);
|
||||||
await removeAllProfileConnectionsAndCleanup(
|
await scope.cleanup.removeAllProfileConnections(profile.id);
|
||||||
profileId: profile.id,
|
|
||||||
profileConnections: scope.profileConnections,
|
|
||||||
connections: scope.connections,
|
|
||||||
storage: scope.storage,
|
|
||||||
serverManager: scope.serverManager,
|
|
||||||
);
|
|
||||||
await scope.profileRegistry.remove(profile.id);
|
await scope.profileRegistry.remove(profile.id);
|
||||||
await scope.storage.clearProfileLastUsed(profile.id);
|
await scope.storage.clearProfileLastUsed(profile.id);
|
||||||
await scope.storage.clearUserScopedPreferencesForProfile(profile.id);
|
await scope.storage.clearUserScopedPreferencesForProfile(profile.id);
|
||||||
@@ -263,14 +260,7 @@ Future<bool> confirmAndSignOutPlexAccount(BuildContext context, {required String
|
|||||||
await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds);
|
await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds);
|
||||||
}
|
}
|
||||||
|
|
||||||
await removePlexAccountConnectionAndCleanup(
|
await scope.cleanup.removePlexAccountConnection(account, plannedRemoval: removal);
|
||||||
account: account,
|
|
||||||
profileConnections: scope.profileConnections,
|
|
||||||
connections: scope.connections,
|
|
||||||
storage: scope.storage,
|
|
||||||
serverManager: scope.serverManager,
|
|
||||||
plannedRemoval: removal,
|
|
||||||
);
|
|
||||||
for (final profileId in removal.removedVirtualProfileIds) {
|
for (final profileId in removal.removedVirtualProfileIds) {
|
||||||
await scope.database.deleteSyncRulesForProfile(profileId);
|
await scope.database.deleteSyncRulesForProfile(profileId);
|
||||||
await scope.database.deleteWatchActionsForProfile(profileId);
|
await scope.database.deleteWatchActionsForProfile(profileId);
|
||||||
|
|||||||
@@ -55,12 +55,6 @@ class AddConnectionScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
];
|
];
|
||||||
final tokensRef = tokens(context);
|
final tokensRef = tokens(context);
|
||||||
// M3E connected-group geometry: large outer corners, small inner corners,
|
|
||||||
// hairline gaps.
|
|
||||||
BorderRadius radiiFor(int i) => BorderRadius.vertical(
|
|
||||||
top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
bottom: Radius.circular(i == options.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
);
|
|
||||||
return FocusedScrollScaffold(
|
return FocusedScrollScaffold(
|
||||||
title: Text(
|
title: Text(
|
||||||
scoped
|
scoped
|
||||||
@@ -75,7 +69,7 @@ class AddConnectionScreen extends StatelessWidget {
|
|||||||
for (var i = 0; i < options.length; i++) ...[
|
for (var i = 0; i < options.length; i++) ...[
|
||||||
if (i > 0) SizedBox(height: tokensRef.groupGap),
|
if (i > 0) SizedBox(height: tokensRef.groupGap),
|
||||||
_BackendCard(
|
_BackendCard(
|
||||||
borderRadius: radiiFor(i),
|
borderRadius: groupItemRadii(context, i, options.length),
|
||||||
leading: options[i].backend != null
|
leading: options[i].backend != null
|
||||||
? BackendBadge(backend: options[i].backend!, size: 28)
|
? BackendBadge(backend: options[i].backend!, size: 28)
|
||||||
: const AppIcon(Symbols.share_rounded, fill: 1, size: 28),
|
: const AppIcon(Symbols.share_rounded, fill: 1, size: 28),
|
||||||
|
|||||||
@@ -342,13 +342,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
|||||||
_discoveredServerFocusNodes[_localServers.last.id]?.requestFocus();
|
_discoveredServerFocusNodes[_localServers.last.id]?.requestFocus();
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _enteredUrls() {
|
List<String> _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlController.text);
|
||||||
return _urlController.text
|
|
||||||
.split(RegExp(r'[\n,]+'))
|
|
||||||
.map((url) => url.trim())
|
|
||||||
.where((url) => url.isNotEmpty)
|
|
||||||
.toList(growable: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shared persistence path for both username/password and Quick Connect:
|
/// Shared persistence path for both username/password and Quick Connect:
|
||||||
/// atomically provision the optional first-run profile, connection, and
|
/// atomically provision the optional first-run profile, connection, and
|
||||||
@@ -642,12 +636,6 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
|||||||
|
|
||||||
if (_localServers.isEmpty) return const [];
|
if (_localServers.isEmpty) return const [];
|
||||||
final tokensRef = tokens(context);
|
final tokensRef = tokens(context);
|
||||||
// M3E connected-group geometry: large outer corners, small inner corners,
|
|
||||||
// hairline gaps between tiles.
|
|
||||||
BorderRadius radiiFor(int i) => BorderRadius.vertical(
|
|
||||||
top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
bottom: Radius.circular(i == _localServers.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs),
|
|
||||||
);
|
|
||||||
return [
|
return [
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(t.addServer.localServers, style: theme.textTheme.titleSmall),
|
Text(t.addServer.localServers, style: theme.textTheme.titleSmall),
|
||||||
@@ -656,7 +644,7 @@ class _AddJellyfinScreenState extends State<AddJellyfinScreen> with AsyncFormSta
|
|||||||
if (i > 0) SizedBox(height: tokensRef.groupGap),
|
if (i > 0) SizedBox(height: tokensRef.groupGap),
|
||||||
_DiscoveredJellyfinServerTile(
|
_DiscoveredJellyfinServerTile(
|
||||||
server: server,
|
server: server,
|
||||||
borderRadius: radiiFor(i),
|
borderRadius: groupItemRadii(context, i, _localServers.length),
|
||||||
focusNode: _discoveredServerFocusNodes[server.id],
|
focusNode: _discoveredServerFocusNodes[server.id],
|
||||||
onNavigateUp: () {
|
onNavigateUp: () {
|
||||||
final index = _localServers.indexOf(server);
|
final index = _localServers.indexOf(server);
|
||||||
|
|||||||
@@ -86,12 +86,11 @@ class _AddPlexAccountScreenState extends State<AddPlexAccountScreen> with AsyncF
|
|||||||
// to the profile, remove it again so a cancelled attach doesn't
|
// to the profile, remove it again so a cancelled attach doesn't
|
||||||
// leave a global account behind.
|
// leave a global account behind.
|
||||||
if (!registration.existedBefore) {
|
if (!registration.existedBefore) {
|
||||||
await removePlexAccountConnectionAndCleanup(
|
await ProfileConnectionCleanup(
|
||||||
account: connection,
|
|
||||||
profileConnections: pcRegistry,
|
profileConnections: pcRegistry,
|
||||||
connections: connRegistry,
|
connections: connRegistry,
|
||||||
storage: storage,
|
storage: storage,
|
||||||
);
|
).removePlexAccountConnection(connection);
|
||||||
}
|
}
|
||||||
if (mounted) Navigator.of(context).pop(false);
|
if (mounted) Navigator.of(context).pop(false);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -212,14 +212,12 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
Widget _themeSelector() {
|
Widget _themeSelector() {
|
||||||
return Consumer<ThemeProvider>(
|
return Consumer<ThemeProvider>(
|
||||||
builder: (context, themeProvider, _) {
|
builder: (context, themeProvider, _) {
|
||||||
return SettingSelectionTile<settings.ThemeMode, settings.ThemeMode>(
|
return SettingSelectionTile<settings.ThemeMode>(
|
||||||
pref: SettingsService.themeMode,
|
pref: SettingsService.themeMode,
|
||||||
icon: themeProvider.themeModeIcon,
|
icon: themeProvider.themeModeIcon,
|
||||||
title: t.settings.theme,
|
title: t.settings.theme,
|
||||||
subtitleBuilder: themeModeLabel,
|
subtitleBuilder: themeModeLabel,
|
||||||
options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(),
|
options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(),
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@@ -293,7 +291,7 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _viewModeSelector() => SettingSegmentedTile<ViewMode, ViewMode>(
|
Widget _viewModeSelector() => SettingSegmentedTile<ViewMode>(
|
||||||
pref: SettingsService.viewMode,
|
pref: SettingsService.viewMode,
|
||||||
icon: Symbols.view_list_rounded,
|
icon: Symbols.view_list_rounded,
|
||||||
title: t.settings.viewMode,
|
title: t.settings.viewMode,
|
||||||
@@ -301,11 +299,9 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
ButtonSegment(value: ViewMode.grid, label: Text(t.settings.gridView)),
|
ButtonSegment(value: ViewMode.grid, label: Text(t.settings.gridView)),
|
||||||
ButtonSegment(value: ViewMode.list, label: Text(t.settings.listView)),
|
ButtonSegment(value: ViewMode.list, label: Text(t.settings.listView)),
|
||||||
],
|
],
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _episodePosterModeSelector() => SettingSegmentedTile<EpisodePosterMode, EpisodePosterMode>(
|
Widget _episodePosterModeSelector() => SettingSegmentedTile<EpisodePosterMode>(
|
||||||
pref: SettingsService.episodePosterMode,
|
pref: SettingsService.episodePosterMode,
|
||||||
icon: Symbols.image_rounded,
|
icon: Symbols.image_rounded,
|
||||||
title: t.settings.episodePosterMode,
|
title: t.settings.episodePosterMode,
|
||||||
@@ -314,11 +310,9 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
ButtonSegment(value: EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)),
|
ButtonSegment(value: EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)),
|
||||||
ButtonSegment(value: EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)),
|
ButtonSegment(value: EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)),
|
||||||
],
|
],
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _continueWatchingActionSelector() => SettingSegmentedTile<ContinueWatchingAction, ContinueWatchingAction>(
|
Widget _continueWatchingActionSelector() => SettingSegmentedTile<ContinueWatchingAction>(
|
||||||
pref: SettingsService.continueWatchingAction,
|
pref: SettingsService.continueWatchingAction,
|
||||||
icon: Symbols.play_circle_rounded,
|
icon: Symbols.play_circle_rounded,
|
||||||
title: t.settings.continueWatchingAction,
|
title: t.settings.continueWatchingAction,
|
||||||
@@ -326,11 +320,9 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
ButtonSegment(value: ContinueWatchingAction.play, label: Text(t.settings.continueWatchingPlay)),
|
ButtonSegment(value: ContinueWatchingAction.play, label: Text(t.settings.continueWatchingPlay)),
|
||||||
ButtonSegment(value: ContinueWatchingAction.details, label: Text(t.settings.continueWatchingDetails)),
|
ButtonSegment(value: ContinueWatchingAction.details, label: Text(t.settings.continueWatchingDetails)),
|
||||||
],
|
],
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _episodeActionSelector() => SettingSegmentedTile<EpisodeAction, EpisodeAction>(
|
Widget _episodeActionSelector() => SettingSegmentedTile<EpisodeAction>(
|
||||||
pref: SettingsService.episodeAction,
|
pref: SettingsService.episodeAction,
|
||||||
icon: Symbols.tv_rounded,
|
icon: Symbols.tv_rounded,
|
||||||
title: t.settings.episodeAction,
|
title: t.settings.episodeAction,
|
||||||
@@ -338,8 +330,6 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
ButtonSegment(value: EpisodeAction.play, label: Text(t.settings.episodePlay)),
|
ButtonSegment(value: EpisodeAction.play, label: Text(t.settings.episodePlay)),
|
||||||
ButtonSegment(value: EpisodeAction.details, label: Text(t.settings.episodeDetails)),
|
ButtonSegment(value: EpisodeAction.details, label: Text(t.settings.episodeDetails)),
|
||||||
],
|
],
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Sections offered as a startup destination, in display order. Live TV is
|
// Sections offered as a startup destination, in display order. Live TV is
|
||||||
@@ -353,14 +343,12 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
|
|
||||||
String _startupSectionLabel(NavigationTabId id) => allNavigationTabs.firstWhere((t) => t.id == id).getLabel();
|
String _startupSectionLabel(NavigationTabId id) => allNavigationTabs.firstWhere((t) => t.id == id).getLabel();
|
||||||
|
|
||||||
Widget _startupSectionSelector() => SettingSelectionTile<NavigationTabId, NavigationTabId>(
|
Widget _startupSectionSelector() => SettingSelectionTile<NavigationTabId>(
|
||||||
pref: SettingsService.startupSection,
|
pref: SettingsService.startupSection,
|
||||||
icon: Symbols.start_rounded,
|
icon: Symbols.start_rounded,
|
||||||
title: t.settings.startupSection,
|
title: t.settings.startupSection,
|
||||||
subtitleBuilder: _startupSectionLabel,
|
subtitleBuilder: _startupSectionLabel,
|
||||||
options: _startupSectionOptions.map((id) => DialogOption(value: id, title: _startupSectionLabel(id))).toList(),
|
options: _startupSectionOptions.map((id) => DialogOption(value: id, title: _startupSectionLabel(id))).toList(),
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) {
|
String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) {
|
||||||
@@ -369,32 +357,29 @@ class AppearanceSettingsScreen extends StatelessWidget {
|
|||||||
VisualEffectsSetting.reduced => t.settings.visualEffectsReduced,
|
VisualEffectsSetting.reduced => t.settings.visualEffectsReduced,
|
||||||
};
|
};
|
||||||
|
|
||||||
Widget _visualEffectsSelector(BuildContext context) =>
|
Widget _visualEffectsSelector(BuildContext context) => SettingSelectionTile<VisualEffectsSetting>(
|
||||||
SettingSelectionTile<VisualEffectsSetting, VisualEffectsSetting>(
|
pref: SettingsService.visualEffects,
|
||||||
pref: SettingsService.visualEffects,
|
icon: Symbols.animation_rounded,
|
||||||
icon: Symbols.animation_rounded,
|
title: t.settings.visualEffects,
|
||||||
title: t.settings.visualEffects,
|
subtitleBuilder: _visualEffectsLabel,
|
||||||
subtitleBuilder: _visualEffectsLabel,
|
options: [
|
||||||
options: [
|
DialogOption(
|
||||||
DialogOption(
|
value: VisualEffectsSetting.auto,
|
||||||
value: VisualEffectsSetting.auto,
|
title: t.settings.visualEffectsAuto,
|
||||||
title: t.settings.visualEffectsAuto,
|
subtitle: t.settings.visualEffectsAutoDescription,
|
||||||
subtitle: t.settings.visualEffectsAutoDescription,
|
),
|
||||||
),
|
DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull),
|
||||||
DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull),
|
DialogOption(
|
||||||
DialogOption(
|
value: VisualEffectsSetting.reduced,
|
||||||
value: VisualEffectsSetting.reduced,
|
title: t.settings.visualEffectsReduced,
|
||||||
title: t.settings.visualEffectsReduced,
|
subtitle: t.settings.visualEffectsReducedDescription,
|
||||||
subtitle: t.settings.visualEffectsReducedDescription,
|
),
|
||||||
),
|
],
|
||||||
],
|
onAfterWrite: (value) {
|
||||||
decode: (v) => v,
|
DevicePerformance.setOverrideSync(value);
|
||||||
encode: (v) => v,
|
_restartApp(context);
|
||||||
onAfterWrite: (value) {
|
},
|
||||||
DevicePerformance.setOverrideSync(value);
|
);
|
||||||
_restartApp(context);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
String _getLanguageDisplayName(AppLocale locale) {
|
String _getLanguageDisplayName(AppLocale locale) {
|
||||||
switch (locale) {
|
switch (locale) {
|
||||||
|
|||||||
@@ -69,13 +69,7 @@ class _EditJellyfinConnectionScreenState extends State<EditJellyfinConnectionScr
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<String> _enteredUrls() {
|
List<String> _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlsController.text);
|
||||||
return _urlsController.text
|
|
||||||
.split(RegExp(r'[\n,]+'))
|
|
||||||
.map((url) => url.trim())
|
|
||||||
.where((url) => url.isNotEmpty)
|
|
||||||
.toList(growable: false);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import '../../i18n/strings.g.dart';
|
|||||||
import '../../models/hotkey_model.dart';
|
import '../../models/hotkey_model.dart';
|
||||||
import '../../services/keyboard_shortcuts_service.dart';
|
import '../../services/keyboard_shortcuts_service.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
import '../../services/shader_service.dart';
|
import '../../services/shortcut_action.dart';
|
||||||
import '../../utils/dialogs.dart';
|
import '../../utils/dialogs.dart';
|
||||||
import '../../utils/snackbar_helper.dart';
|
import '../../utils/snackbar_helper.dart';
|
||||||
import '../../focus/focusable_button.dart';
|
import '../../focus/focusable_button.dart';
|
||||||
@@ -26,9 +26,7 @@ class KeyboardShortcutsScreen extends StatelessWidget {
|
|||||||
listenable: keyboardService,
|
listenable: keyboardService,
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final hotkeys = keyboardService.hotkeys;
|
final hotkeys = keyboardService.hotkeys;
|
||||||
final actions = hotkeys.keys
|
final actions = hotkeys.keys.where((action) => ShortcutAction.fromId(action)?.isSupported ?? true).toList();
|
||||||
.where((action) => action != 'shader_toggle' || ShaderService.isPlatformSupported)
|
|
||||||
.toList();
|
|
||||||
return FocusedScrollScaffold(
|
return FocusedScrollScaffold(
|
||||||
title: Text(t.settings.keyboardShortcuts),
|
title: Text(t.settings.keyboardShortcuts),
|
||||||
slivers: [
|
slivers: [
|
||||||
|
|||||||
@@ -266,7 +266,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _playerBackendSelector() => SettingSegmentedTile<bool, bool>(
|
Widget _playerBackendSelector() => SettingSegmentedTile<bool>(
|
||||||
pref: SettingsService.useExoPlayer,
|
pref: SettingsService.useExoPlayer,
|
||||||
icon: Symbols.play_circle_rounded,
|
icon: Symbols.play_circle_rounded,
|
||||||
title: t.settings.playerBackend,
|
title: t.settings.playerBackend,
|
||||||
@@ -274,8 +274,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
ButtonSegment(value: true, label: Text(t.settings.exoPlayer)),
|
ButtonSegment(value: true, label: Text(t.settings.exoPlayer)),
|
||||||
ButtonSegment(value: false, label: Text(t.settings.mpv)),
|
ButtonSegment(value: false, label: Text(t.settings.mpv)),
|
||||||
],
|
],
|
||||||
decode: (s) => s,
|
|
||||||
encode: (s) => s,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _externalPlayerTile() => SettingsBuilder(
|
Widget _externalPlayerTile() => SettingsBuilder(
|
||||||
@@ -391,7 +389,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
subtitle: t.settings.tunneledPlaybackDescription,
|
subtitle: t.settings.tunneledPlaybackDescription,
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _dvConversionModeTile() => SettingSelectionTile<DvConversionModePreference, DvConversionModePreference>(
|
Widget _dvConversionModeTile() => SettingSelectionTile<DvConversionModePreference>(
|
||||||
pref: SettingsService.dvConversionMode,
|
pref: SettingsService.dvConversionMode,
|
||||||
icon: Symbols.hdr_strong_rounded,
|
icon: Symbols.hdr_strong_rounded,
|
||||||
title: t.settings.dvConversionMode,
|
title: t.settings.dvConversionMode,
|
||||||
@@ -399,8 +397,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
options: DvConversionModePreference.values
|
options: DvConversionModePreference.values
|
||||||
.map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m)))
|
.map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m)))
|
||||||
.toList(),
|
.toList(),
|
||||||
decode: (m) => m,
|
|
||||||
encode: (m) => m,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) {
|
String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) {
|
||||||
@@ -412,7 +408,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
|
|
||||||
Widget _bufferSizeTile() {
|
Widget _bufferSizeTile() {
|
||||||
final bufferOptions = const [0, 64, 128, 256, 512, 1024];
|
final bufferOptions = const [0, 64, 128, 256, 512, 1024];
|
||||||
return SettingSelectionTile<int, int>(
|
return SettingSelectionTile<int>(
|
||||||
pref: SettingsService.bufferSize,
|
pref: SettingsService.bufferSize,
|
||||||
icon: Symbols.memory_rounded,
|
icon: Symbols.memory_rounded,
|
||||||
title: t.settings.bufferSize,
|
title: t.settings.bufferSize,
|
||||||
@@ -420,8 +416,6 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
options: bufferOptions
|
options: bufferOptions
|
||||||
.map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB'))
|
.map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB'))
|
||||||
.toList(),
|
.toList(),
|
||||||
decode: (s) => s,
|
|
||||||
encode: (s) => s,
|
|
||||||
onAfterWrite: (value) async {
|
onAfterWrite: (value) async {
|
||||||
if (Platform.isAndroid && value > 0) {
|
if (Platform.isAndroid && value > 0) {
|
||||||
final heapMB = await PlayerAndroid.getHeapSize();
|
final heapMB = await PlayerAndroid.getHeapSize();
|
||||||
@@ -433,7 +427,7 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _defaultQualityTile() => SettingSelectionTile<TranscodeQualityPreset, TranscodeQualityPreset>(
|
Widget _defaultQualityTile() => SettingSelectionTile<TranscodeQualityPreset>(
|
||||||
pref: SettingsService.defaultQualityPreset,
|
pref: SettingsService.defaultQualityPreset,
|
||||||
icon: Symbols.high_quality_rounded,
|
icon: Symbols.high_quality_rounded,
|
||||||
title: t.settings.defaultQualityTitle,
|
title: t.settings.defaultQualityTitle,
|
||||||
@@ -441,18 +435,14 @@ class _PlaybackSettingsScreenState extends State<PlaybackSettingsScreen> {
|
|||||||
options: TranscodeQualityPreset.displayOrder
|
options: TranscodeQualityPreset.displayOrder
|
||||||
.map((p) => DialogOption(value: p, title: qualityPresetLabel(p)))
|
.map((p) => DialogOption(value: p, title: qualityPresetLabel(p)))
|
||||||
.toList(),
|
.toList(),
|
||||||
decode: (p) => p,
|
|
||||||
encode: (p) => p,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
Widget _musicQualityTile() => SettingSelectionTile<AudioQualityPreset, AudioQualityPreset>(
|
Widget _musicQualityTile() => SettingSelectionTile<AudioQualityPreset>(
|
||||||
pref: SettingsService.musicQualityPreset,
|
pref: SettingsService.musicQualityPreset,
|
||||||
icon: Symbols.music_note_rounded,
|
icon: Symbols.music_note_rounded,
|
||||||
title: t.settings.musicQualityTitle,
|
title: t.settings.musicQualityTitle,
|
||||||
subtitleBuilder: _musicQualityLabel,
|
subtitleBuilder: _musicQualityLabel,
|
||||||
options: AudioQualityPreset.values.map((p) => DialogOption(value: p, title: _musicQualityLabel(p))).toList(),
|
options: AudioQualityPreset.values.map((p) => DialogOption(value: p, title: _musicQualityLabel(p))).toList(),
|
||||||
decode: (p) => p,
|
|
||||||
encode: (p) => p,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
String _musicQualityLabel(AudioQualityPreset preset) =>
|
String _musicQualityLabel(AudioQualityPreset preset) =>
|
||||||
|
|||||||
@@ -3,9 +3,8 @@ import 'package:material_symbols_icons/symbols.dart';
|
|||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
import '../../i18n/strings.g.dart';
|
import '../../i18n/strings.g.dart';
|
||||||
|
import '../../models/catalog/catalog_item.dart';
|
||||||
import '../../providers/seerr_account_provider.dart';
|
import '../../providers/seerr_account_provider.dart';
|
||||||
import '../../providers/trackers_provider.dart';
|
|
||||||
import '../../providers/trakt_account_provider.dart';
|
|
||||||
import '../../widgets/app_icon.dart';
|
import '../../widgets/app_icon.dart';
|
||||||
import '../../widgets/catalog_source_logo.dart';
|
import '../../widgets/catalog_source_logo.dart';
|
||||||
import '../../widgets/focused_scroll_scaffold.dart';
|
import '../../widgets/focused_scroll_scaffold.dart';
|
||||||
@@ -13,8 +12,7 @@ import '../../widgets/focusable_list_tile.dart';
|
|||||||
import '../../widgets/settings_section.dart';
|
import '../../widgets/settings_section.dart';
|
||||||
import 'seerr_connect_screen.dart';
|
import 'seerr_connect_screen.dart';
|
||||||
import 'seerr_settings_screen.dart';
|
import 'seerr_settings_screen.dart';
|
||||||
import 'tracker_settings_screen.dart';
|
import 'tracker_service_info.dart';
|
||||||
import 'trakt_settings_screen.dart';
|
|
||||||
|
|
||||||
/// Unified hub for all connected services: the watch-progress trackers
|
/// Unified hub for all connected services: the watch-progress trackers
|
||||||
/// (Trakt, MyAnimeList, AniList, Simkl) and the Seerr request server. Each
|
/// (Trakt, MyAnimeList, AniList, Simkl) and the Seerr request server. Each
|
||||||
@@ -38,7 +36,7 @@ class ServicesSettingsScreen extends StatelessWidget {
|
|||||||
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SettingsGroup(children: [_trakt(), _mal(), _anilist(), _simkl(), _seerr()]),
|
SettingsGroup(children: [for (final info in TrackerServiceInfo.all) _TrackerHubRow(info), _seerr()]),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
@@ -46,78 +44,9 @@ class ServicesSettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _trakt() => Consumer<TraktAccountProvider>(
|
|
||||||
builder: (context, account, _) => _ServiceHubRow(
|
|
||||||
leading: const CatalogSourceLogo.asset('assets/trakt_circlemark.svg', size: 24),
|
|
||||||
title: t.trakt.title,
|
|
||||||
username: account.isConnected ? account.username : null,
|
|
||||||
onTap: () {
|
|
||||||
if (account.isConnected) {
|
|
||||||
Navigator.push(context, MaterialPageRoute(builder: (_) => const TraktSettingsScreen()));
|
|
||||||
} else {
|
|
||||||
startTraktConnection(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _mal() => Consumer<TrackersProvider>(
|
|
||||||
builder: (context, account, _) => _ServiceHubRow(
|
|
||||||
leading: const CatalogSourceLogo.asset('assets/mal_mark.svg', size: 24),
|
|
||||||
title: t.services.names.mal,
|
|
||||||
username: account.isMalConnected ? account.malUsername : null,
|
|
||||||
onTap: () {
|
|
||||||
if (account.isMalConnected) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.mal())),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
startMalConnection(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _anilist() => Consumer<TrackersProvider>(
|
|
||||||
builder: (context, account, _) => _ServiceHubRow(
|
|
||||||
leading: const CatalogSourceLogo.asset('assets/anilist_mark.svg', size: 24),
|
|
||||||
title: t.services.names.anilist,
|
|
||||||
username: account.isAnilistConnected ? account.anilistUsername : null,
|
|
||||||
onTap: () {
|
|
||||||
if (account.isAnilistConnected) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.anilist())),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
startAnilistConnection(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _simkl() => Consumer<TrackersProvider>(
|
|
||||||
builder: (context, account, _) => _ServiceHubRow(
|
|
||||||
leading: const CatalogSourceLogo.asset('assets/simkl_mark.svg', size: 24),
|
|
||||||
title: t.services.names.simkl,
|
|
||||||
username: account.isSimklConnected ? account.simklUsername : null,
|
|
||||||
onTap: () {
|
|
||||||
if (account.isSimklConnected) {
|
|
||||||
Navigator.push(
|
|
||||||
context,
|
|
||||||
MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.simkl())),
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
startSimklConnection(context);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Widget _seerr() => Consumer<SeerrAccountProvider>(
|
Widget _seerr() => Consumer<SeerrAccountProvider>(
|
||||||
builder: (context, account, _) => _ServiceHubRow(
|
builder: (context, account, _) => _ServiceHubRow(
|
||||||
leading: const CatalogSourceLogo.asset('assets/seerr_mark.svg', size: 24),
|
leading: const CatalogSourceLogo(CatalogSourceId.seerr, size: 24),
|
||||||
title: t.services.names.seerr,
|
title: t.services.names.seerr,
|
||||||
username: account.isConnected ? account.displayName : null,
|
username: account.isConnected ? account.displayName : null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -132,6 +61,31 @@ class ServicesSettingsScreen extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Hub row for a watch tracker. Owns the `watch` on that service's account
|
||||||
|
/// provider so only this row rebuilds when the connection state changes.
|
||||||
|
class _TrackerHubRow extends StatelessWidget {
|
||||||
|
final TrackerServiceInfo info;
|
||||||
|
|
||||||
|
const _TrackerHubRow(this.info);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final connected = info.isConnected(context);
|
||||||
|
return _ServiceHubRow(
|
||||||
|
leading: CatalogSourceLogo(info.logoSource, size: 24),
|
||||||
|
title: info.displayName,
|
||||||
|
username: connected ? info.username(context) : null,
|
||||||
|
onTap: () {
|
||||||
|
if (connected) {
|
||||||
|
Navigator.push(context, MaterialPageRoute(builder: (_) => info.buildSettingsScreen()));
|
||||||
|
} else {
|
||||||
|
info.startConnection(context);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _ServiceHubRow extends StatelessWidget {
|
class _ServiceHubRow extends StatelessWidget {
|
||||||
final Widget leading;
|
final Widget leading;
|
||||||
final String title;
|
final String title;
|
||||||
|
|||||||
@@ -26,12 +26,9 @@ import '../../services/saf_storage_service.dart';
|
|||||||
import '../../services/settings_export_service.dart';
|
import '../../services/settings_export_service.dart';
|
||||||
import '../../providers/theme_provider.dart';
|
import '../../providers/theme_provider.dart';
|
||||||
import '../../providers/seerr_account_provider.dart';
|
import '../../providers/seerr_account_provider.dart';
|
||||||
import '../../providers/trackers_provider.dart';
|
|
||||||
import '../../providers/trakt_account_provider.dart';
|
|
||||||
import '../../services/keyboard_shortcuts_service.dart';
|
import '../../services/keyboard_shortcuts_service.dart';
|
||||||
import '../../services/settings_service.dart' as settings;
|
import '../../services/settings_service.dart' as settings;
|
||||||
import '../../services/update_service.dart';
|
import '../../services/update_service.dart';
|
||||||
import '../../utils/app_logger.dart';
|
|
||||||
import '../../utils/dialogs.dart';
|
import '../../utils/dialogs.dart';
|
||||||
import '../../utils/snackbar_helper.dart';
|
import '../../utils/snackbar_helper.dart';
|
||||||
import '../../utils/platform_detector.dart';
|
import '../../utils/platform_detector.dart';
|
||||||
@@ -55,6 +52,7 @@ import 'playback_settings_screen.dart';
|
|||||||
import '../profile/profile_switch_screen.dart';
|
import '../profile/profile_switch_screen.dart';
|
||||||
import 'services_settings_screen.dart';
|
import 'services_settings_screen.dart';
|
||||||
import 'settings_utils.dart';
|
import 'settings_utils.dart';
|
||||||
|
import 'tracker_service_info.dart';
|
||||||
import '../../widgets/loading_indicator_box.dart';
|
import '../../widgets/loading_indicator_box.dart';
|
||||||
|
|
||||||
class SettingsScreen extends StatefulWidget {
|
class SettingsScreen extends StatefulWidget {
|
||||||
@@ -263,13 +261,12 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildServicesTile() {
|
Widget _buildServicesTile() {
|
||||||
return Consumer3<TraktAccountProvider, TrackersProvider, SeerrAccountProvider>(
|
// The tracker account providers are watched through [TrackerServiceInfo].
|
||||||
builder: (context, trakt, trackers, seerr, _) {
|
return Consumer<SeerrAccountProvider>(
|
||||||
|
builder: (context, seerr, _) {
|
||||||
final connectedNames = <String>[
|
final connectedNames = <String>[
|
||||||
if (trakt.isConnected) t.trakt.title,
|
for (final info in TrackerServiceInfo.all)
|
||||||
if (trackers.isMalConnected) t.services.names.mal,
|
if (info.isConnected(context)) info.displayName,
|
||||||
if (trackers.isAnilistConnected) t.services.names.anilist,
|
|
||||||
if (trackers.isSimklConnected) t.services.names.simkl,
|
|
||||||
if (seerr.isConnected) t.services.names.seerr,
|
if (seerr.isConnected) t.services.names.seerr,
|
||||||
];
|
];
|
||||||
final subtitle = connectedNames.isEmpty ? t.settings.servicesDescription : connectedNames.join(' · ');
|
final subtitle = connectedNames.isEmpty ? t.settings.servicesDescription : connectedNames.join(' · ');
|
||||||
@@ -614,65 +611,50 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<bool> _selectDownloadLocation() async {
|
Future<bool> _selectDownloadLocation() async {
|
||||||
try {
|
final changed = await guardSettingsOperation<bool, DownloadStorageException>(
|
||||||
String? selectedPath;
|
context,
|
||||||
String pathType = 'file';
|
operation: 'Download directory selection',
|
||||||
|
body: () async {
|
||||||
|
String? selectedPath;
|
||||||
|
String pathType = 'file';
|
||||||
|
|
||||||
if (Platform.isAndroid) {
|
if (Platform.isAndroid) {
|
||||||
final safStorage = SafStorageService.instance;
|
final safStorage = SafStorageService.instance;
|
||||||
if (!safStorage.supportsDirectoryPicker) {
|
if (!safStorage.supportsDirectoryPicker) {
|
||||||
showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable);
|
showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable);
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
|
selectedPath = await safStorage.pickDirectory();
|
||||||
|
if (!mounted) return false;
|
||||||
|
if (selectedPath != null) pathType = 'saf';
|
||||||
|
} else {
|
||||||
|
selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder);
|
||||||
|
if (!mounted) return false;
|
||||||
}
|
}
|
||||||
selectedPath = await safStorage.pickDirectory();
|
if (selectedPath == null) return false;
|
||||||
if (!mounted) return false;
|
|
||||||
if (selectedPath != null) pathType = 'saf';
|
|
||||||
} else {
|
|
||||||
selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder);
|
|
||||||
if (!mounted) return false;
|
|
||||||
}
|
|
||||||
if (selectedPath == null) return false;
|
|
||||||
|
|
||||||
if (pathType == 'file') {
|
if (pathType == 'file') {
|
||||||
final dir = Directory(selectedPath);
|
final dir = Directory(selectedPath);
|
||||||
final isWritable =
|
final writableChecker =
|
||||||
await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)(dir);
|
widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable;
|
||||||
if (!mounted) return false;
|
final isWritable = await writableChecker(dir);
|
||||||
if (!isWritable) {
|
if (!mounted) return false;
|
||||||
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
|
if (!isWritable) {
|
||||||
return false;
|
showErrorSnackBar(context, t.settings.downloadLocationInvalid);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
await context.read<DownloadProvider>().setDownloadLocation(path: selectedPath, pathType: pathType);
|
await context.read<DownloadProvider>().setDownloadLocation(path: selectedPath, pathType: pathType);
|
||||||
if (!mounted) return false;
|
if (!mounted) return false;
|
||||||
|
|
||||||
// ignore: no-empty-block - setState triggers rebuild to reflect new download path
|
// ignore: no-empty-block - setState triggers rebuild to reflect new download path
|
||||||
setState(() {});
|
setState(() {});
|
||||||
showSuccessSnackBar(context, t.settings.downloadLocationChanged);
|
showSuccessSnackBar(context, t.settings.downloadLocationChanged);
|
||||||
return true;
|
return true;
|
||||||
} on DownloadStorageException catch (error, stackTrace) {
|
},
|
||||||
if (!mounted) {
|
);
|
||||||
appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace);
|
return changed ?? false;
|
||||||
return false;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace);
|
|
||||||
return false;
|
|
||||||
} on PlatformException catch (error, stackTrace) {
|
|
||||||
if (!mounted) {
|
|
||||||
appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace);
|
|
||||||
return false;
|
|
||||||
} on FileSystemException catch (error, stackTrace) {
|
|
||||||
if (!mounted) {
|
|
||||||
appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _resetDownloadLocation() async {
|
Future<void> _resetDownloadLocation() async {
|
||||||
@@ -720,29 +702,15 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleExportSettings() async {
|
Future<void> _handleExportSettings() async {
|
||||||
try {
|
await guardSettingsOperation<void, SettingsExportException>(
|
||||||
final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)();
|
context,
|
||||||
if (!mounted || path == null) return;
|
operation: 'Settings export',
|
||||||
showSuccessSnackBar(context, t.settings.exportSettingsSuccess);
|
body: () async {
|
||||||
} on SettingsExportException catch (error, stackTrace) {
|
final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)();
|
||||||
if (!mounted) {
|
if (!mounted || path == null) return;
|
||||||
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
|
showSuccessSnackBar(context, t.settings.exportSettingsSuccess);
|
||||||
return;
|
},
|
||||||
}
|
);
|
||||||
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
|
|
||||||
} on PlatformException catch (error, stackTrace) {
|
|
||||||
if (!mounted) {
|
|
||||||
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
|
|
||||||
} on FileSystemException catch (error, stackTrace) {
|
|
||||||
if (!mounted) {
|
|
||||||
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _showImportSettingsDialog() async {
|
Future<void> _showImportSettingsDialog() async {
|
||||||
@@ -757,51 +725,41 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _handleImportSettings() async {
|
Future<void> _handleImportSettings() async {
|
||||||
try {
|
await guardSettingsOperation<void, SettingsExportException>(
|
||||||
final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)();
|
context,
|
||||||
if (!mounted) return;
|
operation: 'Settings import',
|
||||||
if (result == null) return; // user cancelled file picker
|
body: () async {
|
||||||
|
// The two typed import failures carry their own message, so they are
|
||||||
|
// handled here instead of falling through to the generic guard.
|
||||||
|
try {
|
||||||
|
final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)();
|
||||||
|
if (!mounted) return;
|
||||||
|
if (result == null) return; // user cancelled file picker
|
||||||
|
|
||||||
final themeProvider = context.read<ThemeProvider>();
|
final themeProvider = context.read<ThemeProvider>();
|
||||||
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
final hiddenLibrariesProvider = context.read<HiddenLibrariesProvider>();
|
||||||
final librariesProvider = context.read<LibrariesProvider>();
|
final librariesProvider = context.read<LibrariesProvider>();
|
||||||
|
|
||||||
// Import wrote directly to SharedPreferences, bypassing `write`. Push
|
// Import wrote directly to SharedPreferences, bypassing `write`. Push
|
||||||
// fresh values into active listenables before providers re-read settings.
|
// fresh values into active listenables before providers re-read settings.
|
||||||
_settingsService.refreshListenables();
|
_settingsService.refreshListenables();
|
||||||
unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale)));
|
unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale)));
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
themeProvider.reload(),
|
themeProvider.reload(),
|
||||||
hiddenLibrariesProvider.refresh(),
|
hiddenLibrariesProvider.refresh(),
|
||||||
if (_keyboardService != null) _keyboardService!.refreshFromStorage(),
|
if (_keyboardService != null) _keyboardService!.refreshFromStorage(),
|
||||||
]);
|
]);
|
||||||
unawaited(librariesProvider.refresh());
|
unawaited(librariesProvider.refresh());
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
showSuccessSnackBar(context, t.settings.importSettingsSuccess);
|
showSuccessSnackBar(context, t.settings.importSettingsSuccess);
|
||||||
} on NoUserSignedInException {
|
} on NoUserSignedInException {
|
||||||
if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser);
|
if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser);
|
||||||
} on InvalidExportFileException {
|
} on InvalidExportFileException {
|
||||||
if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile);
|
if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile);
|
||||||
} on SettingsExportException catch (error, stackTrace) {
|
}
|
||||||
if (!mounted) {
|
},
|
||||||
appLogger.e('Settings import failed', error: error, stackTrace: stackTrace);
|
);
|
||||||
return;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace);
|
|
||||||
} on PlatformException catch (error, stackTrace) {
|
|
||||||
if (!mounted) {
|
|
||||||
appLogger.e('Settings import failed', error: error, stackTrace: stackTrace);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace);
|
|
||||||
} on FileSystemException catch (error, stackTrace) {
|
|
||||||
if (!mounted) {
|
|
||||||
appLogger.e('Settings import failed', error: error, stackTrace: stackTrace);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _checkForUpdates() async {
|
Future<void> _checkForUpdates() async {
|
||||||
|
|||||||
@@ -56,6 +56,32 @@ void showSettingsFailure(
|
|||||||
if (context.mounted) showErrorSnackBar(context, t.settings.saveFailed);
|
if (context.mounted) showErrorSnackBar(context, t.settings.saveFailed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Runs [body] and reports the recoverable failures that every settings
|
||||||
|
/// file/platform operation shares — [PlatformException], [FileSystemException]
|
||||||
|
/// and the site-specific domain exception [E] — through [showSettingsFailure].
|
||||||
|
/// Any other exception type is rethrown so programming errors are not swallowed.
|
||||||
|
///
|
||||||
|
/// [context] is resolved before [body] starts, so a failure that lands after the
|
||||||
|
/// caller was disposed is still logged; only the snackbar is skipped. Returns
|
||||||
|
/// `null` when the operation failed.
|
||||||
|
Future<T?> guardSettingsOperation<T, E extends Object>(
|
||||||
|
BuildContext context, {
|
||||||
|
required String operation,
|
||||||
|
required Future<T> Function() body,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
return await body();
|
||||||
|
} on Object catch (error, stackTrace) {
|
||||||
|
if (error is! E && error is! PlatformException && error is! FileSystemException) rethrow;
|
||||||
|
if (context.mounted) {
|
||||||
|
showSettingsFailure(context, operation: operation, error: error, stackTrace: stackTrace);
|
||||||
|
} else {
|
||||||
|
appLogger.e('$operation failed', error: error, stackTrace: stackTrace);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _showSettingsInputDialog({
|
void _showSettingsInputDialog({
|
||||||
required BuildContext context,
|
required BuildContext context,
|
||||||
required String title,
|
required String title,
|
||||||
|
|||||||
@@ -48,18 +48,16 @@ class SubtitleStylingScreen extends StatelessWidget {
|
|||||||
SettingsGroup(
|
SettingsGroup(
|
||||||
title: t.subtitlingStyling.text,
|
title: t.subtitlingStyling.text,
|
||||||
children: [
|
children: [
|
||||||
SettingSelectionTile<SubAssOverride, SubAssOverride>(
|
SettingSelectionTile<SubAssOverride>(
|
||||||
pref: SettingsService.subAssOverride,
|
pref: SettingsService.subAssOverride,
|
||||||
icon: Symbols.subtitles_rounded,
|
icon: Symbols.subtitles_rounded,
|
||||||
title: t.subtitlingStyling.assOverride,
|
title: t.subtitlingStyling.assOverride,
|
||||||
subtitleBuilder: _assOverrideLabel,
|
subtitleBuilder: _assOverrideLabel,
|
||||||
options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(),
|
options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(),
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
),
|
),
|
||||||
// iOS/tvOS avfoundation VO: screen vs video-resolution basis.
|
// iOS/tvOS avfoundation VO: screen vs video-resolution basis.
|
||||||
if (Platform.isIOS)
|
if (Platform.isIOS)
|
||||||
SettingSelectionTile<SubtitleRenderResolution, SubtitleRenderResolution>(
|
SettingSelectionTile<SubtitleRenderResolution>(
|
||||||
pref: SettingsService.subtitleRenderResolution,
|
pref: SettingsService.subtitleRenderResolution,
|
||||||
icon: Symbols.aspect_ratio_rounded,
|
icon: Symbols.aspect_ratio_rounded,
|
||||||
title: t.subtitlingStyling.renderResolution,
|
title: t.subtitlingStyling.renderResolution,
|
||||||
@@ -68,13 +66,11 @@ class SubtitleStylingScreen extends StatelessWidget {
|
|||||||
SubtitleRenderResolution.screen,
|
SubtitleRenderResolution.screen,
|
||||||
SubtitleRenderResolution.video,
|
SubtitleRenderResolution.video,
|
||||||
].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(),
|
].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(),
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
),
|
),
|
||||||
// Android libass overlay: full or a fractional render scale (perf knob for
|
// Android libass overlay: full or a fractional render scale (perf knob for
|
||||||
// render-bound low-end TVs; heavy/animated signs raster faster at < 1).
|
// render-bound low-end TVs; heavy/animated signs raster faster at < 1).
|
||||||
if (Platform.isAndroid)
|
if (Platform.isAndroid)
|
||||||
SettingSelectionTile<SubtitleRenderResolution, SubtitleRenderResolution>(
|
SettingSelectionTile<SubtitleRenderResolution>(
|
||||||
pref: SettingsService.subtitleRenderResolution,
|
pref: SettingsService.subtitleRenderResolution,
|
||||||
icon: Symbols.aspect_ratio_rounded,
|
icon: Symbols.aspect_ratio_rounded,
|
||||||
title: t.subtitlingStyling.renderResolution,
|
title: t.subtitlingStyling.renderResolution,
|
||||||
@@ -86,8 +82,6 @@ class SubtitleStylingScreen extends StatelessWidget {
|
|||||||
SubtitleRenderResolution.third,
|
SubtitleRenderResolution.third,
|
||||||
SubtitleRenderResolution.quarter,
|
SubtitleRenderResolution.quarter,
|
||||||
].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(),
|
].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(),
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
),
|
),
|
||||||
SettingNumberTile(
|
SettingNumberTile(
|
||||||
pref: SettingsService.subtitleFontSize,
|
pref: SettingsService.subtitleFontSize,
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class TrackerLibraryFilterScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
SettingsGroup(
|
SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
SettingSegmentedTile<TrackerLibraryFilterMode, TrackerLibraryFilterMode>(
|
SettingSegmentedTile<TrackerLibraryFilterMode>(
|
||||||
pref: modePref,
|
pref: modePref,
|
||||||
icon: Symbols.filter_list_rounded,
|
icon: Symbols.filter_list_rounded,
|
||||||
title: t.services.libraryFilter.mode,
|
title: t.services.libraryFilter.mode,
|
||||||
@@ -100,8 +100,6 @@ class TrackerLibraryFilterScreen extends StatelessWidget {
|
|||||||
label: Text(t.services.libraryFilter.modeWhitelist),
|
label: Text(t.services.libraryFilter.modeWhitelist),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
decode: (v) => v,
|
|
||||||
encode: (v) => v,
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import '../../i18n/strings.g.dart';
|
||||||
|
import '../../models/catalog/catalog_item.dart';
|
||||||
|
import '../../providers/trackers_provider.dart';
|
||||||
|
import '../../providers/trakt_account_provider.dart';
|
||||||
|
import '../../services/trackers/anilist/anilist_tracker.dart';
|
||||||
|
import '../../services/trackers/mal/mal_tracker.dart';
|
||||||
|
import '../../services/trackers/simkl/simkl_tracker.dart';
|
||||||
|
import '../../services/trackers/tracker.dart';
|
||||||
|
import '../../services/trackers/tracker_constants.dart';
|
||||||
|
import '../../services/trakt/trakt_scrobble_service.dart';
|
||||||
|
import 'tracker_settings_screen.dart';
|
||||||
|
import 'trakt_settings_screen.dart';
|
||||||
|
|
||||||
|
/// One watch tracker, described once for every place that lists services: the
|
||||||
|
/// services hub, the rating sheet, and the settings summary line.
|
||||||
|
///
|
||||||
|
/// [isConnected] and [username] take a [BuildContext] because each service
|
||||||
|
/// keeps its account state on a different provider; they read it with `watch`,
|
||||||
|
/// so the calling element rebuilds exactly like the per-service `Consumer`
|
||||||
|
/// these entries replaced.
|
||||||
|
class TrackerServiceInfo {
|
||||||
|
final TrackerService service;
|
||||||
|
final String displayName;
|
||||||
|
|
||||||
|
/// Which brand mark to draw; the asset path itself lives only in
|
||||||
|
/// `CatalogSourceLogo`.
|
||||||
|
final CatalogSourceId logoSource;
|
||||||
|
|
||||||
|
final TrackerRatingSource ratingSource;
|
||||||
|
final bool Function(BuildContext) isConnected;
|
||||||
|
final String? Function(BuildContext) username;
|
||||||
|
final Future<void> Function(BuildContext) startConnection;
|
||||||
|
final Widget Function() buildSettingsScreen;
|
||||||
|
|
||||||
|
const TrackerServiceInfo({
|
||||||
|
required this.service,
|
||||||
|
required this.displayName,
|
||||||
|
required this.logoSource,
|
||||||
|
required this.ratingSource,
|
||||||
|
required this.isConnected,
|
||||||
|
required this.username,
|
||||||
|
required this.startConnection,
|
||||||
|
required this.buildSettingsScreen,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Entry for a service that shares [TrackerSettingsScreen]: [config] already
|
||||||
|
/// carries the name and the [TrackersProvider] accessors.
|
||||||
|
TrackerServiceInfo.shared(
|
||||||
|
TrackerConfig config, {
|
||||||
|
required this.logoSource,
|
||||||
|
required this.ratingSource,
|
||||||
|
required this.startConnection,
|
||||||
|
}) : service = config.service,
|
||||||
|
displayName = config.displayName,
|
||||||
|
isConnected = ((context) => config.isConnected(context.watch<TrackersProvider>())),
|
||||||
|
username = ((context) => config.username(context.watch<TrackersProvider>())),
|
||||||
|
buildSettingsScreen = (() => TrackerSettingsScreen(config: config));
|
||||||
|
|
||||||
|
/// Display order shared by every list. Built per call because [displayName]
|
||||||
|
/// reads the active locale.
|
||||||
|
static List<TrackerServiceInfo> get all => [
|
||||||
|
TrackerServiceInfo(
|
||||||
|
service: TrackerService.trakt,
|
||||||
|
displayName: t.trakt.title,
|
||||||
|
logoSource: CatalogSourceId.trakt,
|
||||||
|
ratingSource: TraktScrobbleService.instance,
|
||||||
|
isConnected: (context) => context.watch<TraktAccountProvider>().isConnected,
|
||||||
|
username: (context) => context.watch<TraktAccountProvider>().username,
|
||||||
|
startConnection: startTraktConnection,
|
||||||
|
buildSettingsScreen: () => const TraktSettingsScreen(),
|
||||||
|
),
|
||||||
|
TrackerServiceInfo.shared(
|
||||||
|
TrackerConfig.mal(),
|
||||||
|
logoSource: CatalogSourceId.mal,
|
||||||
|
ratingSource: MalTracker.instance,
|
||||||
|
startConnection: startMalConnection,
|
||||||
|
),
|
||||||
|
TrackerServiceInfo.shared(
|
||||||
|
TrackerConfig.anilist(),
|
||||||
|
logoSource: CatalogSourceId.anilist,
|
||||||
|
ratingSource: AnilistTracker.instance,
|
||||||
|
startConnection: startAnilistConnection,
|
||||||
|
),
|
||||||
|
TrackerServiceInfo.shared(
|
||||||
|
TrackerConfig.simkl(),
|
||||||
|
logoSource: CatalogSourceId.simkl,
|
||||||
|
ratingSource: SimklTracker.instance,
|
||||||
|
startConnection: startSimklConnection,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -67,7 +67,6 @@ class TrackerConfig {
|
|||||||
final String displayName;
|
final String displayName;
|
||||||
final bool Function(TrackersProvider) isConnected;
|
final bool Function(TrackersProvider) isConnected;
|
||||||
final String? Function(TrackersProvider) username;
|
final String? Function(TrackersProvider) username;
|
||||||
final Pref<bool> scrobblePref;
|
|
||||||
final Future<void> Function(bool) onScrobbleChanged;
|
final Future<void> Function(bool) onScrobbleChanged;
|
||||||
final Future<void> Function(TrackersProvider) disconnect;
|
final Future<void> Function(TrackersProvider) disconnect;
|
||||||
|
|
||||||
@@ -76,17 +75,17 @@ class TrackerConfig {
|
|||||||
required this.displayName,
|
required this.displayName,
|
||||||
required this.isConnected,
|
required this.isConnected,
|
||||||
required this.username,
|
required this.username,
|
||||||
required this.scrobblePref,
|
|
||||||
required this.onScrobbleChanged,
|
required this.onScrobbleChanged,
|
||||||
required this.disconnect,
|
required this.disconnect,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Pref<bool> get scrobblePref => SettingsService.scrobblePref(service);
|
||||||
|
|
||||||
static TrackerConfig mal() => TrackerConfig(
|
static TrackerConfig mal() => TrackerConfig(
|
||||||
service: TrackerService.mal,
|
service: TrackerService.mal,
|
||||||
displayName: t.services.names.mal,
|
displayName: t.services.names.mal,
|
||||||
isConnected: (a) => a.isMalConnected,
|
isConnected: (a) => a.isMalConnected,
|
||||||
username: (a) => a.malUsername,
|
username: (a) => a.malUsername,
|
||||||
scrobblePref: SettingsService.enableMalScrobble,
|
|
||||||
onScrobbleChanged: MalTracker.instance.setEnabled,
|
onScrobbleChanged: MalTracker.instance.setEnabled,
|
||||||
disconnect: (a) => a.disconnectMal(),
|
disconnect: (a) => a.disconnectMal(),
|
||||||
);
|
);
|
||||||
@@ -96,7 +95,6 @@ class TrackerConfig {
|
|||||||
displayName: t.services.names.anilist,
|
displayName: t.services.names.anilist,
|
||||||
isConnected: (a) => a.isAnilistConnected,
|
isConnected: (a) => a.isAnilistConnected,
|
||||||
username: (a) => a.anilistUsername,
|
username: (a) => a.anilistUsername,
|
||||||
scrobblePref: SettingsService.enableAnilistScrobble,
|
|
||||||
onScrobbleChanged: AnilistTracker.instance.setEnabled,
|
onScrobbleChanged: AnilistTracker.instance.setEnabled,
|
||||||
disconnect: (a) => a.disconnectAnilist(),
|
disconnect: (a) => a.disconnectAnilist(),
|
||||||
);
|
);
|
||||||
@@ -106,7 +104,6 @@ class TrackerConfig {
|
|||||||
displayName: t.services.names.simkl,
|
displayName: t.services.names.simkl,
|
||||||
isConnected: (a) => a.isSimklConnected,
|
isConnected: (a) => a.isSimklConnected,
|
||||||
username: (a) => a.simklUsername,
|
username: (a) => a.simklUsername,
|
||||||
scrobblePref: SettingsService.enableSimklScrobble,
|
|
||||||
onScrobbleChanged: SimklTracker.instance.setEnabled,
|
onScrobbleChanged: SimklTracker.instance.setEnabled,
|
||||||
disconnect: (a) => a.disconnectSimkl(),
|
disconnect: (a) => a.disconnectSimkl(),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ class TraktSettingsScreen extends StatelessWidget {
|
|||||||
service: TrackerService.trakt,
|
service: TrackerService.trakt,
|
||||||
toggles: [
|
toggles: [
|
||||||
TrackerSettingsToggle(
|
TrackerSettingsToggle(
|
||||||
pref: SettingsService.enableTraktScrobble,
|
pref: SettingsService.scrobblePref(TrackerService.trakt),
|
||||||
icon: Symbols.auto_timer_rounded,
|
icon: Symbols.auto_timer_rounded,
|
||||||
title: t.trakt.scrobble,
|
title: t.trakt.scrobble,
|
||||||
subtitle: t.trakt.scrobbleDescription,
|
subtitle: t.trakt.scrobbleDescription,
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState {
|
|||||||
_lastVideoLayoutSize = pendingSize;
|
_lastVideoLayoutSize = pendingSize;
|
||||||
_lastVideoLayoutPlayer = currentPlayer;
|
_lastVideoLayoutPlayer = currentPlayer;
|
||||||
_videoFilterManager?.updatePlayerSize(pendingSize);
|
_videoFilterManager?.updatePlayerSize(pendingSize);
|
||||||
_videoPIPManager?.updatePlayerSize(pendingSize);
|
|
||||||
_updateAmbientLightingOnResize(pendingSize);
|
_updateAmbientLightingOnResize(pendingSize);
|
||||||
unawaited(currentPlayer.updateFrame());
|
unawaited(currentPlayer.updateFrame());
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
|||||||
unawaited(_videoFilterManager!.updateVideoFilter());
|
unawaited(_videoFilterManager!.updateVideoFilter());
|
||||||
}
|
}
|
||||||
|
|
||||||
_videoPIPManager ??= VideoPIPManager(player: currentPlayer, initialPlayerSize: initialPlayerSize);
|
_videoPIPManager ??= VideoPIPManager(
|
||||||
|
player: currentPlayer,
|
||||||
|
playerSize: () => _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null,
|
||||||
|
);
|
||||||
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
|
_videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry;
|
||||||
_attachPipStateListener();
|
_attachPipStateListener();
|
||||||
}
|
}
|
||||||
@@ -92,7 +95,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value;
|
final isInPip = PipService().isPipActive.value;
|
||||||
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
|
_setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed');
|
||||||
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
|
_recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited');
|
||||||
|
|
||||||
|
|||||||
@@ -468,7 +468,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState {
|
|||||||
final mediaControlsManager = MediaControlsManager();
|
final mediaControlsManager = MediaControlsManager();
|
||||||
_mediaControlsManager = mediaControlsManager;
|
_mediaControlsManager = mediaControlsManager;
|
||||||
|
|
||||||
final mediaControlRouter = VideoPlayerMediaControlRouter(
|
final mediaControlRouter = MediaControlRouter(
|
||||||
canControlPlayback: _canControlPlayback,
|
canControlPlayback: _canControlPlayback,
|
||||||
canNavigateMediaItems: _canNavigateMediaItems,
|
canNavigateMediaItems: _canNavigateMediaItems,
|
||||||
onPlay: () {
|
onPlay: () {
|
||||||
|
|||||||
@@ -22,6 +22,36 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable ambient lighting for the current video/player geometry.
|
||||||
|
/// Returns false when the aspect ratios cannot be determined yet.
|
||||||
|
Future<bool> _enableAmbientLighting(AmbientLightingService ambientLighting, ShaderProvider shaderProvider) async {
|
||||||
|
// Get video display aspect ratio
|
||||||
|
final dwidth = await player?.getProperty('dwidth');
|
||||||
|
final dheight = await player?.getProperty('dheight');
|
||||||
|
if (dwidth == null || dheight == null) return false;
|
||||||
|
final w = double.tryParse(dwidth);
|
||||||
|
final h = double.tryParse(dheight);
|
||||||
|
if (w == null || h == null || h == 0) return false;
|
||||||
|
final videoAspect = w / h;
|
||||||
|
|
||||||
|
// Get player widget aspect ratio
|
||||||
|
final playerSize = _videoFilterManager?.playerSize;
|
||||||
|
if (playerSize == null || playerSize.height == 0) return false;
|
||||||
|
final outputAspect = playerSize.width / playerSize.height;
|
||||||
|
|
||||||
|
// Clear shaders — ambient lighting and shaders are mutually exclusive
|
||||||
|
if (shaderProvider.isShaderEnabled) {
|
||||||
|
await _shaderService!.applyPreset(ShaderPreset.none);
|
||||||
|
shaderProvider.setCurrentPreset(ShaderPreset.none);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force contain mode when enabling ambient lighting
|
||||||
|
_videoFilterManager?.resetToContain();
|
||||||
|
|
||||||
|
await ambientLighting.enable(videoAspect, outputAspect);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/// Restore ambient lighting from persisted setting
|
/// Restore ambient lighting from persisted setting
|
||||||
Future<void> _restoreAmbientLighting() async {
|
Future<void> _restoreAmbientLighting() async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
@@ -34,27 +64,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
|
|||||||
final ambientLighting = _ambientLightingService;
|
final ambientLighting = _ambientLightingService;
|
||||||
if (ambientLighting == null || !ambientLighting.isSupported) return;
|
if (ambientLighting == null || !ambientLighting.isSupported) return;
|
||||||
|
|
||||||
// Same enable logic as _toggleAmbientLighting
|
if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return;
|
||||||
final dwidth = await player?.getProperty('dwidth');
|
|
||||||
final dheight = await player?.getProperty('dheight');
|
|
||||||
if (dwidth == null || dheight == null) return;
|
|
||||||
final w = double.tryParse(dwidth);
|
|
||||||
final h = double.tryParse(dheight);
|
|
||||||
if (w == null || h == null || h == 0) return;
|
|
||||||
final videoAspect = w / h;
|
|
||||||
|
|
||||||
final playerSize = _videoFilterManager?.playerSize;
|
|
||||||
if (playerSize == null || playerSize.height == 0) return;
|
|
||||||
final outputAspect = playerSize.width / playerSize.height;
|
|
||||||
|
|
||||||
// Clear shaders — ambient lighting and shaders are mutually exclusive
|
|
||||||
if (shaderProvider.isShaderEnabled) {
|
|
||||||
await _shaderService!.applyPreset(ShaderPreset.none);
|
|
||||||
shaderProvider.setCurrentPreset(ShaderPreset.none);
|
|
||||||
}
|
|
||||||
|
|
||||||
_videoFilterManager?.resetToContain();
|
|
||||||
await ambientLighting.enable(videoAspect, outputAspect);
|
|
||||||
if (mounted) _setPlayerState(() {});
|
if (mounted) _setPlayerState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,30 +127,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState {
|
|||||||
await ambientLighting.disable();
|
await ambientLighting.disable();
|
||||||
unawaited(_videoFilterManager?.updateVideoFilter());
|
unawaited(_videoFilterManager?.updateVideoFilter());
|
||||||
} else {
|
} else {
|
||||||
// Get video display aspect ratio
|
if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return;
|
||||||
final dwidth = await player?.getProperty('dwidth');
|
|
||||||
final dheight = await player?.getProperty('dheight');
|
|
||||||
if (dwidth == null || dheight == null) return;
|
|
||||||
final w = double.tryParse(dwidth);
|
|
||||||
final h = double.tryParse(dheight);
|
|
||||||
if (w == null || h == null || h == 0) return;
|
|
||||||
final videoAspect = w / h;
|
|
||||||
|
|
||||||
// Get player widget aspect ratio
|
|
||||||
final playerSize = _videoFilterManager?.playerSize;
|
|
||||||
if (playerSize == null || playerSize.height == 0) return;
|
|
||||||
final outputAspect = playerSize.width / playerSize.height;
|
|
||||||
|
|
||||||
// Clear shaders — ambient lighting and shaders are mutually exclusive
|
|
||||||
if (shaderProvider.isShaderEnabled) {
|
|
||||||
await _shaderService!.applyPreset(ShaderPreset.none);
|
|
||||||
shaderProvider.setCurrentPreset(ShaderPreset.none);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Force contain mode when enabling ambient lighting
|
|
||||||
_videoFilterManager?.resetToContain();
|
|
||||||
|
|
||||||
await ambientLighting.enable(videoAspect, outputAspect);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist ambient lighting state
|
// Persist ambient lighting state
|
||||||
|
|||||||
@@ -188,85 +188,31 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<bool>(
|
final episode = nextEpisode;
|
||||||
valueListenable: PipService().isPipActive,
|
if (episode == null) return const SizedBox.shrink();
|
||||||
builder: (context, isInPip, child) {
|
return _VideoPlayerPromptShell(
|
||||||
final episode = nextEpisode;
|
visible: visible,
|
||||||
if (isInPip || !visible || episode == null) {
|
chromeController: chromeController,
|
||||||
return const SizedBox.shrink();
|
focusNodes: [cancelFocusNode, confirmFocusNode],
|
||||||
}
|
children: [
|
||||||
return _VideoPlayerPromptPosition(
|
_PlayNextEpisodeHeader(episode: episode),
|
||||||
chromeController: chromeController,
|
const SizedBox(height: 12),
|
||||||
child: _VideoPlayerPromptInteractionHold(
|
_VideoPlayerPromptActions(
|
||||||
chromeController: chromeController,
|
cancelLabel: t.common.cancel,
|
||||||
focusNodes: [cancelFocusNode, confirmFocusNode],
|
cancelFocusNode: cancelFocusNode,
|
||||||
child: _VideoPlayerPromptCard(
|
onCancel: onCancel,
|
||||||
child: Column(
|
confirmFocusNode: confirmFocusNode,
|
||||||
mainAxisSize: .min,
|
onConfirm: onPlayNext,
|
||||||
crossAxisAlignment: .start,
|
confirmChildren: [
|
||||||
children: [
|
if (autoPlayCountdown > 0) ...[
|
||||||
_PlayNextEpisodeHeader(episode: episode),
|
Text('$autoPlayCountdown'),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(width: 4),
|
||||||
Row(
|
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
|
||||||
children: [
|
] else
|
||||||
Expanded(
|
Text(t.videoControls.playNext),
|
||||||
child: FocusableButton(
|
],
|
||||||
focusNode: cancelFocusNode,
|
),
|
||||||
onPressed: onCancel,
|
],
|
||||||
autoScroll: false,
|
|
||||||
onNavigateRight: () => confirmFocusNode.requestFocus(),
|
|
||||||
onNavigateUp: () {},
|
|
||||||
onNavigateDown: () {},
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: onCancel,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
child: Text(t.common.cancel),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: FocusableButton(
|
|
||||||
focusNode: confirmFocusNode,
|
|
||||||
onPressed: onPlayNext,
|
|
||||||
autoScroll: false,
|
|
||||||
onNavigateLeft: () => cancelFocusNode.requestFocus(),
|
|
||||||
onNavigateUp: () {},
|
|
||||||
onNavigateDown: () {},
|
|
||||||
useBackgroundFocus: true,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: onPlayNext,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: .center,
|
|
||||||
children: [
|
|
||||||
if (autoPlayCountdown > 0) ...[
|
|
||||||
Text('$autoPlayCountdown'),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18),
|
|
||||||
] else
|
|
||||||
Text(t.videoControls.playNext),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -344,6 +290,49 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
|
|||||||
required this.onContinue,
|
required this.onContinue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return _VideoPlayerPromptShell(
|
||||||
|
visible: visible,
|
||||||
|
chromeController: chromeController,
|
||||||
|
focusNodes: [pauseFocusNode, continueFocusNode],
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
t.videoControls.stillWatching,
|
||||||
|
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
t.videoControls.pausingIn(seconds: '$countdown'),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
_VideoPlayerPromptActions(
|
||||||
|
cancelLabel: t.videoControls.pauseButton,
|
||||||
|
cancelFocusNode: pauseFocusNode,
|
||||||
|
onCancel: onPause,
|
||||||
|
confirmFocusNode: continueFocusNode,
|
||||||
|
onConfirm: onContinue,
|
||||||
|
confirmChildren: [Text('$countdown'), const SizedBox(width: 4), Text(t.videoControls.continueWatching)],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _VideoPlayerPromptShell extends StatelessWidget {
|
||||||
|
final bool visible;
|
||||||
|
final PlayerChromeController chromeController;
|
||||||
|
final List<FocusNode> focusNodes;
|
||||||
|
final List<Widget> children;
|
||||||
|
|
||||||
|
const _VideoPlayerPromptShell({
|
||||||
|
required this.visible,
|
||||||
|
required this.chromeController,
|
||||||
|
required this.focusNodes,
|
||||||
|
required this.children,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ValueListenableBuilder<bool>(
|
return ValueListenableBuilder<bool>(
|
||||||
@@ -356,75 +345,9 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
|
|||||||
chromeController: chromeController,
|
chromeController: chromeController,
|
||||||
child: _VideoPlayerPromptInteractionHold(
|
child: _VideoPlayerPromptInteractionHold(
|
||||||
chromeController: chromeController,
|
chromeController: chromeController,
|
||||||
focusNodes: [pauseFocusNode, continueFocusNode],
|
focusNodes: focusNodes,
|
||||||
child: _VideoPlayerPromptCard(
|
child: _VideoPlayerPromptCard(
|
||||||
child: Column(
|
child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children),
|
||||||
mainAxisSize: .min,
|
|
||||||
crossAxisAlignment: .start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
t.videoControls.stillWatching,
|
|
||||||
style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(
|
|
||||||
t.videoControls.pausingIn(seconds: '$countdown'),
|
|
||||||
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Expanded(
|
|
||||||
child: FocusableButton(
|
|
||||||
focusNode: pauseFocusNode,
|
|
||||||
onPressed: onPause,
|
|
||||||
autoScroll: false,
|
|
||||||
onNavigateRight: () => continueFocusNode.requestFocus(),
|
|
||||||
onNavigateUp: () {},
|
|
||||||
onNavigateDown: () {},
|
|
||||||
child: OutlinedButton(
|
|
||||||
onPressed: onPause,
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: Colors.white,
|
|
||||||
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
child: Text(t.videoControls.pauseButton),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: FocusableButton(
|
|
||||||
focusNode: continueFocusNode,
|
|
||||||
onPressed: onContinue,
|
|
||||||
autoScroll: false,
|
|
||||||
onNavigateLeft: () => pauseFocusNode.requestFocus(),
|
|
||||||
onNavigateUp: () {},
|
|
||||||
onNavigateDown: () {},
|
|
||||||
useBackgroundFocus: true,
|
|
||||||
child: FilledButton(
|
|
||||||
onPressed: onContinue,
|
|
||||||
style: FilledButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: Colors.black,
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: .center,
|
|
||||||
children: [
|
|
||||||
Text('$countdown'),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
Text(t.videoControls.continueWatching),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -433,6 +356,72 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _VideoPlayerPromptActions extends StatelessWidget {
|
||||||
|
final String cancelLabel;
|
||||||
|
final FocusNode cancelFocusNode;
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
final FocusNode confirmFocusNode;
|
||||||
|
final VoidCallback onConfirm;
|
||||||
|
final List<Widget> confirmChildren;
|
||||||
|
|
||||||
|
const _VideoPlayerPromptActions({
|
||||||
|
required this.cancelLabel,
|
||||||
|
required this.cancelFocusNode,
|
||||||
|
required this.onCancel,
|
||||||
|
required this.confirmFocusNode,
|
||||||
|
required this.onConfirm,
|
||||||
|
required this.confirmChildren,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: FocusableButton(
|
||||||
|
focusNode: cancelFocusNode,
|
||||||
|
onPressed: onCancel,
|
||||||
|
autoScroll: false,
|
||||||
|
onNavigateRight: () => confirmFocusNode.requestFocus(),
|
||||||
|
onNavigateUp: () {},
|
||||||
|
onNavigateDown: () {},
|
||||||
|
child: OutlinedButton(
|
||||||
|
onPressed: onCancel,
|
||||||
|
style: OutlinedButton.styleFrom(
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
child: Text(cancelLabel),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: FocusableButton(
|
||||||
|
focusNode: confirmFocusNode,
|
||||||
|
onPressed: onConfirm,
|
||||||
|
autoScroll: false,
|
||||||
|
onNavigateLeft: () => cancelFocusNode.requestFocus(),
|
||||||
|
onNavigateUp: () {},
|
||||||
|
onNavigateDown: () {},
|
||||||
|
useBackgroundFocus: true,
|
||||||
|
child: FilledButton(
|
||||||
|
onPressed: onConfirm,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
foregroundColor: Colors.black,
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||||
|
),
|
||||||
|
child: Row(mainAxisAlignment: .center, children: confirmChildren),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _VideoPlayerPromptPosition extends StatelessWidget {
|
class _VideoPlayerPromptPosition extends StatelessWidget {
|
||||||
final PlayerChromeController chromeController;
|
final PlayerChromeController chromeController;
|
||||||
final Widget child;
|
final Widget child;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ import '../services/playback_source_resolver.dart';
|
|||||||
import '../services/multi_server_manager.dart';
|
import '../services/multi_server_manager.dart';
|
||||||
import '../services/offline_watch_sync_service.dart';
|
import '../services/offline_watch_sync_service.dart';
|
||||||
import '../services/display_mode_service.dart';
|
import '../services/display_mode_service.dart';
|
||||||
|
import '../services/media_control_router.dart';
|
||||||
import '../services/settings_service.dart';
|
import '../services/settings_service.dart';
|
||||||
import '../services/sleep_timer_service.dart';
|
import '../services/sleep_timer_service.dart';
|
||||||
import '../services/track_manager.dart';
|
import '../services/track_manager.dart';
|
||||||
@@ -87,7 +88,6 @@ import 'video_player/completion_latch.dart';
|
|||||||
import 'video_player/frame_rate_matcher.dart';
|
import 'video_player/frame_rate_matcher.dart';
|
||||||
import 'video_player/live_stream_retry.dart';
|
import 'video_player/live_stream_retry.dart';
|
||||||
import 'video_player/live_timeline_report.dart';
|
import 'video_player/live_timeline_report.dart';
|
||||||
import 'video_player/media_control_router.dart';
|
|
||||||
import 'video_player/wakelock_controller.dart';
|
import 'video_player/wakelock_controller.dart';
|
||||||
import 'video_player/live_tv_session_args.dart';
|
import 'video_player/live_tv_session_args.dart';
|
||||||
import 'video_player/live_tv_session_state.dart';
|
import 'video_player/live_tv_session_state.dart';
|
||||||
@@ -1368,6 +1368,22 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
return exitPosition;
|
return exitPosition;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Pause/hide the player, flush stopped progress, restore system UI and
|
||||||
|
/// orientation, then leave the player route. No-op when the route cannot pop.
|
||||||
|
Future<void> _exitPlayerRoute({required bool navigateHome}) async {
|
||||||
|
final navigator = Navigator.of(context);
|
||||||
|
if (!navigator.canPop()) return;
|
||||||
|
|
||||||
|
_isExiting.value = true;
|
||||||
|
final exitPosition = await _pauseAndHidePlayerForRouteExit();
|
||||||
|
if (!mounted) return;
|
||||||
|
await _sendStoppedProgressOnce(positionOverride: exitPosition);
|
||||||
|
if (!mounted) return;
|
||||||
|
await _restoreSystemUiAndOrientation();
|
||||||
|
if (!mounted) return;
|
||||||
|
_finishPlayerNavigation(navigator, navigateHome: navigateHome);
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle back button press
|
/// Handle back button press
|
||||||
/// For non-host participants in Watch Together, shows leave session confirmation
|
/// For non-host participants in Watch Together, shows leave session confirmation
|
||||||
Future<void> _handleBackButton({bool navigateHome = false}) async {
|
Future<void> _handleBackButton({bool navigateHome = false}) async {
|
||||||
@@ -1390,36 +1406,14 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
|
|
||||||
if (confirmed && mounted) {
|
if (confirmed && mounted) {
|
||||||
await _watchTogetherProvider!.leaveSession();
|
await _watchTogetherProvider!.leaveSession();
|
||||||
if (mounted) {
|
if (mounted) await _exitPlayerRoute(navigateHome: navigateHome);
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
if (navigator.canPop()) {
|
|
||||||
_isExiting.value = true;
|
|
||||||
final exitPosition = await _pauseAndHidePlayerForRouteExit();
|
|
||||||
if (!mounted) return;
|
|
||||||
await _sendStoppedProgressOnce(positionOverride: exitPosition);
|
|
||||||
if (!mounted) return;
|
|
||||||
await _restoreSystemUiAndOrientation();
|
|
||||||
if (!mounted) return;
|
|
||||||
_finishPlayerNavigation(navigator, navigateHome: navigateHome);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Default behavior for hosts or non-session users
|
// Default behavior for hosts or non-session users
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final navigator = Navigator.of(context);
|
await _exitPlayerRoute(navigateHome: navigateHome);
|
||||||
if (navigator.canPop()) {
|
|
||||||
_isExiting.value = true;
|
|
||||||
final exitPosition = await _pauseAndHidePlayerForRouteExit();
|
|
||||||
if (!mounted) return;
|
|
||||||
await _sendStoppedProgressOnce(positionOverride: exitPosition);
|
|
||||||
if (!mounted) return;
|
|
||||||
await _restoreSystemUiAndOrientation();
|
|
||||||
if (!mounted) return;
|
|
||||||
_finishPlayerNavigation(navigator, navigateHome: navigateHome);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
_isHandlingBack = false;
|
_isHandlingBack = false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,33 +9,40 @@ import '../../profiles/profile_connection_registry.dart';
|
|||||||
import '../../providers/companion_remote_provider.dart';
|
import '../../providers/companion_remote_provider.dart';
|
||||||
import '../../utils/app_logger.dart';
|
import '../../utils/app_logger.dart';
|
||||||
|
|
||||||
|
/// Resolves the active profile's Plex identity and primes companion-remote
|
||||||
|
/// crypto with it, returning whether crypto ended up ready.
|
||||||
|
///
|
||||||
|
/// Crypto is an app-level service, not bound to any one widget: everything the
|
||||||
|
/// bootstrap needs is captured up front, so an unmount mid-await must not abort
|
||||||
|
/// work the user asked for. Hence no `context.mounted` guards below.
|
||||||
|
Future<bool> ensureCompanionRemoteCryptoFromContext(BuildContext context) async {
|
||||||
|
final companionRemote = context.read<CompanionRemoteProvider>();
|
||||||
|
final connections = context.read<ConnectionRegistry>();
|
||||||
|
final activeProfile = context.read<ActiveProfileProvider>();
|
||||||
|
final profileConnections = context.read<ProfileConnectionRegistry>();
|
||||||
|
final plexHome = context.read<PlexHomeService>();
|
||||||
|
final identity = await resolveActivePlexIdentity(
|
||||||
|
activeProfile: activeProfile,
|
||||||
|
connections: connections,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
);
|
||||||
|
final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id);
|
||||||
|
return companionRemote.ensureCryptoReady(
|
||||||
|
home,
|
||||||
|
connections: connections,
|
||||||
|
activeProfile: activeProfile,
|
||||||
|
profileConnections: profileConnections,
|
||||||
|
identity: identity,
|
||||||
|
plexHomeForConnection: plexHome.materializePlexHomeForConnection,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> startCompanionRemoteHost(BuildContext context) async {
|
Future<bool> startCompanionRemoteHost(BuildContext context) async {
|
||||||
final companionRemote = context.read<CompanionRemoteProvider>();
|
final companionRemote = context.read<CompanionRemoteProvider>();
|
||||||
if (companionRemote.isHostServerRunning) return true;
|
if (companionRemote.isHostServerRunning) return true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// The host is an app-level service, not bound to this widget: everything
|
if (!await ensureCompanionRemoteCryptoFromContext(context)) return false;
|
||||||
// it needs is captured up front, so an unmount mid-await must not abort a
|
|
||||||
// start the user asked for. Hence no `context.mounted` guards below.
|
|
||||||
final connections = context.read<ConnectionRegistry>();
|
|
||||||
final activeProfile = context.read<ActiveProfileProvider>();
|
|
||||||
final profileConnections = context.read<ProfileConnectionRegistry>();
|
|
||||||
final plexHome = context.read<PlexHomeService>();
|
|
||||||
final identity = await resolveActivePlexIdentity(
|
|
||||||
activeProfile: activeProfile,
|
|
||||||
connections: connections,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
);
|
|
||||||
final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id);
|
|
||||||
final ok = await companionRemote.ensureCryptoReady(
|
|
||||||
home,
|
|
||||||
connections: connections,
|
|
||||||
activeProfile: activeProfile,
|
|
||||||
profileConnections: profileConnections,
|
|
||||||
identity: identity,
|
|
||||||
plexHomeForConnection: plexHome.materializePlexHomeForConnection,
|
|
||||||
);
|
|
||||||
if (!ok) return false;
|
|
||||||
|
|
||||||
await companionRemote.startHostServer();
|
await companionRemote.startHostServer();
|
||||||
return companionRemote.isHostServerRunning;
|
return companionRemote.isHostServerRunning;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart';
|
|||||||
import 'package:flutter/painting.dart';
|
import 'package:flutter/painting.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
|
|
||||||
|
import '../utils/async_singleton.dart';
|
||||||
|
import '../utils/device_channel.dart';
|
||||||
import '../utils/platform_detector.dart';
|
import '../utils/platform_detector.dart';
|
||||||
|
|
||||||
/// User override for the visual-effects tier (stored by SettingsService).
|
/// User override for the visual-effects tier (stored by SettingsService).
|
||||||
@@ -19,11 +21,9 @@ enum VisualEffectsSetting { auto, full, reduced }
|
|||||||
class DevicePerformance {
|
class DevicePerformance {
|
||||||
DevicePerformance._();
|
DevicePerformance._();
|
||||||
|
|
||||||
static DevicePerformance? _instance;
|
static final AsyncSingleton<DevicePerformance> _singleton = AsyncSingleton();
|
||||||
static Future<void>? _initialization;
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static Future<void>? debugDetectionGate;
|
static set debugDetectionGate(Future<void>? value) => _singleton.debugGate = value;
|
||||||
static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device');
|
|
||||||
|
|
||||||
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
|
/// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel
|
||||||
/// reservations), below 3 GB Shield-class devices (~2.8 GiB).
|
/// reservations), below 3 GB Shield-class devices (~2.8 GiB).
|
||||||
@@ -39,35 +39,13 @@ class DevicePerformance {
|
|||||||
|
|
||||||
/// Get the singleton, detecting hardware signals on first call.
|
/// Get the singleton, detecting hardware signals on first call.
|
||||||
/// [override] is the persisted SettingsService.visualEffects value.
|
/// [override] is the persisted SettingsService.visualEffects value.
|
||||||
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async {
|
static Future<DevicePerformance> getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) =>
|
||||||
final existing = _instance;
|
_singleton.getInstance(() => DevicePerformance._().._override = override, (instance) => instance._detect());
|
||||||
if (existing != null) {
|
|
||||||
final initialization = _initialization;
|
|
||||||
if (initialization != null) await initialization;
|
|
||||||
return existing;
|
|
||||||
}
|
|
||||||
|
|
||||||
final instance = DevicePerformance._().._override = override;
|
|
||||||
_instance = instance;
|
|
||||||
final initialization = instance._detect();
|
|
||||||
_initialization = initialization;
|
|
||||||
try {
|
|
||||||
await initialization;
|
|
||||||
} catch (_) {
|
|
||||||
if (identical(_instance, instance)) _instance = null;
|
|
||||||
rethrow;
|
|
||||||
} finally {
|
|
||||||
if (identical(_initialization, initialization)) _initialization = null;
|
|
||||||
}
|
|
||||||
return instance;
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _detect() async {
|
Future<void> _detect() async {
|
||||||
final gate = debugDetectionGate;
|
|
||||||
if (gate != null) await gate;
|
|
||||||
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
|
if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier
|
||||||
try {
|
try {
|
||||||
final result = await _deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
|
final result = await deviceChannel.invokeMapMethod<dynamic, dynamic>('getPerformanceSignals');
|
||||||
if (result == null) return;
|
if (result == null) return;
|
||||||
_is64Bit = result['is64Bit'] == true;
|
_is64Bit = result['is64Bit'] == true;
|
||||||
_isLowRam = result['isLowRamDevice'] == true;
|
_isLowRam = result['isLowRamDevice'] == true;
|
||||||
@@ -85,7 +63,7 @@ class DevicePerformance {
|
|||||||
|
|
||||||
/// Total device RAM as reported by the platform, or null off-Android /
|
/// Total device RAM as reported by the platform, or null off-Android /
|
||||||
/// before init. Used to scale memory-watchdog thresholds to the device.
|
/// before init. Used to scale memory-watchdog thresholds to the device.
|
||||||
static int? get totalMemBytes => _instance?._totalMemBytes;
|
static int? get totalMemBytes => _singleton.instance?._totalMemBytes;
|
||||||
|
|
||||||
/// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB),
|
/// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB),
|
||||||
/// independent of the visual-effects override. Use this for decisions tied to
|
/// independent of the visual-effects override. Use this for decisions tied to
|
||||||
@@ -93,11 +71,11 @@ class DevicePerformance {
|
|||||||
/// boxes lagging a GL subtitle overlay — where a user's effects preference is
|
/// boxes lagging a GL subtitle overlay — where a user's effects preference is
|
||||||
/// irrelevant. Safe before init (returns false). See [isReduced] for the
|
/// irrelevant. Safe before init (returns false). See [isReduced] for the
|
||||||
/// effects-tier gate that the override can force.
|
/// effects-tier gate that the override can force.
|
||||||
static bool get isLowEndHardware => _instance?._autoReduced ?? false;
|
static bool get isLowEndHardware => _singleton.instance?._autoReduced ?? false;
|
||||||
|
|
||||||
/// Primary gate for effect chokepoints. Safe before init (full tier).
|
/// Primary gate for effect chokepoints. Safe before init (full tier).
|
||||||
static bool get isReduced {
|
static bool get isReduced {
|
||||||
final instance = _instance;
|
final instance = _singleton.instance;
|
||||||
if (instance == null) return false;
|
if (instance == null) return false;
|
||||||
return switch (instance._override) {
|
return switch (instance._override) {
|
||||||
VisualEffectsSetting.auto => instance._autoReduced,
|
VisualEffectsSetting.auto => instance._autoReduced,
|
||||||
@@ -112,7 +90,7 @@ class DevicePerformance {
|
|||||||
/// Update the user override from the settings screen and re-apply the
|
/// Update the user override from the settings screen and re-apply the
|
||||||
/// budgets that were computed at boot.
|
/// budgets that were computed at boot.
|
||||||
static void setOverrideSync(VisualEffectsSetting value) {
|
static void setOverrideSync(VisualEffectsSetting value) {
|
||||||
_instance?._override = value;
|
_singleton.instance?._override = value;
|
||||||
applyImageCacheBudget();
|
applyImageCacheBudget();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +120,7 @@ class DevicePerformance {
|
|||||||
/// Raw signals are always included (even when the tier is forced) so an
|
/// Raw signals are always included (even when the tier is forced) so an
|
||||||
/// uploaded log answers "did the reduced tier engage, and why / why not".
|
/// uploaded log answers "did the reduced tier engage, and why / why not".
|
||||||
static String describeSync() {
|
static String describeSync() {
|
||||||
final instance = _instance;
|
final instance = _singleton.instance;
|
||||||
if (instance == null) return 'unknown';
|
if (instance == null) return 'unknown';
|
||||||
final tier = isReduced ? 'reduced' : 'full';
|
final tier = isReduced ? 'reduced' : 'full';
|
||||||
final signals = <String>[
|
final signals = <String>[
|
||||||
@@ -158,14 +136,13 @@ class DevicePerformance {
|
|||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
|
static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) {
|
||||||
_initialization = null;
|
|
||||||
debugDetectionGate = null;
|
|
||||||
if (autoReduced == null && override == null) {
|
if (autoReduced == null && override == null) {
|
||||||
_instance = null;
|
_singleton.debugReset();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_instance ??= DevicePerformance._();
|
final instance = _singleton.instance ?? DevicePerformance._();
|
||||||
if (autoReduced != null) _instance!._autoReduced = autoReduced;
|
_singleton.debugReset(instance: instance);
|
||||||
if (override != null) _instance!._override = override;
|
if (autoReduced != null) instance._autoReduced = autoReduced;
|
||||||
|
if (override != null) instance._override = override;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1323,46 +1323,23 @@ class DownloadManagerService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
var artworkSettled = !queueItem.downloadArtwork;
|
final settled = await _runSupplementaryDownloads(
|
||||||
if (queueItem.downloadArtwork) {
|
globalKey,
|
||||||
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client);
|
metadata,
|
||||||
final chapterArtworkSettled = metadata.serverId == null
|
client,
|
||||||
? false
|
downloadArtwork: queueItem.downloadArtwork,
|
||||||
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
|
downloadSubtitles: queueItem.downloadSubtitles,
|
||||||
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
|
record: record,
|
||||||
}
|
showYear: showYear,
|
||||||
|
);
|
||||||
var subtitlesSettled = !queueItem.downloadSubtitles;
|
if (settled.artwork && settled.subtitles) {
|
||||||
if (queueItem.downloadSubtitles) {
|
|
||||||
try {
|
|
||||||
final resolution = await client.resolveDownload(
|
|
||||||
metadata,
|
|
||||||
mediaIndex: record?.mediaIndex ?? 0,
|
|
||||||
mediaSourceId: record?.mediaSourceId,
|
|
||||||
);
|
|
||||||
if (resolution.externalSubtitlesResolved) {
|
|
||||||
subtitlesSettled = await _downloadSubtitles(
|
|
||||||
globalKey,
|
|
||||||
metadata,
|
|
||||||
resolution.externalSubtitles,
|
|
||||||
client,
|
|
||||||
showYear: showYear,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
appLogger.d('Subtitle enrichment remains deferred for $globalKey');
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e, stackTrace: st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (artworkSettled && subtitlesSettled) {
|
|
||||||
await _database.removeFromQueue(globalKey);
|
await _database.removeFromQueue(globalKey);
|
||||||
appLogger.i('Deferred supplementary downloads completed for $globalKey');
|
appLogger.i('Deferred supplementary downloads completed for $globalKey');
|
||||||
} else {
|
} else {
|
||||||
await _database.updateSupplementaryQueueIntent(
|
await _database.updateSupplementaryQueueIntent(
|
||||||
globalKey,
|
globalKey,
|
||||||
downloadSubtitles: !subtitlesSettled,
|
downloadSubtitles: !settled.subtitles,
|
||||||
downloadArtwork: !artworkSettled,
|
downloadArtwork: !settled.artwork,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
@@ -1591,17 +1568,8 @@ class DownloadManagerService {
|
|||||||
if (client != null) unawaited(_processQueue(client));
|
if (client != null) unawaited(_processQueue(client));
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _cancelNativeTask(String globalKey, String taskId, {required String reason}) async {
|
Future<void> _cancelNativeTask(String globalKey, String taskId, {required String reason}) =>
|
||||||
if (!downloadsSupported || taskId.isEmpty) return;
|
_cancelNativeTaskIds(globalKey, [taskId], reason: reason);
|
||||||
try {
|
|
||||||
final cancelled = await FileDownloader().cancelTaskWithId(taskId);
|
|
||||||
if (cancelled) {
|
|
||||||
appLogger.d('Cancelled native task $taskId for $globalKey ($reason)');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.w('Failed to cancel native task $taskId for $globalKey ($reason)', error: e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _cancelNativeTasksForGlobalKey(
|
Future<void> _cancelNativeTasksForGlobalKey(
|
||||||
String globalKey, {
|
String globalKey, {
|
||||||
@@ -1624,16 +1592,7 @@ class DownloadManagerService {
|
|||||||
appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e);
|
appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (taskIds.isEmpty) return;
|
await _cancelNativeTaskIds(globalKey, taskIds, reason: reason);
|
||||||
|
|
||||||
try {
|
|
||||||
final cancelled = await FileDownloader().cancelTasksWithIds(taskIds);
|
|
||||||
if (cancelled) {
|
|
||||||
appLogger.d('Cancelled ${taskIds.length} native task(s) for $globalKey ($reason): ${taskIds.join(', ')}');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
appLogger.w('Failed to cancel native tasks for $globalKey ($reason): ${taskIds.join(', ')}', error: e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<DownloadedMediaItem?> _downloadForCurrentTaskSession(
|
Future<DownloadedMediaItem?> _downloadForCurrentTaskSession(
|
||||||
@@ -2390,36 +2349,20 @@ class DownloadManagerService {
|
|||||||
try {
|
try {
|
||||||
final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey);
|
final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey);
|
||||||
final client = ctx?.client ?? await _getClientForDownloadKey(globalKey);
|
final client = ctx?.client ?? await _getClientForDownloadKey(globalKey);
|
||||||
final showYear = ctx?.showYear;
|
|
||||||
|
|
||||||
if (metadata != null && client != null) {
|
if (metadata != null && client != null) {
|
||||||
if (downloadArtwork) {
|
final settled = await _runSupplementaryDownloads(
|
||||||
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client);
|
globalKey,
|
||||||
final chapterArtworkSettled = metadata.serverId == null
|
metadata,
|
||||||
? false
|
client,
|
||||||
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
|
downloadArtwork: downloadArtwork,
|
||||||
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
|
downloadSubtitles: downloadSubtitles,
|
||||||
}
|
record: existingCheck,
|
||||||
if (downloadSubtitles) {
|
showYear: ctx?.showYear,
|
||||||
var subtitles = ctx?.subtitles;
|
preresolvedSubtitles: ctx?.subtitles,
|
||||||
if (subtitles == null) {
|
);
|
||||||
try {
|
artworkSettled = settled.artwork;
|
||||||
final resolution = await client.resolveDownload(
|
subtitlesSettled = settled.subtitles;
|
||||||
metadata,
|
|
||||||
mediaIndex: existingCheck.mediaIndex,
|
|
||||||
mediaSourceId: existingCheck.mediaSourceId,
|
|
||||||
);
|
|
||||||
if (resolution.externalSubtitlesResolved) {
|
|
||||||
subtitles = resolution.externalSubtitles;
|
|
||||||
}
|
|
||||||
} catch (e, st) {
|
|
||||||
appLogger.w('Could not re-resolve subtitles for $globalKey', error: e, stackTrace: st);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (subtitles != null) {
|
|
||||||
subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st);
|
appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st);
|
||||||
@@ -2516,6 +2459,58 @@ class DownloadManagerService {
|
|||||||
return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId);
|
return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Best-effort supplementary work for an already-stored video (artwork,
|
||||||
|
/// chapter thumbnails, external subtitles); reports which half settled so the
|
||||||
|
/// caller can do its own queue-row bookkeeping. Shared by the completion path
|
||||||
|
/// and the deferred-repair path: [record] carries the media-source
|
||||||
|
/// coordinates for re-resolving subtitles, [preresolvedSubtitles] skips that
|
||||||
|
/// re-resolve, and [showYear] is caller-supplied because the paths differ.
|
||||||
|
Future<({bool artwork, bool subtitles})> _runSupplementaryDownloads(
|
||||||
|
String globalKey,
|
||||||
|
MediaItem metadata,
|
||||||
|
MediaServerClient client, {
|
||||||
|
required bool downloadArtwork,
|
||||||
|
required bool downloadSubtitles,
|
||||||
|
required DownloadedMediaItem? record,
|
||||||
|
required int? showYear,
|
||||||
|
List<DownloadSubtitleSpec>? preresolvedSubtitles,
|
||||||
|
}) async {
|
||||||
|
var artworkSettled = !downloadArtwork;
|
||||||
|
if (downloadArtwork) {
|
||||||
|
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client);
|
||||||
|
final chapterArtworkSettled = metadata.serverId == null
|
||||||
|
? false
|
||||||
|
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
|
||||||
|
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
|
||||||
|
}
|
||||||
|
|
||||||
|
var subtitlesSettled = !downloadSubtitles;
|
||||||
|
if (downloadSubtitles) {
|
||||||
|
try {
|
||||||
|
var subtitles = preresolvedSubtitles;
|
||||||
|
if (subtitles == null) {
|
||||||
|
final resolution = await client.resolveDownload(
|
||||||
|
metadata,
|
||||||
|
mediaIndex: record?.mediaIndex ?? 0,
|
||||||
|
mediaSourceId: record?.mediaSourceId,
|
||||||
|
);
|
||||||
|
if (resolution.externalSubtitlesResolved) {
|
||||||
|
subtitles = resolution.externalSubtitles;
|
||||||
|
} else {
|
||||||
|
appLogger.d('Subtitle enrichment remains deferred for $globalKey');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (subtitles != null) {
|
||||||
|
subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear);
|
||||||
|
}
|
||||||
|
} catch (e, st) {
|
||||||
|
appLogger.w('Could not resolve subtitles for $globalKey', error: e, stackTrace: st);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (artwork: artworkSettled, subtitles: subtitlesSettled);
|
||||||
|
}
|
||||||
|
|
||||||
Future<bool> _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async {
|
Future<bool> _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async {
|
||||||
if (metadata.serverId == null) return false;
|
if (metadata.serverId == null) return false;
|
||||||
|
|
||||||
@@ -3266,24 +3261,39 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteMovieStorageDirectory(MediaItem movie) async {
|
/// Delete one media directory and everything under it, on either storage backend.
|
||||||
|
/// [safComponents] and [fileDirectory] are thunks so only the branch that runs
|
||||||
|
/// resolves its path — the file-mode getters create the directory as a side effect.
|
||||||
|
Future<void> _deleteStorageDirectory({
|
||||||
|
required List<String> Function() safComponents,
|
||||||
|
required Future<Directory> Function() fileDirectory,
|
||||||
|
required String label,
|
||||||
|
}) async {
|
||||||
if (_storageService.isUsingSaf) {
|
if (_storageService.isUsingSaf) {
|
||||||
final safBaseUri = _storageService.safBaseUri;
|
final safBaseUri = _storageService.safBaseUri;
|
||||||
if (safBaseUri == null) return;
|
if (safBaseUri == null) return;
|
||||||
final movieDir = await _safStorage.getChild(safBaseUri, _storageService.getMovieSafPathComponents(movie));
|
final dir = await _safStorage.getChild(safBaseUri, safComponents());
|
||||||
if (movieDir != null) {
|
if (dir != null) {
|
||||||
await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory');
|
await _deleteSafDirRecursive(dir.uri, description: '$label directory');
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final movieDir = await _storageService.getMovieDirectory(movie);
|
final dir = await fileDirectory();
|
||||||
if (await movieDir.exists()) {
|
if (await dir.exists()) {
|
||||||
await movieDir.delete(recursive: true);
|
await dir.delete(recursive: true);
|
||||||
appLogger.i('Deleted movie directory: ${movieDir.path}');
|
appLogger.i('Deleted $label directory: ${dir.path}');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _deleteMovieStorageDirectory(MediaItem movie) {
|
||||||
|
return _deleteStorageDirectory(
|
||||||
|
safComponents: () => _storageService.getMovieSafPathComponents(movie),
|
||||||
|
fileDirectory: () => _storageService.getMovieDirectory(movie),
|
||||||
|
label: 'movie',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo(
|
Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo(
|
||||||
MediaItem episode, {
|
MediaItem episode, {
|
||||||
required int? showYear,
|
required int? showYear,
|
||||||
@@ -3330,50 +3340,32 @@ class DownloadManagerService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async {
|
Future<void> _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async {
|
||||||
|
await _deleteStorageDirectory(
|
||||||
|
safComponents: () => _storageService.getSeasonSafPathComponents(season, showYear: showYear),
|
||||||
|
fileDirectory: () => _storageService.getSeasonDirectory(season, showYear: showYear),
|
||||||
|
label: 'season',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Drop the parent show directory too if the deleted season left it empty.
|
||||||
if (_storageService.isUsingSaf) {
|
if (_storageService.isUsingSaf) {
|
||||||
final safBaseUri = _storageService.safBaseUri;
|
final safBaseUri = _storageService.safBaseUri;
|
||||||
if (safBaseUri == null) return;
|
if (safBaseUri == null) return;
|
||||||
final seasonDir = await _safStorage.getChild(
|
|
||||||
safBaseUri,
|
|
||||||
_storageService.getSeasonSafPathComponents(season, showYear: showYear),
|
|
||||||
);
|
|
||||||
if (seasonDir != null) {
|
|
||||||
await _deleteSafDirRecursive(seasonDir.uri, description: 'season directory');
|
|
||||||
}
|
|
||||||
final showDir = await _safStorage.getChild(
|
final showDir = await _safStorage.getChild(
|
||||||
safBaseUri,
|
safBaseUri,
|
||||||
_storageService.getShowSafPathComponents(season, showYear: showYear),
|
_storageService.getShowSafPathComponents(season, showYear: showYear),
|
||||||
);
|
);
|
||||||
if (showDir != null) {
|
await _deleteEmptySafDirsInOrder([showDir?.uri]);
|
||||||
await _deleteEmptySafDirsInOrder([showDir.uri]);
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear);
|
|
||||||
if (await seasonDir.exists()) {
|
|
||||||
await seasonDir.delete(recursive: true);
|
|
||||||
appLogger.i('Deleted season directory: ${seasonDir.path}');
|
|
||||||
}
|
|
||||||
await _cleanupShowDirectory(season, showYear);
|
await _cleanupShowDirectory(season, showYear);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _deleteShowStorageDirectory(MediaItem show) async {
|
Future<void> _deleteShowStorageDirectory(MediaItem show) {
|
||||||
if (_storageService.isUsingSaf) {
|
return _deleteStorageDirectory(
|
||||||
final safBaseUri = _storageService.safBaseUri;
|
safComponents: () => _storageService.getShowSafPathComponents(show),
|
||||||
if (safBaseUri == null) return;
|
fileDirectory: () => _storageService.getShowDirectory(show),
|
||||||
final showDir = await _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(show));
|
label: 'show',
|
||||||
if (showDir != null) {
|
);
|
||||||
await _deleteSafDirRecursive(showDir.uri, description: 'show directory');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final showDir = await _storageService.getShowDirectory(show);
|
|
||||||
if (await showDir.exists()) {
|
|
||||||
await showDir.delete(recursive: true);
|
|
||||||
appLogger.i('Deleted show directory: ${showDir.path}');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Safety net: after metadata-based deletion, verify the actual DB-recorded
|
/// Safety net: after metadata-based deletion, verify the actual DB-recorded
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import '../database/app_database.dart';
|
||||||
|
import '../models/download_models.dart';
|
||||||
|
import '../utils/app_logger.dart';
|
||||||
|
import '../utils/downloaded_version_match.dart';
|
||||||
|
import 'download_storage_service.dart';
|
||||||
|
|
||||||
|
/// A downloaded copy resolved to a playable location, plus the version that is
|
||||||
|
/// actually on disk — which can differ from the requested one when
|
||||||
|
/// [resolveDownloadedVideoSource] was allowed to fall back.
|
||||||
|
typedef DownloadedVideoSource = ({String path, int mediaIndex, String? mediaSourceId});
|
||||||
|
|
||||||
|
/// Single source of truth for "where is the playable copy of this downloaded
|
||||||
|
/// row, and is it the version that was asked for".
|
||||||
|
///
|
||||||
|
/// Returns null when the row cannot back playback: the download is not
|
||||||
|
/// complete, it holds a different version than requested (unless
|
||||||
|
/// [allowAnyDownloadedVersion]), it has no stored video path, or the stored
|
||||||
|
/// file is gone from disk.
|
||||||
|
///
|
||||||
|
/// Version matching is strict by default so online flows keep streaming an
|
||||||
|
/// explicitly requested non-downloaded version (issue #1440). With
|
||||||
|
/// [allowAnyDownloadedVersion] the downloaded version is returned on mismatch
|
||||||
|
/// instead — for offline flows where the alternative is failing outright.
|
||||||
|
///
|
||||||
|
/// Callers own their own preconditions (profile ownership, how the row was
|
||||||
|
/// looked up); this only judges the row itself.
|
||||||
|
Future<DownloadedVideoSource?> resolveDownloadedVideoSource(
|
||||||
|
DownloadedMediaItem row, {
|
||||||
|
int? requestedMediaIndex,
|
||||||
|
String? requestedMediaSourceId,
|
||||||
|
bool allowAnyDownloadedVersion = false,
|
||||||
|
}) async {
|
||||||
|
if (row.status != DownloadStatus.completed.index) {
|
||||||
|
appLogger.d('Download not complete for ${row.globalKey}. Status: ${row.status}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!downloadedVersionMatches(
|
||||||
|
row,
|
||||||
|
requestedMediaIndex: requestedMediaIndex,
|
||||||
|
requestedMediaSourceId: requestedMediaSourceId,
|
||||||
|
)) {
|
||||||
|
if (!allowAnyDownloadedVersion) {
|
||||||
|
appLogger.d(
|
||||||
|
'[VersionTrace] Downloaded copy of ${row.globalKey} is version ${row.mediaIndex} '
|
||||||
|
'(source ${row.mediaSourceId}), but requested version $requestedMediaIndex '
|
||||||
|
'(source ${requestedMediaSourceId?.trim()}) — skipping offline',
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
appLogger.d(
|
||||||
|
'[VersionTrace] Requested version $requestedMediaIndex (source ${requestedMediaSourceId?.trim()}) '
|
||||||
|
'is not downloaded — falling back to downloaded version ${row.mediaIndex} '
|
||||||
|
'(source ${row.mediaSourceId})',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final storedPath = row.videoFilePath;
|
||||||
|
if (storedPath == null) {
|
||||||
|
appLogger.d('Video file path is null for ${row.globalKey}');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final storageService = DownloadStorageService.instance;
|
||||||
|
// SAF URIs (content://) are already playable and come back untouched; file
|
||||||
|
// paths may be stored relative, so resolve them and confirm they still exist.
|
||||||
|
final readablePath = await storageService.getReadablePath(storedPath);
|
||||||
|
if (!storageService.isSafUri(storedPath) && !await File(readablePath).exists()) {
|
||||||
|
appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
appLogger.d('Found offline video: $readablePath');
|
||||||
|
return (path: readablePath, mediaIndex: row.mediaIndex, mediaSourceId: row.mediaSourceId);
|
||||||
|
}
|
||||||
@@ -30,70 +30,22 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener {
|
|||||||
Future<void> toggleFullscreen() async {
|
Future<void> toggleFullscreen() async {
|
||||||
if (!PlatformDetector.isDesktopOS()) return;
|
if (!PlatformDetector.isDesktopOS()) return;
|
||||||
|
|
||||||
if (Platform.isMacOS) {
|
final isCurrentlyFullscreen = await _platformIsFullscreen();
|
||||||
final isCurrentlyFullscreen = await MacOSWindowService.isFullscreen();
|
await _platformSetFullscreen(!isCurrentlyFullscreen);
|
||||||
if (isCurrentlyFullscreen) {
|
|
||||||
await MacOSWindowService.exitFullscreen();
|
|
||||||
} else {
|
|
||||||
await MacOSWindowService.enterFullscreen();
|
|
||||||
}
|
|
||||||
} else if (Platform.isWindows) {
|
|
||||||
// Route through the native Win32 runner, which restores to the monitor
|
|
||||||
// the window is currently on (window_manager 0.5.1 picks the wrong one
|
|
||||||
// on multi-monitor setups — see issue #880). The native code also
|
|
||||||
// preserves maximized state internally, so no unmaximize dance here.
|
|
||||||
final isCurrentlyFullscreen = await NativeWindowService.isFullScreen();
|
|
||||||
await NativeWindowService.setFullScreen(!isCurrentlyFullscreen);
|
|
||||||
} else {
|
|
||||||
final isCurrentlyFullscreen = await windowManager.isFullScreen();
|
|
||||||
if (isCurrentlyFullscreen) {
|
|
||||||
await windowManager.setFullScreen(false);
|
|
||||||
if (_wasMaximized) {
|
|
||||||
await windowManager.maximize();
|
|
||||||
_wasMaximized = false;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
_wasMaximized = await windowManager.isMaximized();
|
|
||||||
if (_wasMaximized) {
|
|
||||||
await windowManager.unmaximize();
|
|
||||||
}
|
|
||||||
await windowManager.setFullScreen(true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Enter fullscreen, preserving maximized state on Windows/Linux for restoration on exit.
|
/// Enter fullscreen, preserving maximized state on Windows/Linux for restoration on exit.
|
||||||
Future<void> enterFullscreen() async {
|
Future<void> enterFullscreen() async {
|
||||||
if (!PlatformDetector.isDesktopOS()) return;
|
if (!PlatformDetector.isDesktopOS()) return;
|
||||||
|
|
||||||
if (Platform.isMacOS) {
|
await _platformSetFullscreen(true);
|
||||||
await MacOSWindowService.enterFullscreen();
|
|
||||||
} else if (Platform.isWindows) {
|
|
||||||
await NativeWindowService.setFullScreen(true);
|
|
||||||
} else {
|
|
||||||
_wasMaximized = await windowManager.isMaximized();
|
|
||||||
if (_wasMaximized) {
|
|
||||||
await windowManager.unmaximize();
|
|
||||||
}
|
|
||||||
await windowManager.setFullScreen(true);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exit fullscreen, restoring maximized state if needed
|
/// Exit fullscreen, restoring maximized state if needed
|
||||||
Future<void> exitFullscreen() async {
|
Future<void> exitFullscreen() async {
|
||||||
if (!PlatformDetector.isDesktopOS()) return;
|
if (!PlatformDetector.isDesktopOS()) return;
|
||||||
|
|
||||||
if (Platform.isMacOS) {
|
await _platformSetFullscreen(false);
|
||||||
await MacOSWindowService.exitFullscreen();
|
|
||||||
} else if (Platform.isWindows) {
|
|
||||||
await NativeWindowService.setFullScreen(false);
|
|
||||||
} else {
|
|
||||||
await windowManager.setFullScreen(false);
|
|
||||||
if (_wasMaximized) {
|
|
||||||
await windowManager.maximize();
|
|
||||||
_wasMaximized = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Exits fullscreen when the platform window is currently fullscreen.
|
/// Exits fullscreen when the platform window is currently fullscreen.
|
||||||
@@ -103,17 +55,47 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener {
|
|||||||
Future<bool> exitFullscreenIfActive() async {
|
Future<bool> exitFullscreenIfActive() async {
|
||||||
if (!PlatformDetector.isDesktopOS()) return false;
|
if (!PlatformDetector.isDesktopOS()) return false;
|
||||||
|
|
||||||
final isActive = Platform.isMacOS
|
final isActive = await _platformIsFullscreen();
|
||||||
? await MacOSWindowService.isFullscreen()
|
|
||||||
: Platform.isWindows
|
|
||||||
? await NativeWindowService.isFullScreen()
|
|
||||||
: await windowManager.isFullScreen();
|
|
||||||
if (!isActive) return false;
|
if (!isActive) return false;
|
||||||
|
|
||||||
await exitFullscreen();
|
await _platformSetFullscreen(false);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> _platformIsFullscreen() {
|
||||||
|
if (Platform.isMacOS) return MacOSWindowService.isFullscreen();
|
||||||
|
if (Platform.isWindows) return NativeWindowService.isFullScreen();
|
||||||
|
return windowManager.isFullScreen();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _platformSetFullscreen(bool value) async {
|
||||||
|
if (Platform.isMacOS) {
|
||||||
|
if (value) {
|
||||||
|
await MacOSWindowService.enterFullscreen();
|
||||||
|
} else {
|
||||||
|
await MacOSWindowService.exitFullscreen();
|
||||||
|
}
|
||||||
|
} else if (Platform.isWindows) {
|
||||||
|
// Route through the native Win32 runner, which restores to the monitor
|
||||||
|
// the window is currently on (window_manager 0.5.1 picks the wrong one
|
||||||
|
// on multi-monitor setups — see issue #880). The native code also
|
||||||
|
// preserves maximized state internally, so no unmaximize dance here.
|
||||||
|
await NativeWindowService.setFullScreen(value);
|
||||||
|
} else if (value) {
|
||||||
|
_wasMaximized = await windowManager.isMaximized();
|
||||||
|
if (_wasMaximized) {
|
||||||
|
await windowManager.unmaximize();
|
||||||
|
}
|
||||||
|
await windowManager.setFullScreen(true);
|
||||||
|
} else {
|
||||||
|
await windowManager.setFullScreen(false);
|
||||||
|
if (_wasMaximized) {
|
||||||
|
await windowManager.maximize();
|
||||||
|
_wasMaximized = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void startMonitoring() {
|
void startMonitoring() {
|
||||||
if (!_shouldMonitor() || _isListening) return;
|
if (!_shouldMonitor() || _isListening) return;
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,20 @@ part 'jellyfin_client/parts/live_tv.dart';
|
|||||||
part 'jellyfin_client/parts/images_downloads.dart';
|
part 'jellyfin_client/parts/images_downloads.dart';
|
||||||
part 'jellyfin_client/parts/metadata_edit.dart';
|
part 'jellyfin_client/parts/metadata_edit.dart';
|
||||||
|
|
||||||
|
/// Canonical declarations of the [JellyfinClient] internals that the `part`
|
||||||
|
/// mixins call into.
|
||||||
|
///
|
||||||
|
/// Every part mixin is `on _JellyfinClientInternals`, so each shared member is
|
||||||
|
/// declared exactly once here instead of being re-declared per file. Members
|
||||||
|
/// used by a single part stay declared in that part.
|
||||||
|
mixin _JellyfinClientInternals on MediaServerCacheMixin {
|
||||||
|
JellyfinConnection get connection;
|
||||||
|
FailoverHttpClient get _http;
|
||||||
|
MediaItem? _mapItem(Map<String, dynamic> json);
|
||||||
|
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
||||||
|
String? _absolutizeImagePath(String? path);
|
||||||
|
}
|
||||||
|
|
||||||
/// [MediaServerClient] over a Jellyfin server.
|
/// [MediaServerClient] over a Jellyfin server.
|
||||||
///
|
///
|
||||||
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the
|
/// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the
|
||||||
@@ -85,6 +99,7 @@ part 'jellyfin_client/parts/metadata_edit.dart';
|
|||||||
class JellyfinClient
|
class JellyfinClient
|
||||||
with
|
with
|
||||||
MediaServerCacheMixin,
|
MediaServerCacheMixin,
|
||||||
|
_JellyfinClientInternals,
|
||||||
_JellyfinBrowseMethods,
|
_JellyfinBrowseMethods,
|
||||||
_JellyfinMusicMethods,
|
_JellyfinMusicMethods,
|
||||||
_JellyfinPlaybackMethods,
|
_JellyfinPlaybackMethods,
|
||||||
|
|||||||
@@ -30,6 +30,27 @@ List<Map<String, dynamic>> _itemsArray(Object? data) {
|
|||||||
return const [];
|
return const [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Builds a [LibraryPage] from an `/Items`-shaped response: the `Items` array
|
||||||
|
/// run through [map], plus the server's `TotalRecordCount` when it reports one.
|
||||||
|
/// Responses that omit it (or return a non-int) fall back to
|
||||||
|
/// [fallbackPageTotal], whose full-page sentinel keeps pagination enabled;
|
||||||
|
/// [singlePage] endpoints return everything at once, so a full page there means
|
||||||
|
/// the end of the list, not "there may be more".
|
||||||
|
LibraryPage<T> _pagedItems<T>(
|
||||||
|
Object? data, {
|
||||||
|
required int offset,
|
||||||
|
required List<T> Function(List<Map<String, dynamic>>) map,
|
||||||
|
int? requestedSize,
|
||||||
|
bool singlePage = false,
|
||||||
|
}) {
|
||||||
|
final rawItems = _itemsArray(data);
|
||||||
|
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||||
|
final fallbackTotal = singlePage
|
||||||
|
? offset + rawItems.length
|
||||||
|
: fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
||||||
|
return LibraryPage<T>(items: map(rawItems), totalCount: rawTotal is int ? rawTotal : fallbackTotal, offset: offset);
|
||||||
|
}
|
||||||
|
|
||||||
/// Slim field set for grid/list browsing — what the card UI actually
|
/// Slim field set for grid/list browsing — what the card UI actually
|
||||||
/// renders (title, year, watched badge, episode count for series).
|
/// renders (title, year, watched badge, episode count for series).
|
||||||
///
|
///
|
||||||
@@ -145,12 +166,7 @@ const _detailFields =
|
|||||||
// any extra round-trip.
|
// any extra round-trip.
|
||||||
'ProviderIds';
|
'ProviderIds';
|
||||||
|
|
||||||
mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||||
JellyfinConnection get connection;
|
|
||||||
FailoverHttpClient get _http;
|
|
||||||
MediaItem? _mapItem(Map<String, dynamic> json);
|
|
||||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
|
||||||
|
|
||||||
// Endpoint conventions follow what the official Jellyfin Kotlin SDK
|
// Endpoint conventions follow what the official Jellyfin Kotlin SDK
|
||||||
// generates (cross-checked against the Findroid client). The SDK mixes
|
// generates (cross-checked against the Findroid client). The SDK mixes
|
||||||
// `/Users/{userId}/...` for "user library" / "views" / "latest" / "single
|
// `/Users/{userId}/...` for "user library" / "views" / "latest" / "single
|
||||||
@@ -700,7 +716,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
final items = _itemsArray(data);
|
final items = _itemsArray(data);
|
||||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
||||||
if (items.isNotEmpty || (rawTotal is int && rawTotal > 0)) {
|
if (items.isNotEmpty || (rawTotal is int && rawTotal > 0)) {
|
||||||
return _pagedMediaItems(data, offset: offset, requestedSize: pageSize);
|
return _pagedItems(data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} on MediaServerHttpException {
|
} on MediaServerHttpException {
|
||||||
@@ -722,7 +738,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
abort: abort,
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
|
Future<LibraryPage<MediaItem>> fetchSeasonEpisodesPage(
|
||||||
@@ -754,7 +770,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
abort: abort,
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query
|
/// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query
|
||||||
@@ -925,27 +941,19 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
required String includeItemTypes,
|
required String includeItemTypes,
|
||||||
bool byAlbumArtist = false,
|
bool byAlbumArtist = false,
|
||||||
AbortController? abort,
|
AbortController? abort,
|
||||||
}) async {
|
}) {
|
||||||
final all = <MediaItem>[];
|
return drainPages<MediaItem>(
|
||||||
var start = 0;
|
(start, size) => _fetchPlayableDescendantsPage(
|
||||||
while (true) {
|
|
||||||
abort?.throwIfAborted();
|
|
||||||
final page = await _fetchPlayableDescendantsPage(
|
|
||||||
parentId,
|
parentId,
|
||||||
start: start,
|
start: start,
|
||||||
size: _pagedListPageSize,
|
size: size,
|
||||||
abort: abort,
|
abort: abort,
|
||||||
includeItemTypes: includeItemTypes,
|
includeItemTypes: includeItemTypes,
|
||||||
byAlbumArtist: byAlbumArtist,
|
byAlbumArtist: byAlbumArtist,
|
||||||
);
|
),
|
||||||
abort?.throwIfAborted();
|
pageSize: _pagedListPageSize,
|
||||||
if (page.items.isEmpty) break;
|
abort: abort,
|
||||||
all.addAll(page.items);
|
);
|
||||||
start += page.items.length;
|
|
||||||
if (start >= page.totalCount) break;
|
|
||||||
}
|
|
||||||
abort?.throwIfAborted();
|
|
||||||
return all;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -991,7 +999,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
abort: abort,
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// All episodes of a series in the app's **aired watch order** — primarily by
|
/// All episodes of a series in the app's **aired watch order** — primarily by
|
||||||
@@ -1146,18 +1154,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchPersonMedia(String personId) async {
|
Future<List<MediaItem>> fetchPersonMedia(String personId) => drainPages<MediaItem>(
|
||||||
final all = <MediaItem>[];
|
(start, size) => fetchPersonMediaPage(personId, start: start, size: size),
|
||||||
var start = 0;
|
pageSize: _pagedListPageSize,
|
||||||
while (true) {
|
);
|
||||||
final page = await fetchPersonMediaPage(personId, start: start, size: _pagedListPageSize);
|
|
||||||
if (page.items.isEmpty) break;
|
|
||||||
all.addAll(page.items);
|
|
||||||
start += page.items.length;
|
|
||||||
if (start >= page.totalCount) break;
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
|
Future<LibraryPage<MediaItem>> fetchPersonMediaPage(
|
||||||
@@ -1186,7 +1186,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
abort: abort,
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize);
|
return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -1637,16 +1637,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
try {
|
try {
|
||||||
final response = await _http.get(path, queryParameters: queryParameters, abort: abort);
|
final response = await _http.get(path, queryParameters: queryParameters, abort: abort);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
final data = response.data;
|
return _pagedItems(
|
||||||
final rawItems = data is List ? data.whereType<Map<String, dynamic>>().toList() : _itemsArray(data);
|
response.data,
|
||||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
|
||||||
final fallbackTotal = singlePage
|
|
||||||
? offset + rawItems.length
|
|
||||||
: fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
|
||||||
return LibraryPage<MediaItem>(
|
|
||||||
items: _mapItems(rawItems),
|
|
||||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
|
||||||
offset: offset,
|
offset: offset,
|
||||||
|
requestedSize: requestedSize,
|
||||||
|
singlePage: singlePage,
|
||||||
|
map: _mapItems,
|
||||||
);
|
);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.w('JellyfinClient: $path failed', error: e, stackTrace: st);
|
appLogger.w('JellyfinClient: $path failed', error: e, stackTrace: st);
|
||||||
@@ -1654,17 +1650,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
LibraryPage<MediaItem> _pagedMediaItems(Object? data, {required int offset, required int requestedSize}) {
|
|
||||||
final rawItems = _itemsArray(data);
|
|
||||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
|
||||||
final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
|
||||||
return LibraryPage<MediaItem>(
|
|
||||||
items: _mapItems(rawItems),
|
|
||||||
totalCount: rawTotal is int ? rawTotal : fallbackTotal,
|
|
||||||
offset: offset,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async {
|
Future<List<MediaHub>> fetchRelatedHubs(String id, {int count = 10}) async {
|
||||||
final response = await _http.get(
|
final response = await _http.get(
|
||||||
|
|||||||
@@ -1,27 +1,15 @@
|
|||||||
part of '../../jellyfin_client.dart';
|
part of '../../jellyfin_client.dart';
|
||||||
|
|
||||||
mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
mixin _JellyfinCollectionMethods on _JellyfinClientInternals {
|
||||||
JellyfinConnection get connection;
|
|
||||||
FailoverHttpClient get _http;
|
|
||||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
|
||||||
|
|
||||||
static const int _collectionsPageSize = 36;
|
static const int _collectionsPageSize = 36;
|
||||||
|
|
||||||
String? _boxSetsViewId;
|
String? _boxSetsViewId;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<List<MediaItem>> fetchCollections(String libraryId) async {
|
Future<List<MediaItem>> fetchCollections(String libraryId) => drainPages<MediaItem>(
|
||||||
final all = <MediaItem>[];
|
(start, size) => fetchCollectionsPage(libraryId, start: start, size: size),
|
||||||
var start = 0;
|
pageSize: _collectionsPageSize,
|
||||||
while (true) {
|
);
|
||||||
final page = await fetchCollectionsPage(libraryId, start: start, size: _collectionsPageSize);
|
|
||||||
all.addAll(page.items);
|
|
||||||
if (page.items.isEmpty) break;
|
|
||||||
start += page.items.length;
|
|
||||||
if (start >= page.totalCount) break;
|
|
||||||
}
|
|
||||||
return all;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
Future<LibraryPage<MediaItem>> fetchCollectionsPage(
|
||||||
@@ -54,7 +42,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
|||||||
abort: abort,
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _itemsPage(response.data, offset: s, requestedSize: pageSize);
|
return _pagedItems(response.data, offset: s, requestedSize: pageSize, map: _mapItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String?> _fetchBoxSetsViewId({AbortController? abort}) async {
|
Future<String?> _fetchBoxSetsViewId({AbortController? abort}) async {
|
||||||
@@ -73,14 +61,6 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
LibraryPage<MediaItem> _itemsPage(Object? data, {required int offset, int? requestedSize}) {
|
|
||||||
final rawItems = _itemsArray(data);
|
|
||||||
final rawTotal = data is Map<String, dynamic> ? data['TotalRecordCount'] : null;
|
|
||||||
final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize);
|
|
||||||
final total = rawTotal is int ? rawTotal : fallbackTotal;
|
|
||||||
return LibraryPage<MediaItem>(items: _mapItems(rawItems), totalCount: total, offset: offset);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
Future<LibraryPage<MediaItem>> fetchCollectionPage(
|
||||||
String collectionId, {
|
String collectionId, {
|
||||||
@@ -104,7 +84,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin {
|
|||||||
abort: abort,
|
abort: abort,
|
||||||
);
|
);
|
||||||
throwIfHttpError(response);
|
throwIfHttpError(response);
|
||||||
return _itemsPage(response.data, offset: s, requestedSize: size);
|
return _pagedItems(response.data, offset: s, requestedSize: size, map: _mapItems);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
part of '../../jellyfin_client.dart';
|
part of '../../jellyfin_client.dart';
|
||||||
|
|
||||||
mixin _JellyfinFileInfoMethods on MediaServerCacheMixin {
|
mixin _JellyfinFileInfoMethods on _JellyfinClientInternals {
|
||||||
@override
|
@override
|
||||||
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
|
Future<MediaFileInfo?> getFileInfo(MediaItem item) async {
|
||||||
// Lightweight browse responses omit `MediaSources`; detail and some cached
|
// Lightweight browse responses omit `MediaSources`; detail and some cached
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
part of '../../jellyfin_client.dart';
|
part of '../../jellyfin_client.dart';
|
||||||
|
|
||||||
mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin {
|
mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals {
|
||||||
JellyfinConnection get connection;
|
|
||||||
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
|
Future<JellyfinPlaybackBundle?> fetchPlaybackBundle(
|
||||||
String itemId, {
|
String itemId, {
|
||||||
int sourceIndex = 0,
|
int sourceIndex = 0,
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
part of '../../jellyfin_client.dart';
|
part of '../../jellyfin_client.dart';
|
||||||
|
|
||||||
mixin _JellyfinLiveTvMethods on MediaServerCacheMixin {
|
mixin _JellyfinLiveTvMethods on _JellyfinClientInternals {
|
||||||
JellyfinConnection get connection;
|
|
||||||
FailoverHttpClient get _http;
|
|
||||||
String? _absolutizeImagePath(String? path);
|
|
||||||
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(
|
Future<List<Map<String, dynamic>>> _safeFetchItemsArray(
|
||||||
String path,
|
String path,
|
||||||
Map<String, dynamic> queryParameters, {
|
Map<String, dynamic> queryParameters, {
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
part of '../../jellyfin_client.dart';
|
part of '../../jellyfin_client.dart';
|
||||||
|
|
||||||
mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin {
|
mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals {
|
||||||
JellyfinConnection get connection;
|
|
||||||
FailoverHttpClient get _http;
|
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> fetchEditableMetadataItem(String itemId) async {
|
Future<Map<String, dynamic>?> fetchEditableMetadataItem(String itemId) async {
|
||||||
if (isOfflineMode) return null;
|
if (isOfflineMode) return null;
|
||||||
final response = await _http.get('/Users/${_segment(connection.userId)}/Items/${_segment(itemId)}');
|
final response = await _http.get('/Users/${_segment(connection.userId)}/Items/${_segment(itemId)}');
|
||||||
|
|||||||
@@ -4,11 +4,7 @@ part of '../../jellyfin_client.dart';
|
|||||||
/// listings, instant mix, and lyrics. Endpoint conventions follow the
|
/// listings, instant mix, and lyrics. Endpoint conventions follow the
|
||||||
/// Jellyfin web client's music surface (cross-checked against the Kotlin
|
/// Jellyfin web client's music surface (cross-checked against the Kotlin
|
||||||
/// SDK), mirroring the style notes at the top of `browse.dart`.
|
/// SDK), mirroring the style notes at the top of `browse.dart`.
|
||||||
mixin _JellyfinMusicMethods on MediaServerCacheMixin {
|
mixin _JellyfinMusicMethods on _JellyfinClientInternals {
|
||||||
JellyfinConnection get connection;
|
|
||||||
FailoverHttpClient get _http;
|
|
||||||
List<MediaItem> _mapItems(Iterable<Map<String, dynamic>> items);
|
|
||||||
|
|
||||||
/// Albums credited to [artist], newest first. Queries `AlbumArtistIds`
|
/// Albums credited to [artist], newest first. Queries `AlbumArtistIds`
|
||||||
/// rather than `ParentId` because Jellyfin links albums to artists via
|
/// rather than `ParentId` because Jellyfin links albums to artists via
|
||||||
/// tags — an artist's albums are usually not its folder children.
|
/// tags — an artist's albums are usually not its folder children.
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user