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:
edde746
2026-07-26 06:09:48 +02:00
parent 61344f7862
commit 352b88109b
217 changed files with 6813 additions and 8773 deletions
@@ -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
View File
@@ -25,10 +25,10 @@ on:
type: boolean type: boolean
env: env:
SENTRY_DART_DEFINE: ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} # Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
GIT_COMMIT_DART_DEFINE: --dart-define=GIT_COMMIT=${{ github.sha }} FLUTTER_VERSION: "3.44.0"
SENTRY_ENV_DART_DEFINE: --dart-define=SENTRY_ENVIRONMENT=github # Shared by every release build command; SENTRY_DIST stays per-platform.
DONATIONS_DART_DEFINE: --dart-define=ENABLE_DONATIONS=true RELEASE_DART_DEFINES: --dart-define=ENABLE_UPDATE_CHECK=true ${{ github.repository == 'edde746/plezy' && '--dart-define=ENABLE_SENTRY=true' || '' }} --dart-define=GIT_COMMIT=${{ github.sha }} --dart-define=SENTRY_ENVIRONMENT=github --dart-define=ENABLE_DONATIONS=true
TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1 TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1
LINUX_APT_PACKAGES: > LINUX_APT_PACKAGES: >
clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev clang cmake meson ninja-build pkg-config nasm libgtk-3-dev libevdev-dev liblzma-dev
@@ -76,7 +76,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -110,7 +110,7 @@ jobs:
EOF EOF
- name: Build APKs - name: Build APKs
run: flutter build apk --release --split-per-abi --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-android-apk ${{ env.DONATIONS_DART_DEFINE }} --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json run: flutter build apk --release --split-per-abi ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-android-apk --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json
- name: Upload symbols to bugs.plezy.app - name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy' if: github.repository == 'edde746/plezy'
@@ -163,7 +163,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -188,7 +188,7 @@ jobs:
run: flutter pub get --enforce-lockfile --no-example run: flutter pub get --enforce-lockfile --no-example
- name: Build iOS (no codesign) - name: Build iOS (no codesign)
run: flutter build ios --release --no-codesign --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-ios ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/ios run: flutter build ios --release --no-codesign ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-ios --split-debug-info=debug-info/ios
- name: Upload symbols to bugs.plezy.app - name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy' if: github.repository == 'edde746/plezy'
@@ -231,7 +231,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:" cache-key: "${{ env.TRUSTED_BUILD_CACHE_VERSION }}-flutter-:os:-:channel:-:version:-:arch:-:hash:"
pub-cache: false pub-cache: false
@@ -256,7 +256,7 @@ jobs:
run: flutter pub get --enforce-lockfile --no-example run: flutter pub get --enforce-lockfile --no-example
- name: Build macOS - name: Build macOS
run: flutter build macos --release --dart-define=ENABLE_UPDATE_CHECK=true ${{ env.SENTRY_DART_DEFINE }} ${{ env.GIT_COMMIT_DART_DEFINE }} ${{ env.SENTRY_ENV_DART_DEFINE }} --dart-define=SENTRY_DIST=github-macos ${{ env.DONATIONS_DART_DEFINE }} --split-debug-info=debug-info/macos run: flutter build macos --release ${{ env.RELEASE_DART_DEFINES }} --dart-define=SENTRY_DIST=github-macos --split-debug-info=debug-info/macos
- name: Upload symbols to bugs.plezy.app - name: Upload symbols to bugs.plezy.app
if: github.repository == 'edde746/plezy' if: github.repository == 'edde746/plezy'
@@ -436,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
View File
@@ -10,6 +10,10 @@ on:
- main - main
workflow_dispatch: workflow_dispatch:
env:
# Only place this workflow names the SDK; .github/actions/setup-flutter-git pins the same release.
FLUTTER_VERSION: "3.44.0"
jobs: jobs:
analyze: analyze:
name: Code Analysis name: Code Analysis
@@ -26,7 +30,7 @@ jobs:
uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2 uses: subosito/flutter-action@1a449444c387b1966244ae4d4f8c696479add0b2 # v2
with: with:
channel: "stable" channel: "stable"
flutter-version: "3.44.0" flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true cache: true
pub-cache: false pub-cache: false
@@ -53,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
+5 -5
View File
@@ -243,7 +243,7 @@ jobs:
uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 uses: actions/cache/save@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6
with: with:
path: ~/.pub-cache path: ~/.pub-cache
key: ${{ runner.os }}-pub-v3-${{ hashFiles('**/pubspec.yaml', '**/pubspec.lock') }} key: ${{ steps.pub-cache.outputs.cache-primary-key }}
- name: Save Gradle cache - name: Save Gradle cache
if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.gradle-cache.outputs.cache-hit != 'true'
@@ -252,7 +252,7 @@ jobs:
path: | path: |
~/.gradle/caches ~/.gradle/caches
~/.gradle/wrapper ~/.gradle/wrapper
key: ${{ runner.os }}-gradle-e2e-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} key: ${{ steps.gradle-cache.outputs.cache-primary-key }}
- name: Save Maestro CLI cache - name: Save Maestro CLI cache
if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.maestro-cache.outputs.cache-hit != 'true'
@@ -261,7 +261,7 @@ jobs:
path: | path: |
~/.maestro/bin ~/.maestro/bin
~/.maestro/lib ~/.maestro/lib
key: ${{ runner.os }}-maestro-${{ env.MAESTRO_VERSION }} key: ${{ steps.maestro-cache.outputs.cache-primary-key }}
- name: Save Android 15 AVD cache - name: Save Android 15 AVD cache
if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.api35-avd-cache.outputs.cache-hit != 'true'
@@ -270,7 +270,7 @@ jobs:
path: | path: |
~/.android/avd/maestro-api35.avd ~/.android/avd/maestro-api35.avd
~/.android/avd/maestro-api35.ini ~/.android/avd/maestro-api35.ini
key: ${{ runner.os }}-avd-v1-api35-x86_64-pixel_6 key: ${{ steps.api35-avd-cache.outputs.cache-primary-key }}
- name: Save Android 9 AVD cache - name: Save Android 9 AVD cache
if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true' if: github.event_name != 'pull_request' && steps.api28-avd-cache.outputs.cache-hit != 'true'
@@ -279,7 +279,7 @@ jobs:
path: | path: |
~/.android/avd/maestro-api28.avd ~/.android/avd/maestro-api28.avd
~/.android/avd/maestro-api28.ini ~/.android/avd/maestro-api28.ini
key: ${{ runner.os }}-avd-v1-api28-x86-pixel_2-playstore key: ${{ steps.api28-avd-cache.outputs.cache-primary-key }}
- name: Upload Maestro diagnostics - name: Upload Maestro diagnostics
if: always() if: always()
@@ -77,6 +77,7 @@ import com.edde746.plezy.shared.FlutterOverlayHelper
import com.edde746.plezy.shared.FrameRateManager import com.edde746.plezy.shared.FrameRateManager
import com.edde746.plezy.shared.MediaCodecQuery import com.edde746.plezy.shared.MediaCodecQuery
import com.edde746.plezy.shared.PlayerSurfaceHost import com.edde746.plezy.shared.PlayerSurfaceHost
import com.edde746.plezy.shared.SurfacePlayerCore
import java.util.concurrent.Executors import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicLong import java.util.concurrent.atomic.AtomicLong
import org.chromium.net.CronetEngine import org.chromium.net.CronetEngine
@@ -102,7 +103,7 @@ interface ExoPlayerDelegate : com.edde746.plezy.shared.PlayerDelegate {
internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null internal fun playbackMimeType(isLive: Boolean): String? = if (isLive) MimeTypes.APPLICATION_M3U8 else null
@OptIn(UnstableApi::class) @OptIn(UnstableApi::class)
class ExoPlayerCore(private val activity: Activity) : Player.Listener { class ExoPlayerCore(private val activity: Activity) : Player.Listener, SurfacePlayerCore {
companion object { companion object {
private const val TAG = "ExoPlayerCore" private const val TAG = "ExoPlayerCore"
@@ -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) {
val core = mpvCore
if (core == null) { if (core == null) {
result.success(false) result.success(false)
} else { return
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete)
}
} else {
val core = playerCore
if (core == null) {
result.success(false)
} else {
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight, onComplete)
} }
core.setVideoFrameRate(fps, duration, extraDelayMs, videoWidth, videoHeight) { switched ->
result.success(switched)
} }
} }
private fun handleClearVideoFrameRate(result: MethodChannel.Result) { private fun handleClearVideoFrameRate(result: MethodChannel.Result) {
Log.d(TAG, "clearVideoFrameRate") Log.d(TAG, "clearVideoFrameRate")
if (usingMpvFallback) { activeSurfaceCore?.clearVideoFrameRate()
mpvCore?.clearVideoFrameRate()
} else {
playerCore?.clearVideoFrameRate()
}
result.success(null) result.success(null)
} }
private fun handleRequestAudioFocus(result: MethodChannel.Result) { private fun handleRequestAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "requestAudioFocus") Log.d(TAG, "requestAudioFocus")
val granted = if (usingMpvFallback) { val granted = activeSurfaceCore?.requestAudioFocus() ?: false
mpvCore?.requestAudioFocus() ?: false
} else {
playerCore?.requestAudioFocus() ?: false
}
result.success(granted) result.success(granted)
} }
private fun handleAbandonAudioFocus(result: MethodChannel.Result) { private fun handleAbandonAudioFocus(result: MethodChannel.Result) {
Log.d(TAG, "abandonAudioFocus") Log.d(TAG, "abandonAudioFocus")
if (usingMpvFallback) { activeSurfaceCore?.abandonAudioFocus()
mpvCore?.abandonAudioFocus()
} else {
playerCore?.abandonAudioFocus()
}
result.success(null) result.success(null)
} }
@@ -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
)
}
+1 -11
View File
@@ -29,7 +29,7 @@ class ConnectionBootstrap {
required this.profileRegistry, required this.profileRegistry,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher, Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher, Future<Map<String, dynamic>> Function(String accountToken)? plexUserInfoFetcher,
}) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers, }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers,
_plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo; _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo;
final StorageService storage; final StorageService storage;
@@ -283,16 +283,6 @@ class ConnectionBootstrap {
} }
} }
Future<List<PlexHomeUser>> _fetchPlexHomeUsers(String accountToken) async {
final auth = await PlexAuthService.create();
try {
final home = await auth.getHomeUsers(accountToken);
return home.users;
} finally {
auth.dispose();
}
}
Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async { Future<Map<String, dynamic>> _fetchPlexUserInfo(String accountToken) async {
final auth = await PlexAuthService.create(); final auth = await PlexAuthService.create();
try { try {
+138 -299
View File
@@ -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 global_key, server_id, parent_rating_key AS metadata_id
FROM downloaded_media
WHERE parent_rating_key IS NOT NULL
AND parent_rating_key != ''
UNION
SELECT global_key, server_id, grandparent_rating_key AS metadata_id
FROM downloaded_media
WHERE grandparent_rating_key IS NOT NULL
AND grandparent_rating_key != ''
)
INSERT INTO api_cache (cache_key, data, pinned, cached_at)
SELECT DISTINCT
metadata.server_id
|| '/~plex-transfer:'
|| substr(source.cache_key, length(metadata.server_id) + 2),
source.data,
source.pinned,
source.cached_at
FROM download_metadata_ids AS metadata
JOIN api_cache AS source
ON source.cache_key =
metadata.server_id || ':/library/metadata/' || metadata.metadata_id
OR source.cache_key =
metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children'
WHERE source.pinned = 1
AND NOT EXISTS (
SELECT 1 SELECT 1
FROM download_owners AS owner FROM download_owners AS owner
WHERE owner.global_key = metadata.global_key WHERE owner.global_key = metadata.global_key
) )''',
ON CONFLICT(cache_key) DO UPDATE SET ),
data = excluded.data, );
pinned = excluded.pinned,
cached_at = excluded.cached_at
''');
final transferRows = await customSelect(''' final transferRows = await customSelect('''
SELECT cache_key, data SELECT cache_key, data
@@ -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()
-58
View File
@@ -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,
+1 -1
View File
@@ -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)();
+207
View File
@@ -0,0 +1,207 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../widgets/overlay_sheet.dart';
import 'dpad_navigator.dart';
import 'key_event_utils.dart';
/// D-pad "move mode" reordering for a remote/keyboard-driven list of rows
/// inside a sheet or dialog.
///
/// The host keeps its list and row widgets; this mixin owns the virtual cursor
/// ([focusedIndex] / [focusedColumn]), the move-mode state and the key handler.
/// Wire it up by passing [handleReorderKeyEvent] to the list's
/// `Focus.onKeyEvent` and by reading [focusedIndex], [focusedColumn] and
/// [movingIndex] when building rows.
///
/// Navigation mode: UP/DOWN move between rows (resetting to column 0),
/// LEFT/RIGHT move between the row (column 0) and the trailing action columns
/// up to [lastReorderColumn], SELECT on column 0 enters move mode and on any
/// other column calls [onReorderColumnActivated].
///
/// Move mode: UP/DOWN swap the moving row with its neighbour, SELECT confirms
/// through [onReorderMoveConfirmed], and BACK restores the order captured when
/// move mode was entered. BACK outside move mode dismisses the hosting sheet.
/// D-pad keys are consumed at the list boundaries so focus cannot escape.
mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
/// Row height assumed by [ensureFocusedVisible] (Material `ListTile` with a
/// subtitle) and the list's top padding.
static const double _itemHeight = 72.0;
static const double _listTopPadding = 8.0;
/// Row the virtual cursor sits on.
int focusedIndex = 0;
/// Column within [focusedIndex]: 0 is the row itself, 1..[lastReorderColumn]
/// are the trailing action buttons.
int focusedColumn = 0;
/// Row being moved, or null when not in move mode.
int? movingIndex;
int? _originalIndex;
List<E>? _originalOrder;
bool _backKeyDownSeen = false;
/// The list being reordered. Mutated in place while moving and replaced
/// wholesale when a move is cancelled.
List<E> get reorderItems;
set reorderItems(List<E> value);
/// Right-most focusable column index (0 when the row has no action buttons).
int get lastReorderColumn;
/// Scrollable holding the rows, or null when the host does not scroll the
/// focused row into view.
ScrollController? get reorderScrollController;
/// Called when SELECT confirms a move; [reorderItems] already holds the new
/// order.
void onReorderMoveConfirmed();
/// Called when SELECT activates a trailing action column (1 or greater).
void onReorderColumnActivated(int column, int index);
/// Scrolls [focusedIndex] into view, parking it ~25% from the viewport top.
void ensureFocusedVisible() {
final scrollController = reorderScrollController;
if (scrollController == null || !scrollController.hasClients) return;
final double targetTop = _listTopPadding + (focusedIndex * _itemHeight);
final double targetBottom = targetTop + _itemHeight;
final double viewportTop = scrollController.offset;
final double viewportHeight = scrollController.position.viewportDimension;
final double viewportBottom = viewportTop + viewportHeight;
// Already fully visible — skip
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
final double destination = (targetTop - viewportHeight * 0.25).clamp(
0.0,
scrollController.position.maxScrollExtent,
);
scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut);
}
KeyEventResult handleReorderKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey;
// Track back key down/up pairing. If focus was elsewhere during KeyDown
// (e.g., on a bottom sheet) and returns here before KeyUp, we get a stray
// KeyUp that would incorrectly pop the dialog. Consume it instead.
if (key.isBackKey) {
if (event is KeyDownEvent) {
_backKeyDownSeen = true;
} else if (event is KeyUpEvent && !_backKeyDownSeen) {
return KeyEventResult.handled;
}
if (event is KeyUpEvent) {
_backKeyDownSeen = false;
}
}
final backResult = handleBackKeyAction(event, () {
if (movingIndex != null) {
// Cancel move - restore original position
setState(() {
final originalOrder = _originalOrder;
if (originalOrder != null) {
reorderItems = List<E>.from(originalOrder);
}
focusedIndex = _originalIndex ?? 0;
movingIndex = null;
_originalIndex = null;
_originalOrder = null;
});
} else {
OverlaySheetController.popAdaptive(context);
}
});
if (backResult != KeyEventResult.ignored) {
return backResult;
}
if (!event.isActionable) return KeyEventResult.ignored;
final int? moving = movingIndex;
if (moving != null) {
// Move mode - arrows reorder the item
if (key.isUpKey && moving > 0) {
_swapMovingItem(moving, moving - 1);
return KeyEventResult.handled;
}
if (key.isDownKey && moving < reorderItems.length - 1) {
_swapMovingItem(moving, moving + 1);
return KeyEventResult.handled;
}
if (key.isSelectKey) {
// Confirm move - apply the reorder
onReorderMoveConfirmed();
setState(() {
movingIndex = null;
_originalIndex = null;
_originalOrder = null;
});
return KeyEventResult.handled;
}
} else {
// Navigation mode
if (key.isUpKey && focusedIndex > 0) {
setState(() {
focusedIndex--;
focusedColumn = 0; // Reset to row when changing rows
});
ensureFocusedVisible();
return KeyEventResult.handled;
}
if (key.isDownKey && focusedIndex < reorderItems.length - 1) {
setState(() {
focusedIndex++;
focusedColumn = 0; // Reset to row when changing rows
});
ensureFocusedVisible();
return KeyEventResult.handled;
}
if (key.isLeftKey && focusedColumn > 0) {
setState(() => focusedColumn--);
return KeyEventResult.handled;
}
if (key.isRightKey && focusedColumn < lastReorderColumn) {
setState(() => focusedColumn++);
return KeyEventResult.handled;
}
if (key.isSelectKey) {
if (focusedColumn == 0) {
// Enter move mode
setState(() {
movingIndex = focusedIndex;
_originalIndex = focusedIndex;
_originalOrder = List<E>.from(reorderItems);
});
} else {
onReorderColumnActivated(focusedColumn, focusedIndex);
}
return KeyEventResult.handled;
}
}
// Block d-pad keys at boundaries so focus doesn't escape the dialog
if (key.isDpadDirection) {
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
}
void _swapMovingItem(int from, int to) {
setState(() {
final item = reorderItems.removeAt(from);
reorderItems.insert(to, item);
movingIndex = to;
focusedIndex = to;
});
ensureFocusedVisible();
}
}
+20 -16
View File
@@ -32,22 +32,18 @@ class ChipKeyCallbacks {
/// This mixin handles: /// This mixin handles:
/// - Internal/external FocusNode pattern /// - Internal/external FocusNode pattern
/// - `_isFocused` state tracking /// - `_isFocused` state tracking
/// - Listener setup in `initState` /// - Listener setup, handoff and cleanup across the State lifecycle
/// - Listener handoff in `didUpdateWidget`
/// - Cleanup in `dispose`
/// ///
/// To use this mixin: /// To use this mixin:
/// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class /// 1. Add `with FocusableChipStateMixin<YourWidget>` to your State class
/// 2. Implement [widgetFocusNode] to return the widget's optional focusNode /// 2. Implement [widgetFocusNode] to return the widget's optional focusNode
/// 3. Implement [debugLabel] to return a debug label for the internal node /// 3. Implement [debugLabel] to return a debug label for the internal node
/// 4. Call [initFocusNode] in your `initState` /// 4. Use [focusNode] and [isFocused] in your build method
/// 5. Call [updateFocusNode] in your `didUpdateWidget`
/// 6. Call [disposeFocusNode] in your `dispose`
/// 7. Use [focusNode] and [isFocused] in your build method
mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> { mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
final _focusNodeBinding = OwnedFocusNodeBinding(); final _focusNodeBinding = OwnedFocusNodeBinding();
bool _isFocused = false; bool _isFocused = false;
final _selectLongPress = DpadSelectLongPressController(); final _selectLongPress = DpadSelectLongPressController();
FocusNode? _boundExternalNode;
/// Override to return the widget's optional external focus node. /// Override to return the widget's optional external focus node.
FocusNode? get widgetFocusNode; FocusNode? get widgetFocusNode;
@@ -61,22 +57,30 @@ mixin FocusableChipStateMixin<T extends StatefulWidget> on State<T> {
/// Whether this widget is currently focused. /// Whether this widget is currently focused.
bool get isFocused => _isFocused; bool get isFocused => _isFocused;
/// Call this in your `initState` to set up the focus listener. @override
void initFocusNode() { void initState() {
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); super.initState();
_bindFocusNode();
} }
/// Call this in your `didUpdateWidget` with the old widget's focusNode. @override
void updateFocusNode(FocusNode? oldFocusNode) { void didUpdateWidget(T oldWidget) {
if (oldFocusNode != widgetFocusNode) { super.didUpdateWidget(oldWidget);
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel); if (_boundExternalNode != widgetFocusNode) {
_bindFocusNode();
} }
} }
/// Call this in your `dispose` to clean up the focus listener. @override
void disposeFocusNode() { void dispose() {
_focusNodeBinding.dispose(); _focusNodeBinding.dispose();
_selectLongPress.dispose(); _selectLongPress.dispose();
super.dispose();
}
void _bindFocusNode() {
_boundExternalNode = widgetFocusNode;
_focusNodeBinding.bind(externalNode: widgetFocusNode, listener: _onFocusChange, debugLabel: debugLabel);
} }
void _onFocusChange() { void _onFocusChange() {
+15 -34
View File
@@ -24,6 +24,7 @@ import 'profiles/profile.dart';
import 'profiles/profile_connection_cleanup.dart'; import 'profiles/profile_connection_cleanup.dart';
import 'profiles/profile_connection_registry.dart'; import 'profiles/profile_connection_registry.dart';
import 'profiles/profile_registry.dart'; import 'profiles/profile_registry.dart';
import 'profiles/profile_selection_policy.dart';
import 'mixins/mounted_set_state_mixin.dart'; import 'mixins/mounted_set_state_mixin.dart';
import 'theme/mono_theme.dart'; import 'theme/mono_theme.dart';
import 'profiles/plex_home_service.dart'; import 'profiles/plex_home_service.dart';
@@ -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) {
+31
View File
@@ -1,6 +1,7 @@
// ignore_for_file: invalid_annotation_target // ignore_for_file: invalid_annotation_target
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';
import '../utils/media_server_http_client.dart' show AbortController;
import 'media_kind.dart'; import 'media_kind.dart';
part 'library_query.freezed.dart'; part 'library_query.freezed.dart';
@@ -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;
}
+27 -47
View File
@@ -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() ?? ''));
+131 -1
View File
@@ -1,3 +1,4 @@
import '../i18n/strings.g.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_kind.dart'; import '../media/media_kind.dart';
@@ -147,7 +148,9 @@ abstract class MetadataEditAdapter {
Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field); Future<List<MetadataArtworkOption>> fetchArtwork(MetadataEditDraft draft, MetadataEditField field);
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option); Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
return applyArtworkFromUrl(draft, field, option.sourceUrl);
}
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url); Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url);
@@ -160,6 +163,133 @@ abstract class MetadataEditAdapter {
} }
} }
/// Basic-info fields shared by every backend; only the studio field type differs.
List<MetadataEditField> metadataBasicFields(
MediaKind kind, {
MetadataEditFieldType studioType = MetadataEditFieldType.text,
}) {
return [
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
if (kind != MediaKind.season)
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
if (kind == MediaKind.movie || kind == MediaKind.show)
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
if (kind != MediaKind.season)
MetadataEditField(
id: 'originallyAvailableAt',
label: t.metadataEdit.releaseDate,
type: MetadataEditFieldType.date,
),
if (kind != MediaKind.season)
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
if (kind == MediaKind.movie || kind == MediaKind.show)
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: studioType),
if (kind == MediaKind.movie || kind == MediaKind.show)
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
];
}
/// Artwork fields shared by every backend; each backend supplies its own artwork
/// key names, which kinds carry a logo, and whether square art exists.
List<MetadataEditField> metadataArtworkFields(
MediaKind kind, {
required String posterKey,
required String backdropKey,
required String logoKey,
String? squareKey,
Set<MediaKind> logoKinds = const {MediaKind.movie, MediaKind.show},
}) {
final fields = <MetadataEditField>[
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art.
kind == MediaKind.episode
? metadataArtworkField(
posterKey,
t.metadataEdit.poster,
t.metadataEdit.selectPoster,
80,
45,
2,
16 / 9,
imageType: ImageType.thumb,
)
: metadataArtworkField(posterKey, t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
];
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
fields.add(
metadataArtworkField(
backdropKey,
t.metadataEdit.background,
t.metadataEdit.selectBackground,
80,
45,
2,
16 / 9,
imageType: ImageType.art,
),
);
}
if (logoKinds.contains(kind)) {
fields.add(
metadataArtworkField(
logoKey,
t.metadataEdit.logo,
t.metadataEdit.selectLogo,
80,
32,
2,
2.5,
fit: MetadataArtworkFit.contain,
imageType: ImageType.logo,
),
);
if (squareKey != null) {
fields.add(
metadataArtworkField(
squareKey,
t.metadataEdit.squareArt,
t.metadataEdit.selectSquareArt,
50,
50,
3,
1,
imageType: ImageType.avatar,
),
);
}
}
return fields;
}
MetadataEditField metadataArtworkField(
String key,
String label,
String title,
double width,
double height,
int columns,
double aspectRatio, {
MetadataArtworkFit fit = MetadataArtworkFit.cover,
ImageType imageType = ImageType.poster,
}) {
return MetadataEditField(
id: 'artwork:$key',
label: label,
type: MetadataEditFieldType.artwork,
saveMode: MetadataEditSaveMode.immediate,
artwork: MetadataArtworkConfig(
key: key,
selectTitle: title,
previewWidth: width,
previewHeight: height,
gridColumns: columns,
gridAspectRatio: aspectRatio,
fit: fit,
imageType: imageType,
),
);
}
bool metadataEditValueEquals(Object? a, Object? b) { bool metadataEditValueEquals(Object? a, Object? b) {
if (identical(a, b)) return true; if (identical(a, b)) return true;
if (a is List && b is List) { if (a is List && b is List) {
@@ -7,7 +7,6 @@ import '../media/media_server_client.dart';
import '../services/plex_client.dart'; import '../services/plex_client.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/language_codes.dart'; import '../utils/language_codes.dart';
import '../utils/media_image_helper.dart';
import 'metadata_edit_models.dart'; import 'metadata_edit_models.dart';
class PlexMetadataEditAdapter extends MetadataEditAdapter { class PlexMetadataEditAdapter extends MetadataEditAdapter {
@@ -60,7 +59,7 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
List<MetadataEditSection> buildSchema(MetadataEditDraft draft) { List<MetadataEditSection> buildSchema(MetadataEditDraft draft) {
final kind = draft.sourceItem.kind; final kind = draft.sourceItem.kind;
return [ return [
MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: _basicFields(kind)), MetadataEditSection(id: 'basic', title: t.metadataEdit.basicInfo, fields: metadataBasicFields(kind)),
if (_tagFields(kind).isNotEmpty) if (_tagFields(kind).isNotEmpty)
MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)), MetadataEditSection(id: 'tags', title: t.metadataEdit.tags, fields: _tagFields(kind)),
MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)), MetadataEditSection(id: 'artwork', title: t.metadataEdit.artwork, fields: _artworkFields(kind)),
@@ -133,11 +132,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
.toList(); .toList();
} }
@override
Future<bool> applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) {
return applyArtworkFromUrl(draft, field, option.sourceUrl);
}
@override @override
Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { Future<bool> applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async {
final element = field.artwork?.key; final element = field.artwork?.key;
@@ -213,29 +207,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
} }
} }
List<MetadataEditField> _basicFields(MediaKind kind) {
return [
MetadataEditField(id: 'title', label: t.metadataEdit.title, type: MetadataEditFieldType.text),
if (kind != MediaKind.season)
MetadataEditField(id: 'titleSort', label: t.metadataEdit.sortTitle, type: MetadataEditFieldType.text),
if (kind == MediaKind.movie || kind == MediaKind.show)
MetadataEditField(id: 'originalTitle', label: t.metadataEdit.originalTitle, type: MetadataEditFieldType.text),
if (kind != MediaKind.season)
MetadataEditField(
id: 'originallyAvailableAt',
label: t.metadataEdit.releaseDate,
type: MetadataEditFieldType.date,
),
if (kind != MediaKind.season)
MetadataEditField(id: 'contentRating', label: t.metadataEdit.contentRating, type: MetadataEditFieldType.text),
if (kind == MediaKind.movie || kind == MediaKind.show)
MetadataEditField(id: 'studio', label: t.metadataEdit.studio, type: MetadataEditFieldType.text),
if (kind == MediaKind.movie || kind == MediaKind.show)
MetadataEditField(id: 'tagline', label: t.metadataEdit.tagline, type: MetadataEditFieldType.text),
MetadataEditField(id: 'summary', label: t.metadataEdit.summary, type: MetadataEditFieldType.multilineText),
];
}
List<MetadataEditField> _tagFields(MediaKind kind) { List<MetadataEditField> _tagFields(MediaKind kind) {
MetadataEditField tag(String id, String label) => MetadataEditField tag(String id, String label) =>
MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList);
@@ -267,94 +238,14 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter {
}; };
} }
List<MetadataEditField> _artworkFields(MediaKind kind) { List<MetadataEditField> _artworkFields(MediaKind kind) => metadataArtworkFields(
final fields = <MetadataEditField>[ kind,
// Episode "posters" are 16:9 thumbnails, not 2:3 poster art. posterKey: 'posters',
kind == MediaKind.episode backdropKey: 'arts',
? _artworkField( logoKey: 'clearLogos',
'posters', squareKey: 'squareArts',
t.metadataEdit.poster, logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection},
t.metadataEdit.selectPoster,
80,
45,
2,
16 / 9,
imageType: ImageType.thumb,
)
: _artworkField('posters', t.metadataEdit.poster, t.metadataEdit.selectPoster, 40, 60, 3, 2 / 3),
];
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.episode) {
fields.add(
_artworkField(
'arts',
t.metadataEdit.background,
t.metadataEdit.selectBackground,
80,
45,
2,
16 / 9,
imageType: ImageType.art,
),
); );
}
if (kind == MediaKind.movie || kind == MediaKind.show || kind == MediaKind.collection) {
fields.add(
_artworkField(
'clearLogos',
t.metadataEdit.logo,
t.metadataEdit.selectLogo,
80,
32,
2,
2.5,
fit: MetadataArtworkFit.contain,
imageType: ImageType.logo,
),
);
fields.add(
_artworkField(
'squareArts',
t.metadataEdit.squareArt,
t.metadataEdit.selectSquareArt,
50,
50,
3,
1,
imageType: ImageType.avatar,
),
);
}
return fields;
}
MetadataEditField _artworkField(
String key,
String label,
String title,
double width,
double height,
int columns,
double aspectRatio, {
MetadataArtworkFit fit = MetadataArtworkFit.cover,
ImageType imageType = ImageType.poster,
}) {
return MetadataEditField(
id: 'artwork:$key',
label: label,
type: MetadataEditFieldType.artwork,
saveMode: MetadataEditSaveMode.immediate,
artwork: MetadataArtworkConfig(
key: key,
selectTitle: title,
previewWidth: width,
previewHeight: height,
gridColumns: columns,
gridAspectRatio: aspectRatio,
fit: fit,
imageType: imageType,
),
);
}
List<MetadataEditField> _advancedFields(MediaKind kind) { List<MetadataEditField> _advancedFields(MediaKind kind) {
final fields = <MetadataEditField>[]; final fields = <MetadataEditField>[];
+19
View File
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/deletion_notifier.dart'; import '../utils/deletion_notifier.dart';
import 'event_aware.dart'; import 'event_aware.dart';
import 'watch_state_aware.dart';
/// Mixin for screens that need to react to deletion events. /// Mixin for screens that need to react to deletion events.
/// ///
@@ -74,3 +75,21 @@ mixin DeletionAware<T extends StatefulWidget> on State<T> {
super.dispose(); super.dispose();
} }
} }
/// Points [DeletionAware]'s filters at the [WatchStateAware] ones.
///
/// The usual case: a screen shows the same rows for both event families, so a
/// deleted show and a watched show affect exactly the same items. Mix this in
/// after both aware mixins instead of re-typing the three getters. A screen
/// that genuinely needs a different scope overrides the getter it cares about
/// (or skips this mixin entirely).
mixin DeletionMirrorsWatchState<T extends StatefulWidget> on WatchStateAware<T>, DeletionAware<T> {
@override
String? get deletionServerId => watchStateServerId;
@override
Set<String>? get deletionGlobalKeys => watchedGlobalKeys;
@override
Set<String>? get deletionIds => watchedIds;
}
-37
View File
@@ -104,43 +104,6 @@ mixin PaginatedItemLoader<T, W extends StatefulWidget> on State<W> {
return (page: result, applied: true); return (page: result, applied: true);
} }
/// Shared initial-load transaction for paginated consumers.
///
/// Owns reset, stale-result rejection, mounted checks, and error-state
/// application. Callers supply only their view fields, logging, and
/// post-success behavior.
Future<bool> loadInitialPaginatedItems({
required int pageSize,
required VoidCallback resetViewState,
required void Function(List<T> items) applyLoadedItems,
required void Function(Object error, StackTrace stackTrace) applyError,
void Function(int loadedCount, int totalCount)? onLoaded,
void Function(Object error, StackTrace stackTrace)? onError,
}) async {
setState(() {
resetViewState();
resetPaginationState();
});
try {
final initialPage = await loadInitialPageWithStatus(pageSize);
if (!initialPage.applied || !mounted) return false;
setState(() {
applyLoadedItems(loadedItems.values.toList());
});
onLoaded?.call(loadedItems.length, totalSize);
return true;
} catch (error, stackTrace) {
onError?.call(error, stackTrace);
if (!mounted) return false;
setState(() {
applyError(error, stackTrace);
});
return false;
}
}
/// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount) /// Fetch any unloaded items inside [firstIndex, firstIndex + visibleCount)
/// with [buffer] extra indices on each side. Serialized — only one /// with [buffer] extra indices on each side. Serialized — only one
/// range-fetch runs at a time — and re-checks after each success so a /// range-fetch runs at a time — and re-checks after each success so a
+74
View File
@@ -0,0 +1,74 @@
import 'package:flutter/widgets.dart';
import '../media/media_item.dart';
import 'item_updatable.dart';
import 'paginated_item_loader.dart';
/// Standard view-state wiring for screens whose body is a single paginated
/// list.
///
/// [PaginatedItemLoader] owns the sparse `loadedItems` map; the hosts
/// (`BaseMediaListDetailScreen`, `BaseLibraryTabState`) additionally expose
/// `items` / `isLoading` / `errorMessage` to drive the loading, empty and
/// error chrome. This mixin owns the transitions between the two, so a
/// screen's `loadItems` supplies only the page size, the error text, and an
/// optional post-load hook.
mixin StandardPaginatedView<T, W extends StatefulWidget> on PaginatedItemLoader<T, W> {
set items(List<T> value);
set isLoading(bool value);
set errorMessage(String? value);
/// Initial-load transaction: clears the view state, fetches the first page,
/// then publishes either the loaded items or [errorMessageFor]'s text.
///
/// Stale results — a newer load started, or the screen was disposed — are
/// dropped without touching state. [errorMessageFor] runs even when
/// unmounted, so screens can log from it; [onLoaded] runs only after a
/// successful publish.
Future<void> loadStandardPaginatedItems({
required int pageSize,
required String Function(Object error, StackTrace stackTrace) errorMessageFor,
void Function(int loadedCount, int totalCount)? onLoaded,
}) async {
setState(() {
isLoading = true;
errorMessage = null;
items = [];
resetPaginationState();
});
try {
final initialPage = await loadInitialPageWithStatus(pageSize);
if (!initialPage.applied || !mounted) return;
setState(() {
items = loadedItems.values.toList();
isLoading = false;
});
onLoaded?.call(loadedItems.length, totalSize);
} catch (error, stackTrace) {
final message = errorMessageFor(error, stackTrace);
if (!mounted) return;
setState(() {
errorMessage = message;
isLoading = false;
});
}
}
}
/// [ItemUpdatable.updateItemInLists] for screens whose visible list is the
/// sparse `loadedItems` map rather than a flat `items` list — searching the
/// map is what keeps an item refreshed at a scrolled-in position, past the
/// first page.
mixin PaginatedItemUpdatable<W extends StatefulWidget> on PaginatedItemLoader<MediaItem, W>, ItemUpdatable<W> {
@override
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) {
for (final entry in loadedItems.entries) {
if (entry.value.globalKey == sourceGlobalKey) {
loadedItems[entry.key] = updatedItem;
return;
}
}
}
}
+1 -4
View File
@@ -2,7 +2,6 @@ import 'package:json_annotation/json_annotation.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../utils/json_utils.dart'; import '../utils/json_utils.dart';
import 'mixins/multi_server_fields.dart';
part 'livetv_channel.g.dart'; part 'livetv_channel.g.dart';
@@ -49,7 +48,7 @@ List<LiveTvChannel> filterLiveTvChannelsForFavorites({
} }
@JsonSerializable(createToJson: false) @JsonSerializable(createToJson: false)
class LiveTvChannel with MultiServerFields { class LiveTvChannel {
@JsonKey(readValue: _readChannelKey) @JsonKey(readValue: _readChannelKey)
final String key; final String key;
@JsonKey(readValue: _readChannelIdentifier) @JsonKey(readValue: _readChannelIdentifier)
@@ -68,10 +67,8 @@ class LiveTvChannel with MultiServerFields {
@JsonKey(fromJson: flexibleBool) @JsonKey(fromJson: flexibleBool)
final bool? drm; final bool? drm;
@override
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final String? serverId; final String? serverId;
@override
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
final String? serverName; final String? serverName;
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
-71
View File
@@ -1,71 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
import '../utils/json_utils.dart';
part 'media_provider_info.g.dart';
List<MediaProviderFeature> _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson);
List<Map<String, dynamic>> _parseRawMaps(Object? raw) => flexibleMapList(raw);
@JsonSerializable(createToJson: false)
class MediaProviderInfo {
@JsonKey(fromJson: flexibleInt)
final int? id;
@JsonKey(fromJson: flexibleInt)
final int? parentID;
@JsonKey(defaultValue: '')
final String identifier;
final String? providerIdentifier;
final String? title;
final String? types;
final String? protocols;
final String? epgSource;
final String? friendlyName;
@JsonKey(name: 'Feature', fromJson: _parseFeatures)
final List<MediaProviderFeature> features;
const MediaProviderInfo({
this.id,
this.parentID,
required this.identifier,
this.providerIdentifier,
this.title,
this.types,
this.protocols,
this.epgSource,
this.friendlyName,
this.features = const [],
});
factory MediaProviderInfo.fromJson(Map<String, dynamic> json) => _$MediaProviderInfoFromJson(json);
}
@JsonSerializable(createToJson: false)
class MediaProviderFeature {
final String? key;
@JsonKey(defaultValue: '')
final String type;
final String? flavor;
final String? scrobbleKey;
final String? unscrobbleKey;
@JsonKey(name: 'Directory', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> directories;
@JsonKey(name: 'Action', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> actions;
@JsonKey(name: 'Pivot', fromJson: _parseRawMaps)
final List<Map<String, dynamic>> pivots;
const MediaProviderFeature({
this.key,
required this.type,
this.flavor,
this.scrobbleKey,
this.unscrobbleKey,
this.directories = const [],
this.actions = const [],
this.pivots = const [],
});
factory MediaProviderFeature.fromJson(Map<String, dynamic> json) => _$MediaProviderFeatureFromJson(json);
}
-38
View File
@@ -1,38 +0,0 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'media_provider_info.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
MediaProviderInfo _$MediaProviderInfoFromJson(Map<String, dynamic> json) =>
MediaProviderInfo(
id: flexibleInt(json['id']),
parentID: flexibleInt(json['parentID']),
identifier: json['identifier'] as String? ?? '',
providerIdentifier: json['providerIdentifier'] as String?,
title: json['title'] as String?,
types: json['types'] as String?,
protocols: json['protocols'] as String?,
epgSource: json['epgSource'] as String?,
friendlyName: json['friendlyName'] as String?,
features: json['Feature'] == null
? const []
: _parseFeatures(json['Feature']),
);
MediaProviderFeature _$MediaProviderFeatureFromJson(
Map<String, dynamic> json,
) => MediaProviderFeature(
key: json['key'] as String?,
type: json['type'] as String? ?? '',
flavor: json['flavor'] as String?,
scrobbleKey: json['scrobbleKey'] as String?,
unscrobbleKey: json['unscrobbleKey'] as String?,
directories: json['Directory'] == null
? const []
: _parseRawMaps(json['Directory']),
actions: json['Action'] == null ? const [] : _parseRawMaps(json['Action']),
pivots: json['Pivot'] == null ? const [] : _parseRawMaps(json['Pivot']),
);
@@ -1,15 +0,0 @@
import 'package:json_annotation/json_annotation.dart';
/// Mixin that provides multi-server support fields for models.
///
/// This mixin adds serverId and serverName fields that are excluded from
/// JSON serialization but can be used to track which server an item belongs to.
mixin MultiServerFields {
/// Server machine identifier (not from API)
@JsonKey(includeFromJson: false, includeToJson: false)
String? get serverId;
/// Server display name (not from API)
@JsonKey(includeFromJson: false, includeToJson: false)
String? get serverName;
}
+35 -82
View File
@@ -9,22 +9,35 @@ enum ShaderPresetType { none, nvscaler, artcnn, anime4k, custom }
/// ArtCNN real-time model sizes. /// ArtCNN real-time model sizes.
enum ArtCNNModel { enum ArtCNNModel {
/// Lightweight real-time model /// Lightweight real-time model
c4f16, c4f16('C4F16'),
/// Higher-quality real-time model /// Higher-quality real-time model
c4f32, c4f32('C4F32');
const ArtCNNModel(this.label);
/// Display label for the model.
final String label;
} }
/// ArtCNN luma doubler variants. /// ArtCNN luma doubler variants.
enum ArtCNNVariant { enum ArtCNNVariant {
/// Neutral luma doubler /// Neutral luma doubler
neutral, neutral('Neutral', 'neutral'),
/// Denoise and soften /// Denoise and soften
denoise, denoise('Denoise', 'dn'),
/// Denoise and sharpen /// Denoise and sharpen
denoiseSharpen, denoiseSharpen('Denoise + Sharpen', 'ds');
const ArtCNNVariant(this.label, this.slug);
/// Display label for the variant.
final String label;
/// Stable slug used in built-in preset ids and shader asset keys.
final String slug;
} }
/// Quality tiers for Anime4K presets /// Quality tiers for Anime4K presets
@@ -39,22 +52,27 @@ enum Anime4KQuality {
/// Anime4K modes that define shader combinations /// Anime4K modes that define shader combinations
enum Anime4KMode { enum Anime4KMode {
/// Mode A: Clamp + Restore /// Mode A: Clamp + Restore
modeA, modeA('A'),
/// Mode B: Clamp + Restore + Upscale + Downscale /// Mode B: Clamp + Restore + Upscale + Downscale
modeB, modeB('B'),
/// Mode C: Clamp + Upscale + Downscale /// Mode C: Clamp + Upscale + Downscale
modeC, modeC('C'),
/// Mode A+A: Clamp + Restore + Restore /// Mode A+A: Clamp + Restore + Restore
modeAA, modeAA('A+A'),
/// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale /// Mode B+B: Clamp + Restore + Restore + Upscale + Downscale
modeBB, modeBB('B+B'),
/// Mode C+A: Clamp + Upscale + Restore + Downscale /// Mode C+A: Clamp + Upscale + Restore + Downscale
modeCA, modeCA('C+A');
const Anime4KMode(this.label);
/// Display label for the mode.
final String label;
} }
@freezed @freezed
@@ -120,93 +138,28 @@ class ShaderPreset {
); );
/// Create an ArtCNN preset with the specified model and variant /// Create an ArtCNN preset with the specified model and variant
static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) { static ShaderPreset artcnnPreset(ArtCNNModel model, ArtCNNVariant variant) => ShaderPreset(
final modelName = _getArtCNNModelName(model); id: 'artcnn_${model.name}_${variant.slug}',
final variantName = _getArtCNNVariantName(variant); name: variant == ArtCNNVariant.neutral ? 'ArtCNN ${model.label}' : 'ArtCNN ${model.label} ${variant.label}',
final variantId = _getArtCNNVariantId(variant);
return ShaderPreset(
id: 'artcnn_${model.name}_$variantId',
name: variant == ArtCNNVariant.neutral ? 'ArtCNN $modelName' : 'ArtCNN $modelName $variantName',
type: ShaderPresetType.artcnn, type: ShaderPresetType.artcnn,
artcnnConfig: ArtCNNConfig(model: model, variant: variant), artcnnConfig: ArtCNNConfig(model: model, variant: variant),
); );
}
/// Create an Anime4K preset with the specified quality and mode /// Create an Anime4K preset with the specified quality and mode
static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) { static ShaderPreset anime4kPreset(Anime4KQuality quality, Anime4KMode mode) {
final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ'; final qualityName = quality == Anime4KQuality.fast ? 'Fast' : 'HQ';
final modeName = _getModeName(mode);
return ShaderPreset( return ShaderPreset(
id: 'anime4k_${quality.name}_${mode.name}', id: 'anime4k_${quality.name}_${mode.name}',
name: 'Anime4K $qualityName $modeName', name: 'Anime4K $qualityName ${mode.label}',
type: ShaderPresetType.anime4k, type: ShaderPresetType.anime4k,
anime4kConfig: Anime4KConfig(quality: quality, mode: mode), anime4kConfig: Anime4KConfig(quality: quality, mode: mode),
); );
} }
static String _getModeName(Anime4KMode mode) { String get modeDisplayName => anime4kConfig?.mode.label ?? '';
switch (mode) {
case Anime4KMode.modeA:
return 'A';
case Anime4KMode.modeB:
return 'B';
case Anime4KMode.modeC:
return 'C';
case Anime4KMode.modeAA:
return 'A+A';
case Anime4KMode.modeBB:
return 'B+B';
case Anime4KMode.modeCA:
return 'C+A';
}
}
static String _getArtCNNModelName(ArtCNNModel model) { String get artcnnModelDisplayName => artcnnConfig?.model.label ?? '';
switch (model) {
case ArtCNNModel.c4f16:
return 'C4F16';
case ArtCNNModel.c4f32:
return 'C4F32';
}
}
static String _getArtCNNVariantName(ArtCNNVariant variant) {
switch (variant) {
case ArtCNNVariant.neutral:
return 'Neutral';
case ArtCNNVariant.denoise:
return 'Denoise';
case ArtCNNVariant.denoiseSharpen:
return 'Denoise + Sharpen';
}
}
static String _getArtCNNVariantId(ArtCNNVariant variant) {
switch (variant) {
case ArtCNNVariant.neutral:
return 'neutral';
case ArtCNNVariant.denoise:
return 'dn';
case ArtCNNVariant.denoiseSharpen:
return 'ds';
}
}
String get modeDisplayName {
if (anime4kConfig != null) {
return _getModeName(anime4kConfig!.mode);
}
return '';
}
String get artcnnModelDisplayName {
if (artcnnConfig != null) {
return _getArtCNNModelName(artcnnConfig!.model);
}
return '';
}
static final List<ShaderPreset> _builtInPresets = List.unmodifiable([ static final List<ShaderPreset> _builtInPresets = List.unmodifiable([
none, none,
+3 -26
View File
@@ -41,11 +41,12 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
}, },
}; };
/// Build a `POST /sync/history` body that adds this item to history. /// Build a `POST /sync/history[/remove]` body for this item. Both endpoints
/// take the same shape; only the removal path ignores [watchedAt].
/// ///
/// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play /// Optional [watchedAt] (ISO-8601 UTC) lets the server attribute the play
/// to a specific point in time; defaults to "now" on Trakt's side. /// to a specific point in time; defaults to "now" on Trakt's side.
Map<String, dynamic> toHistoryAddBody({String? watchedAt}) => switch (this) { Map<String, dynamic> toHistoryBody({String? watchedAt}) => switch (this) {
TraktScrobbleMovieRequest(:final ids) => { TraktScrobbleMovieRequest(:final ids) => {
'movies': [ 'movies': [
{'watched_at': ?watchedAt, 'ids': ids.toJson()}, {'watched_at': ?watchedAt, 'ids': ids.toJson()},
@@ -67,28 +68,4 @@ sealed class TraktScrobbleRequest with _$TraktScrobbleRequest {
], ],
}, },
}; };
/// Build a `POST /sync/history/remove` body that removes this item from history.
Map<String, dynamic> toHistoryRemoveBody() => switch (this) {
TraktScrobbleMovieRequest(:final ids) => {
'movies': [
{'ids': ids.toJson()},
],
},
TraktScrobbleEpisodeRequest(:final showIds, :final season, :final number) => {
'shows': [
{
'ids': showIds.toJson(),
'seasons': [
{
'number': season,
'episodes': [
{'number': number},
],
},
],
},
],
},
};
} }
+1 -11
View File
@@ -26,7 +26,7 @@ class PlexHomeService {
this._storage, this._storage,
Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher, Future<List<PlexHomeUser>> Function(String accountToken)? plexHomeUserFetcher,
this._refreshInterval = const Duration(hours: 1), this._refreshInterval = const Duration(hours: 1),
}) : _fetchHomeUsers = plexHomeUserFetcher ?? _defaultHomeUserFetcher; }) : _fetchHomeUsers = plexHomeUserFetcher ?? fetchPlexHomeUsers;
final ConnectionRegistry _connections; final ConnectionRegistry _connections;
final ProfileConnectionRegistry _profileConnections; final ProfileConnectionRegistry _profileConnections;
@@ -498,13 +498,3 @@ class PlexHomeService {
_started = false; _started = false;
} }
} }
Future<List<PlexHomeUser>> _defaultHomeUserFetcher(String accountToken) async {
final auth = await PlexAuthService.create();
try {
final home = await auth.getHomeUsers(accountToken);
return home.users;
} finally {
auth.dispose();
}
}
+3 -4
View File
@@ -234,6 +234,8 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
final connections = context.read<ConnectionRegistry>(); final connections = context.read<ConnectionRegistry>();
final pcRegistry = context.read<ProfileConnectionRegistry>(); final pcRegistry = context.read<ProfileConnectionRegistry>();
final binder = context.read<ActiveProfileBinder>(); final binder = context.read<ActiveProfileBinder>();
// Built before the await: capturing the prompt needs a live context.
final promptForPin = dialogPinPrompt(context, profile.displayName);
final all = await connections.list(); final all = await connections.list();
PlexAccountConnection? account; PlexAccountConnection? account;
for (final c in all) { for (final c in all) {
@@ -248,10 +250,7 @@ Future<PlexHomeSwitchStatus> _preVerifyPlexHomePin(BuildContext context, Profile
account: account, account: account,
homeUserUuid: homeUuid, homeUserUuid: homeUuid,
requiresPin: true, requiresPin: true,
promptForPin: ({String? errorMessage}) async { promptForPin: promptForPin,
if (!context.mounted) return null;
return showPinEntryDialog(context, profile.displayName, errorMessage: errorMessage);
},
persistTo: pcRegistry, persistTo: pcRegistry,
persistProfileId: profile.id, persistProfileId: profile.id,
logLabel: profile.displayName, logLabel: profile.displayName,
+100 -186
View File
@@ -9,65 +9,6 @@ import 'profile_connection_registry.dart';
import 'profile_merge.dart'; import 'profile_merge.dart';
import 'profile_registry.dart'; import 'profile_registry.dart';
Future<void> removeProfileConnectionAndCleanup({
required String profileId,
required Connection connection,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
final removedServerIds = _serverIdsForConnection(connection);
await profileConnections.remove(profileId, connection.id);
await _clearProfileServerPrefsNoLongerReferenced(
profileId: profileId,
removedServerIds: removedServerIds,
profileConnections: profileConnections,
connections: connections,
storage: storage,
clearEverywhereWhenUnreferenced: connection is JellyfinConnection,
);
if (connection is JellyfinConnection) {
await _removeUnreferencedJellyfinConnection(
connection,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
}
}
Future<void> removeAllProfileConnectionsAndCleanup({
required String profileId,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
final rows = await profileConnections.listForProfile(profileId);
if (rows.isEmpty) return;
final all = await connections.list();
final byId = {for (final connection in all) connection.id: connection};
for (final row in rows) {
final connection = byId[row.connectionId];
if (connection == null) {
await profileConnections.remove(profileId, row.connectionId);
continue;
}
await removeProfileConnectionAndCleanup(
profileId: profileId,
connection: connection,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
}
}
/// Profile ids affected by a Plex account removal. Planning is read-only so /// Profile ids affected by a Plex account removal. Planning is read-only so
/// callers can finish failure-prone cleanup before committing join/account /// callers can finish failure-prone cleanup before committing join/account
/// deletion. /// deletion.
@@ -96,27 +37,73 @@ Future<PlexAccountRemoval> planPlexAccountConnectionRemoval({
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
} }
/// Sign out of a Plex account: remove the account [Connection], every join /// Where the session should land after a profile or connection removal.
/// row referencing it, and everything owned by its virtual Plex Home enum PostRemovalRoute { signedOut, staySignedIn }
/// profiles — including borrowed Jellyfin connections left unreferenced,
/// which previously survived as orphans and wedged the session (#1423). /// Removal of profile↔connection join rows and everything they leave
/// /// unreferenced, bound to one set of registries. Every flow resolves the same
/// Pass a read-only [plannedRemoval] from /// instances from the provider tree, so callers construct this once and call
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup /// through it.
/// must finish before this destructive commit. Omitting it preserves the class ProfileConnectionCleanup {
/// atomic add/cancel-account cleanup path. ProfileConnectionCleanup({
/// required this.profileConnections,
/// All cleanup is explicit and completes before this returns; correctness required this.connections,
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which required this.storage,
/// runs later and no-ops. this.serverManager,
Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({ });
required PlexAccountConnection account,
required ProfileConnectionRegistry profileConnections, final ProfileConnectionRegistry profileConnections;
required ConnectionRegistry connections, final ConnectionRegistry connections;
required StorageService storage, final StorageService storage;
MultiServerManager? serverManager, final MultiServerManager? serverManager;
Future<void> removeProfileConnection({required String profileId, required Connection connection}) async {
final removedServerIds = _serverIdsForConnection(connection);
await profileConnections.remove(profileId, connection.id);
await _clearProfileServerPrefsNoLongerReferenced(
profileId: profileId,
removedServerIds: removedServerIds,
clearEverywhereWhenUnreferenced: connection is JellyfinConnection,
);
if (connection is JellyfinConnection) {
await _removeUnreferencedJellyfinConnection(connection);
}
}
Future<void> removeAllProfileConnections(String profileId) async {
final rows = await profileConnections.listForProfile(profileId);
if (rows.isEmpty) return;
final all = await connections.list();
final byId = {for (final connection in all) connection.id: connection};
for (final row in rows) {
final connection = byId[row.connectionId];
if (connection == null) {
await profileConnections.remove(profileId, row.connectionId);
continue;
}
await removeProfileConnection(profileId: profileId, connection: connection);
}
}
/// Sign out of a Plex account: remove the account [Connection], every join
/// row referencing it, and everything owned by its virtual Plex Home
/// profiles — including borrowed Jellyfin connections left unreferenced,
/// which previously survived as orphans and wedged the session (#1423).
///
/// Pass a read-only [plannedRemoval] from
/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup
/// must finish before this destructive commit. Omitting it preserves the
/// atomic add/cancel-account cleanup path.
///
/// All cleanup is explicit and completes before this returns; correctness
/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which
/// runs later and no-ops.
Future<PlexAccountRemoval> removePlexAccountConnection(
PlexAccountConnection account, {
PlexAccountRemoval? plannedRemoval, PlexAccountRemoval? plannedRemoval,
}) async { }) async {
final removal = final removal =
plannedRemoval ?? plannedRemoval ??
await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections);
@@ -126,14 +113,7 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
// Remove direct join rows first so per-profile pref cleanup observes each // Remove direct join rows first so per-profile pref cleanup observes each
// row going away; the FK cascade from the connection delete is then a no-op. // row going away; the FK cascade from the connection delete is then a no-op.
for (final row in rows.where((r) => r.connectionId == account.id)) { for (final row in rows.where((r) => r.connectionId == account.id)) {
await removeProfileConnectionAndCleanup( await removeProfileConnection(profileId: row.profileId, connection: account);
profileId: row.profileId,
connection: account,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
} }
await connections.remove(account.id); await connections.remove(account.id);
await storage.clearPlexHomeUsersCache(account.id); await storage.clearPlexHomeUsersCache(account.id);
@@ -141,42 +121,24 @@ Future<PlexAccountRemoval> removePlexAccountConnectionAndCleanup({
// The account's virtual profiles die with the connection; their borrowed // The account's virtual profiles die with the connection; their borrowed
// connections and per-profile prefs must go too. // connections and per-profile prefs must go too.
for (final profileId in removedVirtualProfileIds) { for (final profileId in removedVirtualProfileIds) {
await removeAllProfileConnectionsAndCleanup( await removeAllProfileConnections(profileId);
profileId: profileId,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
await storage.clearProfileLastUsed(profileId); await storage.clearProfileLastUsed(profileId);
await storage.clearUserScopedPreferencesForProfile(profileId); await storage.clearUserScopedPreferencesForProfile(profileId);
} }
return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds);
} }
/// Where the session should land after a profile or connection removal. /// In-session mirror of the boot guard (`main.dart`: "stored connections
enum PostRemovalRoute { signedOut, staySignedIn } /// exist but no profiles resolved — returning to auth"): prune orphaned
/// Jellyfin connections, then decide whether any selectable profile remains.
/// In-session mirror of the boot guard (`main.dart`: "stored connections /// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
/// exist but no profiles resolved — returning to auth"): prune orphaned /// accounts are harmless because the connection map is re-read here.
/// Jellyfin connections, then decide whether any selectable profile remains. Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed
/// accounts are harmless because the connection map is re-read here.
Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalState({
required ProfileRegistry profileRegistry, required ProfileRegistry profileRegistry,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required Map<String, List<PlexHomeUser>> plexHomeUsers, required Map<String, List<PlexHomeUser>> plexHomeUsers,
required StorageService storage, }) async {
MultiServerManager? serverManager, await pruneUnreferencedJellyfinConnections();
}) async {
await pruneUnreferencedJellyfinConnections(
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
final conns = await connections.list(); final conns = await connections.list();
if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]); if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
@@ -188,89 +150,48 @@ Future<({PostRemovalRoute route, List<Profile> profiles})> resolvePostRemovalSta
); );
if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]); if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const <Profile>[]);
return (route: PostRemovalRoute.staySignedIn, profiles: merged); return (route: PostRemovalRoute.staySignedIn, profiles: merged);
} }
Future<int> pruneUnreferencedJellyfinConnections({ Future<int> pruneUnreferencedJellyfinConnections() async {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
final all = await connections.list(); final all = await connections.list();
final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet();
var removed = 0; var removed = 0;
for (final connection in all.whereType<JellyfinConnection>()) { for (final connection in all.whereType<JellyfinConnection>()) {
if (referencedConnectionIds.contains(connection.id)) continue; if (referencedConnectionIds.contains(connection.id)) continue;
await _removeJellyfinConnection( await _removeJellyfinConnection(connection);
connection,
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
removed++; removed++;
} }
return removed; return removed;
} }
Future<void> _removeUnreferencedJellyfinConnection( Future<void> _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async {
JellyfinConnection connection, {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return;
await _removeJellyfinConnection( await _removeJellyfinConnection(connection);
connection, }
profileConnections: profileConnections,
connections: connections,
storage: storage,
serverManager: serverManager,
);
}
Future<void> _removeJellyfinConnection( Future<void> _removeJellyfinConnection(JellyfinConnection connection) async {
JellyfinConnection connection, {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
MultiServerManager? serverManager,
}) async {
await connections.remove(connection.id); await connections.remove(connection.id);
serverManager?.removeJellyfinConnection(connection); serverManager?.removeJellyfinConnection(connection);
final serverId = ServerId.tryParse(connection.serverMachineId); final serverId = ServerId.tryParse(connection.serverMachineId);
if (serverId != null && if (serverId != null && !await _isServerReferenced(serverId)) {
!await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) {
await storage.clearLibraryPreferencesForServerEverywhere(serverId); await storage.clearLibraryPreferencesForServerEverywhere(serverId);
} }
} }
Future<void> _clearProfileServerPrefsNoLongerReferenced({ Future<void> _clearProfileServerPrefsNoLongerReferenced({
required String profileId, required String profileId,
required Set<ServerId> removedServerIds, required Set<ServerId> removedServerIds,
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
required StorageService storage,
required bool clearEverywhereWhenUnreferenced, required bool clearEverywhereWhenUnreferenced,
}) async { }) async {
if (removedServerIds.isEmpty) return; if (removedServerIds.isEmpty) return;
final remainingProfileServerIds = await _serverIdsForProfile( final remainingProfileServerIds = await _serverIdsForProfile(profileId);
profileId,
profileConnections: profileConnections,
connections: connections,
);
final activeProfileId = storage.getActiveProfileId(); final activeProfileId = storage.getActiveProfileId();
for (final serverId in removedServerIds) { for (final serverId in removedServerIds) {
if (remainingProfileServerIds.contains(serverId)) continue; if (remainingProfileServerIds.contains(serverId)) continue;
final serverStillReferenced = await _isServerReferenced( final serverStillReferenced = await _isServerReferenced(serverId);
serverId,
profileConnections: profileConnections,
connections: connections,
);
if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { if (serverStillReferenced || !clearEverywhereWhenUnreferenced) {
await storage.clearLibraryPreferencesForServer( await storage.clearLibraryPreferencesForServer(
serverId, serverId,
@@ -281,17 +202,13 @@ Future<void> _clearProfileServerPrefsNoLongerReferenced({
await storage.clearLibraryPreferencesForServerEverywhere(serverId); await storage.clearLibraryPreferencesForServerEverywhere(serverId);
} }
} }
} }
/// Server ids reachable through this profile's join rows. Narrower than /// Server ids reachable through this profile's join rows. Narrower than
/// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home /// `ActiveProfileBinder._expectedServerIdsForProfile`: an implicit Plex Home
/// parent is not counted here, so folding the two together would change which /// parent is not counted here, so folding the two together would change which
/// per-profile prefs survive an unlink. /// per-profile prefs survive an unlink.
Future<Set<ServerId>> _serverIdsForProfile( Future<Set<ServerId>> _serverIdsForProfile(String profileId) async {
String profileId, {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
}) async {
final rows = await profileConnections.listForProfile(profileId); final rows = await profileConnections.listForProfile(profileId);
if (rows.isEmpty) return const {}; if (rows.isEmpty) return const {};
@@ -301,13 +218,9 @@ Future<Set<ServerId>> _serverIdsForProfile(
for (final row in rows) for (final row in rows)
if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection),
}; };
} }
Future<bool> _isServerReferenced( Future<bool> _isServerReferenced(ServerId serverId) async {
ServerId serverId, {
required ProfileConnectionRegistry profileConnections,
required ConnectionRegistry connections,
}) async {
final rows = await profileConnections.listAll(); final rows = await profileConnections.listAll();
if (rows.isEmpty) return false; if (rows.isEmpty) return false;
@@ -318,6 +231,7 @@ Future<bool> _isServerReferenced(
if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true;
} }
return false; return false;
}
} }
// [ServerId]-typed for the preference APIs, which drops ids that fail to // [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;
}
+98 -100
View File
@@ -684,18 +684,13 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}'); appLogger.d('CompanionRemote: Connecting to ${host.name} at ${host.addresses}');
final candidate = _peerServiceFactory(); String? winner;
_pendingRemotePeer = candidate; final connected = await _runRemoteConnect(
_session = RemoteSession( generation: generation,
role: RemoteSessionRole.remote, seedConnectingSession: true,
status: RemoteSessionStatus.connecting, rethrowOnFailure: true,
createdAt: DateTime.now(), join: (peer) async {
); winner = await peer.joinSessionRacingWithContexts(
_setupPeerServiceListeners(candidate, generation);
safeNotifyListeners();
try {
final winner = await candidate.joinSessionRacingWithContexts(
_deviceName, _deviceName,
_platform, _platform,
host.addresses, host.addresses,
@@ -703,38 +698,18 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
authContextId: authContext.id, authContextId: authContext.id,
expectedHostClientId: host.clientId, expectedHostClientId: host.clientId,
); );
if (!_ownsPeer(candidate, generation)) { },
await _disposePeerOnce(candidate); onConnected: (peer) {
return; _lastHostAddresses = [winner!];
} _lastAuthContextId = peer.selectedAuthContextId ?? authContext.id;
_lastHostClientId = peer.selectedHostClientId ?? host.clientId;
_pendingRemotePeer = null;
_peerService = candidate;
_lastHostAddresses = [winner];
_lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id;
_lastHostClientId = candidate.selectedHostClientId ?? host.clientId;
_session = _session?.copyWith(status: RemoteSessionStatus.connected); _session = _session?.copyWith(status: RemoteSessionStatus.connected);
safeNotifyListeners(); },
appLogger.d('CompanionRemote: Connected to ${host.name} via $winner'); failureLog: 'CompanionRemote: Failed to connect to host',
} catch (error, stackTrace) { onFailure: _failRemoteConnectSession,
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return;
}
_pendingRemotePeer = null;
_cleanupSubscriptions();
await _disposePeerOnce(candidate);
appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace);
_session = _session?.copyWith(
status: RemoteSessionStatus.error,
errorMessage: _localizedRemoteError(
error,
(details) => t.companionRemote.pairing.failedToConnect(error: details),
),
); );
safeNotifyListeners(); if (connected) {
rethrow; appLogger.d('CompanionRemote: Connected to ${host.name} via $winner');
} }
} }
@@ -754,39 +729,22 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
appLogger.d('CompanionRemote: Connecting to manual host $hostAddress'); appLogger.d('CompanionRemote: Connecting to manual host $hostAddress');
final candidate = _peerServiceFactory(); await _runRemoteConnect(
_pendingRemotePeer = candidate; generation: generation,
_session = RemoteSession( seedConnectingSession: true,
role: RemoteSessionRole.remote, rethrowOnFailure: true,
status: RemoteSessionStatus.connecting, join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts),
createdAt: DateTime.now(), onConnected: (peer) {
); _lastAuthContextId = peer.selectedAuthContextId;
_setupPeerServiceListeners(candidate, generation); _lastHostClientId = peer.selectedHostClientId ?? '';
safeNotifyListeners();
try {
await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts);
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return;
}
_pendingRemotePeer = null;
_peerService = candidate;
_lastAuthContextId = candidate.selectedAuthContextId;
_lastHostClientId = candidate.selectedHostClientId ?? '';
_session = _session?.copyWith(status: RemoteSessionStatus.connected); _session = _session?.copyWith(status: RemoteSessionStatus.connected);
safeNotifyListeners(); },
} catch (error, stackTrace) { failureLog: 'CompanionRemote: Failed to connect to manual host',
if (!_ownsPeer(candidate, generation)) { onFailure: _failRemoteConnectSession,
await _disposePeerOnce(candidate); );
return;
} }
_pendingRemotePeer = null; void _failRemoteConnectSession(Object error) {
_cleanupSubscriptions();
await _disposePeerOnce(candidate);
appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace);
_session = _session?.copyWith( _session = _session?.copyWith(
status: RemoteSessionStatus.error, status: RemoteSessionStatus.error,
errorMessage: _localizedRemoteError( errorMessage: _localizedRemoteError(
@@ -795,7 +753,60 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
), ),
); );
safeNotifyListeners(); safeNotifyListeners();
rethrow; }
/// Runs the candidate-peer connect lifecycle shared by the discovered/manual
/// connect paths and by reconnect attempts: create a candidate, wire its
/// listeners, then promote it to [_peerService] or dispose it. The generation
/// guards live here so a candidate that lost ownership while joining is
/// disposed rather than promoted, in exactly one place. Returns true only
/// when the candidate was promoted.
Future<bool> _runRemoteConnect({
required int generation,
required Future<void> Function(CompanionRemotePeerService peer) join,
required void Function(CompanionRemotePeerService peer) onConnected,
required String failureLog,
required void Function(Object error) onFailure,
bool seedConnectingSession = false,
bool rethrowOnFailure = false,
}) async {
final candidate = _peerServiceFactory();
_pendingRemotePeer = candidate;
if (seedConnectingSession) {
_session = RemoteSession(
role: RemoteSessionRole.remote,
status: RemoteSessionStatus.connecting,
createdAt: DateTime.now(),
);
}
_setupPeerServiceListeners(candidate, generation);
if (seedConnectingSession) safeNotifyListeners();
try {
await join(candidate);
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return false;
}
_pendingRemotePeer = null;
_peerService = candidate;
onConnected(candidate);
safeNotifyListeners();
return true;
} catch (error, stackTrace) {
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return false;
}
_pendingRemotePeer = null;
_cleanupSubscriptions();
await _disposePeerOnce(candidate);
appLogger.e(failureLog, error: error, stackTrace: stackTrace);
onFailure(error);
if (rethrowOnFailure) rethrow;
return false;
} }
} }
@@ -981,47 +992,34 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin
} }
if (generation != _remoteGeneration || isDisposed) return; if (generation != _remoteGeneration || isDisposed) return;
final candidate = _peerServiceFactory();
_pendingRemotePeer = candidate;
_setupPeerServiceListeners(candidate, generation);
final authContextId = _authContextForId(_lastAuthContextId)?.id; final authContextId = _authContextForId(_lastAuthContextId)?.id;
final expectedHostClientId = _lastHostClientId ?? ''; final expectedHostClientId = _lastHostClientId ?? '';
try { final reconnected = await _runRemoteConnect(
await candidate.joinSessionWithContexts( generation: generation,
join: (peer) => peer.joinSessionWithContexts(
_deviceName, _deviceName,
_platform, _platform,
hostAddresses.first, hostAddresses.first,
_authContexts, _authContexts,
authContextId: authContextId, authContextId: authContextId,
expectedHostClientId: expectedHostClientId, expectedHostClientId: expectedHostClientId,
); ),
if (!_ownsPeer(candidate, generation)) { onConnected: (peer) {
await _disposePeerOnce(candidate); _lastAuthContextId = peer.selectedAuthContextId ?? authContextId;
return; _lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId;
}
_pendingRemotePeer = null;
_peerService = candidate;
_lastAuthContextId = candidate.selectedAuthContextId ?? authContextId;
_lastHostClientId = candidate.selectedHostClientId ?? _lastHostClientId;
_session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null);
_reconnectAttempts = 0; _reconnectAttempts = 0;
safeNotifyListeners(); },
appLogger.d('CompanionRemote: Reconnected successfully'); failureLog: 'CompanionRemote: Reconnect failed',
} catch (error, stackTrace) { onFailure: (_) {
if (!_ownsPeer(candidate, generation)) {
await _disposePeerOnce(candidate);
return;
}
_pendingRemotePeer = null;
_cleanupSubscriptions();
await _disposePeerOnce(candidate);
appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace);
if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) { if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) {
_scheduleReconnect(generation); _scheduleReconnect(generation);
} }
},
);
if (reconnected) {
appLogger.d('CompanionRemote: Reconnected successfully');
} }
} }
+8 -58
View File
@@ -14,7 +14,7 @@ import '../services/system_shelf_service.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/coalesced_load_coordinator.dart'; import '../utils/coalesced_load_coordinator.dart';
import '../utils/deletion_notifier.dart'; import '../utils/deletion_notifier.dart';
import '../utils/global_key_utils.dart'; import '../utils/media_event_keys.dart';
import '../utils/media_hub_ordering.dart'; import '../utils/media_hub_ordering.dart';
import '../utils/watch_state_notifier.dart'; import '../utils/watch_state_notifier.dart';
import 'hidden_libraries_provider.dart'; import 'hidden_libraries_provider.dart';
@@ -467,28 +467,9 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// Watch on-deck items and their parent shows/seasons (an episode's watch /// Watch on-deck items and their parent shows/seasons (an episode's watch
/// flip changes what Continue Watching should show for its series). /// flip changes what Continue Watching should show for its series).
Set<String>? get _watchedIds { Set<String>? get _watchedIds => hierarchicalEventIds(_onDeck);
final keys = <String>{};
for (final item in _onDeck) {
keys.add(item.id);
if (item.parentId != null) keys.add(item.parentId!);
if (item.grandparentId != null) keys.add(item.grandparentId!);
}
return keys;
}
Set<String>? get _watchedGlobalKeys { Set<String>? get _watchedGlobalKeys => hierarchicalEventGlobalKeys(_onDeck);
final keys = <String>{};
for (final item in _onDeck) {
final serverId = item.serverId;
if (serverId == null) return null;
keys.add(buildGlobalKey(ServerId(serverId), item.id));
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
}
return keys;
}
void _onWatchStateChanged(WatchStateEvent event) { void _onWatchStateChanged(WatchStateEvent event) {
if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) { if (event.changeType == WatchStateChangeType.progressUpdate && event.isNowWatched != true) {
@@ -512,46 +493,15 @@ class DiscoverProvider extends ChangeNotifier with DisposableChangeNotifierMixin
unawaited(refreshContinueWatching()); unawaited(refreshContinueWatching());
} }
/// Everything on screen: the Continue Watching row plus every hub row.
Iterable<MediaItem> get _visibleItems => _onDeck.followedBy(_hubs.expand((hub) => hub.items));
/// Deletions can affect any visible list, so the filter covers on-deck and /// Deletions can affect any visible list, so the filter covers on-deck and
/// hub items plus their parents (a deleted season/show takes its visible /// hub items plus their parents (a deleted season/show takes its visible
/// episodes with it). /// episodes with it).
Set<String>? get _deletionIds { Set<String>? get _deletionIds => hierarchicalEventIds(_visibleItems);
final keys = <String>{};
void addItem(MediaItem item) {
keys.add(item.id);
if (item.parentId != null) keys.add(item.parentId!);
if (item.grandparentId != null) keys.add(item.grandparentId!);
}
_onDeck.forEach(addItem); Set<String>? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems);
for (final hub in _hubs) {
hub.items.forEach(addItem);
}
return keys;
}
Set<String>? get _deletionGlobalKeys {
final keys = <String>{};
bool addItem(MediaItem item) {
final serverId = item.serverId;
if (serverId == null) return false;
keys.add(buildGlobalKey(ServerId(serverId), item.id));
if (item.parentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.parentId!));
if (item.grandparentId != null) keys.add(buildGlobalKey(ServerId(serverId), item.grandparentId!));
return true;
}
for (final item in _onDeck) {
if (!addItem(item)) return null;
}
for (final hub in _hubs) {
for (final item in hub.items) {
if (!addItem(item)) return null;
}
}
return keys;
}
void _onDeletion(DeletionEvent event) { void _onDeletion(DeletionEvent event) {
// On-deck and hubs are server-backed: a download-only deletion leaves the // On-deck and hubs are server-backed: a download-only deletion leaves the
+1 -1
View File
@@ -139,7 +139,7 @@ class _DownloadMetadataStore extends ChangeNotifier {
hydrated.add( hydrated.add(
HydratedWatchStatePatch( HydratedWatchStatePatch(
globalKey: scopedKey, globalKey: scopedKey,
patch: WatchStatePatch.fromSnapshot(snapshot), patch: snapshot,
updatedAt: latest.updatedAt, updatedAt: latest.updatedAt,
order: latest.id, order: latest.id,
), ),
+4 -38
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import '../media/ids.dart'; import '../media/ids.dart';
import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../media/media_backend.dart'; import '../media/media_backend.dart';
@@ -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(
'Downloaded version mismatch for $globalKey: have index ${downloadedItem.mediaIndex} '
'(source ${downloadedItem.mediaSourceId}), expected index $mediaIndex '
'(source ${mediaSourceId?.trim()})',
); );
return null; return source?.path;
}
if (downloadedItem.videoFilePath == null) {
appLogger.w('Video file path is null for globalKey: $globalKey');
return null;
}
final storedPath = downloadedItem.videoFilePath!;
final storageService = DownloadStorageService.instance;
// SAF URIs (content://) are already valid - don't transform them
if (storageService.isSafUri(storedPath)) {
appLogger.d('Found SAF video path: $storedPath');
return storedPath;
}
// Convert stored path (may be relative) to absolute path
final absolutePath = await storageService.ensureAbsolutePath(storedPath);
// Verify file exists
final file = File(absolutePath);
if (!await file.exists()) {
appLogger.w('Offline video file not found: $absolutePath');
return null;
}
return absolutePath;
} }
/// Queue a download for a media item. /// Queue a download for a media item.
+15 -33
View File
@@ -108,24 +108,20 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
/// filter to a one-element set when no filter is currently set. /// filter to a one-element set when no filter is currently set.
void addToVisibleServerIds(ServerId serverId) { void addToVisibleServerIds(ServerId serverId) {
final current = _visibleServerIds; final current = _visibleServerIds;
if (current == null) { if (current != null && current.contains(serverId)) return;
_serverManager.setVisibleServerIds({serverId}); _serverManager.setVisibleServerIds({...?current, serverId});
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
safeNotifyListeners();
_refreshLiveTvAvailabilitySoon();
return;
}
if (current.contains(serverId)) return;
_serverManager.setVisibleServerIds({...current, serverId});
_expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId}; _expectedVisibleServerIds = {...?_expectedVisibleServerIds, serverId};
safeNotifyListeners(); safeNotifyListeners();
_refreshLiveTvAvailabilitySoon(); _refreshLiveTvAvailabilitySoon();
} }
/// Keep only ids the manager considers visible under the active filter.
List<String> _visible(List<String> ids) =>
ids.where((id) => _serverManager.isServerVisible(ServerId(id))).toList();
void _pruneLiveTvServersForVisibility() { void _pruneLiveTvServersForVisibility() {
final filter = _visibleServerIds; if (_visibleServerIds == null) return;
if (filter == null) return; _liveTvServers.removeWhere((s) => !_serverManager.isServerVisible(ServerId(s.serverId)));
_liveTvServers.removeWhere((s) => !filter.contains(s.serverId));
_hasLiveTv = _liveTvServers.isNotEmpty; _hasLiveTv = _liveTvServers.isNotEmpty;
} }
@@ -199,20 +195,10 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
} }
/// Get all online server IDs (visibility-filtered). /// Get all online server IDs (visibility-filtered).
List<String> get onlineServerIds { List<String> get onlineServerIds => _visible(_serverManager.onlineServerIds);
final all = _serverManager.onlineServerIds;
final filter = _visibleServerIds;
if (filter == null) return all;
return all.where(filter.contains).toList();
}
/// Get all server IDs (visibility-filtered). /// Get all server IDs (visibility-filtered).
List<String> get serverIds { List<String> get serverIds => _visible(_serverManager.serverIds);
final all = _serverManager.serverIds;
final filter = _visibleServerIds;
if (filter == null) return all;
return all.where(filter.contains).toList();
}
/// Server ids the active profile is expected to have, including unreachable /// Server ids the active profile is expected to have, including unreachable
/// Plex servers that have no live client yet. /// Plex servers that have no live client yet.
@@ -223,11 +209,8 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
} }
/// Check if a server is online (and visible under the active profile). /// Check if a server is online (and visible under the active profile).
bool isServerOnline(ServerId serverId) { bool isServerOnline(ServerId serverId) =>
final filter = _visibleServerIds; _serverManager.isServerVisible(serverId) && _serverManager.isServerOnline(serverId);
if (filter != null && !filter.contains(serverId)) return false;
return _serverManager.isServerOnline(serverId);
}
/// Get number of online servers /// Get number of online servers
int get onlineServerCount => onlineServerIds.length; int get onlineServerCount => onlineServerIds.length;
@@ -312,10 +295,9 @@ class MultiServerProvider extends ChangeNotifier with DisposableChangeNotifierMi
} }
} }
final filter = _visibleServerIds; final visibleLiveTvServers = newLiveTvServers
final visibleLiveTvServers = filter == null .where((s) => _serverManager.isServerVisible(ServerId(s.serverId)))
? newLiveTvServers .toList();
: newLiveTvServers.where((s) => filter.contains(s.serverId)).toList();
final hadLiveTv = _hasLiveTv; final hadLiveTv = _hasLiveTv;
final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet(); final oldServerIds = _liveTvServers.map((s) => '${s.serverId}\u0000${s.dvrKey}').toSet();
+7 -40
View File
@@ -11,36 +11,10 @@ import '../services/watch_state_resolver.dart';
import '../utils/global_key_utils.dart'; import '../utils/global_key_utils.dart';
import '../utils/watch_state_notifier.dart'; import '../utils/watch_state_notifier.dart';
@immutable
class WatchStatePatch {
final bool? isWatched;
final bool hasViewOffsetMs;
final int? viewOffsetMs;
const WatchStatePatch({this.isWatched, this.hasViewOffsetMs = false, this.viewOffsetMs});
factory WatchStatePatch.fromSnapshot(WatchStateSnapshot snapshot) => WatchStatePatch(
isWatched: snapshot.isWatched,
hasViewOffsetMs: snapshot.hasViewOffsetMs,
viewOffsetMs: snapshot.viewOffsetMs,
);
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is WatchStatePatch &&
other.isWatched == isWatched &&
other.hasViewOffsetMs == hasViewOffsetMs &&
other.viewOffsetMs == viewOffsetMs;
@override
int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs);
}
@immutable @immutable
class HydratedWatchStatePatch { class HydratedWatchStatePatch {
final String globalKey; final String globalKey;
final WatchStatePatch patch; final WatchStateSnapshot patch;
final int updatedAt; final int updatedAt;
final int order; final int order;
@@ -53,7 +27,7 @@ class HydratedWatchStatePatch {
} }
class _WatchStatePatchEntry { class _WatchStatePatchEntry {
final WatchStatePatch patch; final WatchStateSnapshot patch;
final int updatedAt; final int updatedAt;
final int sequence; final int sequence;
final bool isSessionEvent; final bool isSessionEvent;
@@ -123,9 +97,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;
+9 -28
View File
@@ -7,6 +7,7 @@ import '../media/media_item.dart';
import '../media/media_kind.dart'; import '../media/media_kind.dart';
import '../media/media_server_client.dart'; import '../media/media_server_client.dart';
import '../mixins/paginated_item_loader.dart'; import '../mixins/paginated_item_loader.dart';
import '../mixins/standard_paginated_view.dart';
import '../utils/app_logger.dart'; import '../utils/app_logger.dart';
import '../utils/media_server_http_client.dart'; import '../utils/media_server_http_client.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
@@ -48,7 +49,9 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
with with
GridFocusNodeMixin<ActorMediaScreen>, GridFocusNodeMixin<ActorMediaScreen>,
FocusableDetailScreenMixin<ActorMediaScreen>, FocusableDetailScreenMixin<ActorMediaScreen>,
PaginatedItemLoader<MediaItem, ActorMediaScreen> { PaginatedItemLoader<MediaItem, ActorMediaScreen>,
PaginatedItemUpdatable<ActorMediaScreen>,
StandardPaginatedView<MediaItem, ActorMediaScreen> {
static const int _pageSize = 200; static const int _pageSize = 200;
@override @override
@@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen<ActorMediaScreen>
} }
@override @override
void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { Future<void> loadItems() {
for (final entry in loadedItems.entries) { return loadStandardPaginatedItems(
if (entry.value.globalKey == sourceGlobalKey) {
loadedItems[entry.key] = updatedItem;
return;
}
}
}
@override
Future<void> loadItems() async {
await loadInitialPaginatedItems(
pageSize: _pageSize, pageSize: _pageSize,
resetViewState: () { errorMessageFor: (error, stackTrace) {
isLoading = true; appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
errorMessage = null; return t.messages.errorLoading(error: error.toString());
items = [];
},
applyLoadedItems: (loaded) {
items = loaded;
isLoading = false;
},
applyError: (error, _) {
errorMessage = t.messages.errorLoading(error: error.toString());
isLoading = false;
}, },
onLoaded: (loadedCount, totalCount) { onLoaded: (loadedCount, totalCount) {
appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}'); appLogger.d('Loaded $loadedCount of $totalCount items for actor: ${widget.actorName}');
autoFocusFirstItemAfterLoad(); autoFocusFirstItemAfterLoad();
}, },
onError: (error, stackTrace) {
appLogger.e('Failed to load actor media', error: error, stackTrace: stackTrace);
},
); );
} }
+2 -2
View File
@@ -12,6 +12,7 @@ import '../profiles/active_profile_provider.dart';
import '../profiles/plex_home_service.dart'; import '../profiles/plex_home_service.dart';
import '../profiles/profile.dart'; import '../profiles/profile.dart';
import '../profiles/profile_connection_registry.dart'; import '../profiles/profile_connection_registry.dart';
import '../profiles/profile_selection_policy.dart';
import '../services/plex_auth_service.dart'; import '../services/plex_auth_service.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../services/storage_service.dart'; import '../services/storage_service.dart';
@@ -196,8 +197,7 @@ class _AuthScreenState extends State<AuthScreen> {
activeProfile: activeProfiles.active, activeProfile: activeProfiles.active,
hasProfiles: activeProfiles.profiles.isNotEmpty, hasProfiles: activeProfiles.profiles.isNotEmpty,
accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true, accountHasHomeUsers: plexHome.current[accountConnection.id]?.isNotEmpty == true,
requireProfileSelectionOnOpen: requireProfileSelectionOnOpen: activeProfiles.requiresSelectionOnOpen(settings),
settings.read(SettingsService.requireProfileSelectionOnOpen) && activeProfiles.hasMultipleProfiles,
); );
if (promptHandled) { if (promptHandled) {
final selected = await Navigator.of( final selected = await Navigator.of(
+27 -11
View File
@@ -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) {
serverId = item.serverId;
} else if (item is MediaPlaylist) {
serverId = item.serverId;
}
if (serverId == null) {
final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false); final multiServerProvider = Provider.of<MultiServerProvider>(context, listen: false);
serverId = multiServerProvider.onlineServerIds.firstOrNull; return multiServerProvider.onlineServerIds.firstOrNull;
}
return serverId;
} }
MediaServerClient _getMediaClientForMediaItem() { MediaServerClient _getMediaClientForMediaItem() {
+18 -67
View File
@@ -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,18 +100,15 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen<CollectionD
FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems), FocusableAction(icon: Symbols.play_arrow_rounded, tooltip: t.common.play, onPressed: playItems),
FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems), FocusableAction(icon: Symbols.shuffle_rounded, tooltip: t.common.shuffle, onPressed: shufflePlayItems),
], ],
if (!PlatformDetector.isAppleTV()) // Emptiness is handled inside [_downloadCollection], so the download
FocusableAction( // entry stays visible for empty collections.
icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, ...buildSyncRuleActions(
tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, context,
onPressed: hasRule ? _manageCollectionSyncRule : _downloadCollection, ruleKey: ruleKey,
iconColor: hasRule ? Colors.teal : null, displayTitle: widget.collection.displayTitle,
), hasRule: hasRule,
if (!PlatformDetector.isAppleTV() && hasRule) showDownload: true,
FocusableAction( onDownload: _downloadCollection,
icon: Symbols.sync_disabled_rounded,
tooltip: t.downloads.removeSyncRule,
onPressed: _removeCollectionSyncRule,
), ),
FocusableAction( FocusableAction(
icon: Symbols.delete_rounded, icon: Symbols.delete_rounded,
@@ -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,
+18 -117
View File
@@ -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,8 +181,17 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!; if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!;
final hubs = <MediaHub>[]; final hubs = <MediaHub>[];
if (_onDeck.isNotEmpty) { if (_onDeck.isNotEmpty) {
hubs.add( hubs.add(_continueWatchingHub);
MediaHub( }
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
_tvBrowseHubsCache = hubs;
_tvBrowseHubsCacheKey = key;
return hubs;
}
/// The synthesized Continue Watching row, rendered ahead of the backend hubs
/// on both the mobile list and the TV rail.
MediaHub get _continueWatchingHub => MediaHub(
id: 'continue_watching', id: 'continue_watching',
title: t.discover.continueWatching, title: t.discover.continueWatching,
type: 'mixed', type: 'mixed',
@@ -189,14 +199,7 @@ class _DiscoverScreenState extends State<DiscoverScreen>
size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0),
more: _hasMoreContinueWatching, more: _hasMoreContinueWatching,
items: _onDeck, items: _onDeck,
),
); );
}
hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty));
_tvBrowseHubsCache = hubs;
_tvBrowseHubsCacheKey = key;
return hubs;
}
void _setSpotlightItem(MediaItem item) => _spotlight.select(item); void _setSpotlightItem(MediaItem item) => _spotlight.select(item);
@@ -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,
+10 -48
View File
@@ -4,7 +4,6 @@ import '../focus/input_mode_tracker.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../media/media_playlist.dart';
import '../mixins/grid_focus_node_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../utils/platform_detector.dart'; import '../utils/platform_detector.dart';
@@ -15,14 +14,6 @@ import '../widgets/media_card_sliver_layout.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../widgets/skeleton_media_card.dart'; import '../widgets/skeleton_media_card.dart';
/// Extract the stable id from a [MediaItem]/[MediaPlaylist] for use as a
/// Flutter widget Key.
String _idForItem(Object item) {
if (item is MediaItem) return item.id;
if (item is MediaPlaylist) return item.id;
return identityHashCode(item).toString();
}
/// Mixin that provides common focus navigation functionality for detail screens. /// Mixin that provides common focus navigation functionality for detail screens.
/// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management. /// Handles app bar focus, back navigation, scroll-to-top, and grid item focus management.
/// ///
@@ -164,56 +155,27 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
/// Used by collection, smart playlist, and music artist detail screens. /// Used by collection, smart playlist, and music artist detail screens.
/// [shape] overrides the grid cell silhouette (e.g. [CardShape.square] /// [shape] overrides the grid cell silhouette (e.g. [CardShape.square]
/// for album grids); null keeps the stock poster geometry. /// for album grids); null keeps the stock poster geometry.
///
/// Fully-loaded case of [buildSparseFocusableGrid]: every slot resolves to an
/// item, so the skeleton branch is unreachable.
Widget buildFocusableGrid({ Widget buildFocusableGrid({
required List<dynamic> items, required List<MediaItem> items,
required void Function(MediaItem source) onRefresh, required void Function(MediaItem source) onRefresh,
String? collectionId, String? collectionId,
VoidCallback? onListRefresh, VoidCallback? onListRefresh,
CardShape? shape, CardShape? shape,
}) { }) {
return SettingsBuilder( return buildSparseFocusableGrid(
prefs: const [SettingsService.viewMode, SettingsService.libraryDensity, SettingsService.tvFullCardLayout], totalItems: items.length,
builder: (context) { itemAt: (index) => items[index],
final svc = SettingsService.instance;
final viewMode = svc.read(SettingsService.viewMode);
final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
final useFullCardLayout = fullCardLayout && shape != CardShape.square;
return MediaCardSliverLayout(
viewMode: viewMode,
itemCount: items.length,
density: libraryDensity,
padding: const EdgeInsets.all(8),
fullBleedImage: useFullCardLayout,
shape: shape,
itemBuilder: (context, position) {
final index = position.index;
final item = items[index];
final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard(
key: Key(_idForItem(item)),
item: item,
focusNode: focusNode,
semanticValue: _semanticPosition(position),
disableScale: position.disableScale,
onRefresh: onRefresh, onRefresh: onRefresh,
collectionId: collectionId, collectionId: collectionId,
onListRefresh: onListRefresh, onListRefresh: onListRefresh,
fullBleedImage: useFullCardLayout && position.isGrid, shape: shape,
cardShapeOverride: shape,
onNavigateUp: position.isFirstRow ? navigateToAppBar : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
);
},
);
},
); );
} }
/// Sparse-loading version of [buildFocusableGrid]. Renders [totalItems] /// Sparse-loading counterpart of [buildFocusableGrid]. Renders [totalItems]
/// slots; for each, [itemAt] returns the loaded item or null if not yet /// slots; for each, [itemAt] returns the loaded item or null if not yet
/// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so /// fetched. Null slots render a skeleton and invoke [onSkeletonVisible] so
/// the caller can kick off a page fetch containing that index. /// the caller can kick off a page fetch containing that index.
@@ -242,7 +204,7 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
onSkeletonVisible?.call(index); onSkeletonVisible?.call(index);
return const SkeletonMediaCard(); return const SkeletonMediaCard();
} }
final focusNode = index == 0 ? firstItemFocusNode : getGridItemFocusNode(index, prefix: 'detail_grid_item'); final focusNode = _focusNodeForIndex(index);
return FocusableMediaCard( return FocusableMediaCard(
key: Key(item.id), key: Key(item.id),
item: item, item: item,
+7 -30
View File
@@ -26,7 +26,6 @@ import '../widgets/desktop_app_bar.dart';
import '../widgets/loading_indicator_box.dart'; import '../widgets/loading_indicator_box.dart';
import '../widgets/overlay_sheet.dart'; import '../widgets/overlay_sheet.dart';
import '../focus/focusable_action_bar.dart'; import '../focus/focusable_action_bar.dart';
import '../focus/focusable_button.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../mixins/grid_focus_node_mixin.dart'; import '../mixins/grid_focus_node_mixin.dart';
import '../mixins/paginated_item_loader.dart'; import '../mixins/paginated_item_loader.dart';
@@ -493,34 +492,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error; Object? get _pageLoadError => _usesPaginatedLoader ? paginationError : _continuation.error;
bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading; bool get _isLoadingPage => _usesPaginatedLoader ? isPaginationLoading : _continuation.isLoading;
Widget _buildContinuationStatusSliver() {
final exception = _pageLoadError;
final error = exception == null ? null : t.messages.errorLoading(error: exception.toString());
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: error == null
? const CircularProgressIndicator()
: Column(
mainAxisSize: .min,
children: [
Text(error, textAlign: TextAlign.center),
const SizedBox(height: 8),
FocusableButton(
focusNode: _continuationRetryFocusNode,
onPressed: _retryHubContinuation,
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent,
child: TextButton(onPressed: _retryHubContinuation, child: Text(t.common.retry)),
),
],
),
),
),
);
}
@override @override
void refresh() { void refresh() {
_loadMoreItems(); _loadMoreItems();
@@ -633,7 +604,13 @@ class _HubDetailScreenState extends State<HubDetailScreen>
}, },
), ),
if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null)) if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null))
_buildContinuationStatusSliver(), ContinuationStatusSliver(
error: _pageLoadError,
onRetry: _retryHubContinuation,
retryFocusNode: _continuationRetryFocusNode,
onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(),
onBack: handleBackFromContent,
),
], ],
), ),
), ),
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import '../../focus/focusable_button.dart';
import '../../i18n/strings.g.dart'; import '../../i18n/strings.g.dart';
import 'state_messages.dart'; import 'state_messages.dart';
@@ -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 {
+13 -35
View File
@@ -6,6 +6,7 @@ import '../../media/media_item.dart';
import '../../media/media_kind.dart'; import '../../media/media_kind.dart';
import '../../media/media_server_client.dart'; import '../../media/media_server_client.dart';
import '../../services/jellyfin_sequential_launcher.dart'; import '../../services/jellyfin_sequential_launcher.dart';
import '../../services/media_list_playback_launcher.dart';
import '../../services/play_queue_launcher.dart'; import '../../services/play_queue_launcher.dart';
import '../../utils/app_logger.dart'; import '../../utils/app_logger.dart';
import '../../utils/error_message_utils.dart'; import '../../utils/error_message_utils.dart';
@@ -243,42 +244,19 @@ class FolderTreeViewState extends State<FolderTreeView> {
} }
} }
Future<void> _handleFolderPlay(MediaItem folder) async { /// Play (or shuffle) a folder row through the backend's launcher. Built
/// here rather than via [MediaListPlaybackLauncher.forItem] because this
/// tree is pinned to one server: the Plex client must be the one backing
/// [widget.serverId], not `forItem`'s fall-back-to-any-online resolution.
Future<void> _launchFolder(MediaItem folder, {required bool shuffle}) async {
final MediaListPlaybackLauncher launcher;
if (folder.backend == MediaBackend.jellyfin) { if (folder.backend == MediaBackend.jellyfin) {
final launcher = JellyfinSequentialLauncher(context: context); launcher = JellyfinSequentialLauncher(context: context);
await launcher.launchFromFolder(folder: folder, shuffle: false); } else {
return;
}
final folderKey = folder.backendFolderKey;
if (folderKey == null) return;
final client = context.getPlexClientForServer(ServerId(widget.serverId!)); final client = context.getPlexClientForServer(ServerId(widget.serverId!));
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
await launcher.launchFromFolder(
folderKey: folderKey,
shuffle: false,
libraryId: folder.libraryId,
libraryTitle: folder.libraryTitle,
);
} }
await launcher.launchFromFolder(folder: folder, shuffle: shuffle);
Future<void> _handleFolderShuffle(MediaItem folder) async {
if (folder.backend == MediaBackend.jellyfin) {
final launcher = JellyfinSequentialLauncher(context: context);
await launcher.launchFromFolder(folder: folder, shuffle: true);
return;
}
final folderKey = folder.backendFolderKey;
if (folderKey == null) return;
final client = context.getPlexClientForServer(ServerId(widget.serverId!));
final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId);
await launcher.launchFromFolder(
folderKey: folderKey,
shuffle: true,
libraryId: folder.libraryId,
libraryTitle: folder.libraryTitle,
);
} }
/// Expandable rows: directory rows plus Jellyfin media containers whose /// Expandable rows: directory rows plus Jellyfin media containers whose
@@ -400,8 +378,8 @@ class FolderTreeViewState extends State<FolderTreeView> {
serverId: widget.serverId, serverId: widget.serverId,
onExpand: isExpandable ? () => _toggleFolder(item) : null, onExpand: isExpandable ? () => _toggleFolder(item) : null,
onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null, onTap: !isExpandable ? () => _handleItemTap(item, entry.parent) : null,
onPlayAll: canPlayFolder ? () => _handleFolderPlay(item) : null, onPlayAll: canPlayFolder ? () => _launchFolder(item, shuffle: false) : null,
onShuffle: canPlayFolder ? () => _handleFolderShuffle(item) : null, onShuffle: canPlayFolder ? () => _launchFolder(item, shuffle: true) : null,
focusNode: isFirstRootItem ? widget.firstItemFocusNode : null, focusNode: isFirstRootItem ? widget.firstItemFocusNode : null,
onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null, onNavigateUp: isFirstRootItem ? widget.onNavigateUp : null,
onNavigateLeft: widget.onNavigateLeft, onNavigateLeft: widget.onNavigateLeft,
@@ -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;
}
} }
+36 -41
View File
@@ -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>();
final futures = <Future<void>>[]; Future<void> runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async {
for (final serverInfo in multiServer.liveTvServers) { try {
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); await action(dvr, serverInfo);
if (client == null || client.liveTvDvr == null) continue; } catch (e) {
futures.add(_reloadGuideSafe(client, serverInfo.dvrKey)); appLogger.d('$actionLabel failed for DVR ${serverInfo.dvrKey}: $e');
} }
if (futures.isEmpty) return;
await Future.wait(futures);
if (!mounted) return;
showSnackBar(context, t.liveTv.guideReloadRequested);
} }
Future<void> _reloadGuideSafe(MediaServerClient client, String dvrId) async { final futures = <Future<void>>[];
try { for (final serverInfo in multiServer.liveTvServers) {
final dvr = client.liveTvDvr; final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr;
if (dvr == null) return; if (dvr == null) continue;
await dvr.reloadGuide(dvrId); futures.add(runSafely(dvr, serverInfo));
} catch (e) {
// 403 (admin only) and transient errors are non-fatal — caller still
// re-fetches client-side channels.
appLogger.d('Reload guide failed for DVR $dvrId: $e');
} }
if (futures.isEmpty) return false;
await Future.wait(futures);
if (!mounted) return false;
showSnackBar(context, successMessage);
return true;
} }
Future<void> _processRecordingRules() async { Future<void> _processRecordingRules() async {
final multiServer = context.read<MultiServerProvider>(); final reached = await _broadcastToDvrs(
final futures = <Future<void>>[]; actionLabel: 'processRecordingRules',
for (final serverInfo in multiServer.liveTvServers) { successMessage: t.liveTv.rulesProcessRequested,
final client = multiServer.getClientForServer(ServerId(serverInfo.serverId)); action: (dvr, _) => dvr.processRecordingRules(),
if (client == null || client.liveTvDvr == null) continue; );
futures.add(_processRulesSafe(client)); if (!reached) return;
}
if (futures.isEmpty) return;
await Future.wait(futures);
if (!mounted) return;
showSnackBar(context, t.liveTv.rulesProcessRequested);
await _recordingsTabKey.currentState?.reload(); await _recordingsTabKey.currentState?.reload();
} }
Future<void> _processRulesSafe(MediaServerClient client) async {
try {
final dvr = client.liveTvDvr;
if (dvr == null) return;
await dvr.processRecordingRules();
} catch (e) {
appLogger.d('processRecordingRules failed: $e');
}
}
/// Recompute visible tabs from the current MultiServerProvider state. /// Recompute visible tabs from the current MultiServerProvider state.
/// Re-inits the tab controller when the visible set changes (matches the /// Re-inits the tab controller when the visible set changes (matches the
/// libraries-screen pattern at libraries_screen.dart:365). /// libraries-screen pattern at libraries_screen.dart:365).
+30 -150
View File
@@ -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,
); );
}, },
), ),
+82 -137
View File
@@ -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));
if (onlineClient == null) continue;
_downloadResumeFired = true; _downloadResumeFired = true;
// The status subscription exists only to drive this one-shot.
_serverStatusSub?.cancel();
_serverStatusSub = null;
final downloadProvider = context.read<DownloadProvider>();
unawaited( unawaited(
context.read<DownloadProvider>().ensureInitialized().then((_) { downloadProvider.ensureInitialized().then((_) {
if (!mounted) return; if (!mounted) return;
context.read<DownloadProvider>().resumeQueuedDownloads(onlineClient); 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}) {
+10 -32
View File
@@ -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
+2 -11
View File
@@ -122,22 +122,13 @@ 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: true, multiline: multiline,
)
: 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)) {
+2 -24
View File
@@ -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,
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: OverlaySheetHost(
// Host owns sheet + system back: a back with a sheet open closes it;
// otherwise focus the action row first, then pop.
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (handleBackNavigation() && mounted) Navigator.pop(context);
},
child: Scaffold(
body: CustomScrollView(
primary: true,
slivers: [ slivers: [
CustomAppBar(title: Text(widget.album.displayTitle)), CustomAppBar(title: Text(widget.album.displayTitle)),
SliverToBoxAdapter(child: _buildHeader()), SliverToBoxAdapter(child: _buildHeader()),
...buildStateSlivers(), ...buildStateSlivers(),
if (hasItems) _buildTrackList(), if (hasItems) _buildTrackList(),
// Keep the last rows reachable above the floating mini-player. // Keep the last rows reachable above the floating mini-player.
SliverToBoxAdapter( SliverToBoxAdapter(child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0)),
child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0),
),
], ],
),
),
),
),
); );
} }
} }
+7 -45
View File
@@ -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,21 +175,7 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PrimaryScrollController( return buildDetailScaffold(
controller: scrollController,
child: IosStatusBarTapScrollToTop(
controller: scrollController,
child: OverlaySheetHost(
// Host owns sheet + system back: a back with a sheet open closes it;
// otherwise focus the action row first, then pop.
canPop: PlatformDetector.isHandheldIOS(context),
onSystemBack: () {
if (BackKeyCoordinator.consumeIfHandled()) return;
if (handleBackNavigation() && mounted) Navigator.pop(context);
},
child: Scaffold(
body: CustomScrollView(
primary: true,
slivers: [ slivers: [
CustomAppBar(title: Text(widget.artist.displayTitle)), CustomAppBar(title: Text(widget.artist.displayTitle)),
SliverToBoxAdapter(child: _buildHeader()), SliverToBoxAdapter(child: _buildHeader()),
@@ -215,14 +183,8 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen<ArtistDetailScr
// Albums arrive newest-first from both backends — no client-side sort. // Albums arrive newest-first from both backends — no client-side sort.
if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square), if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square),
// Keep the last rows reachable above the floating mini-player. // Keep the last rows reachable above the floating mini-player.
SliverToBoxAdapter( SliverToBoxAdapter(child: SizedBox(height: context.watch<MiniPlayerInsetController?>()?.overlayHeight ?? 0)),
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,18 +117,13 @@ 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
@@ -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,7 +350,13 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
tokenAcquiredAt: DateTime.now(), tokenAcquiredAt: DateTime.now(),
), ),
); );
if (mounted) { _finishBorrow();
}
/// Shared tail of every successful borrow: rebind the target profile when
/// it is the active one, then pop with the result or confirm in place.
void _finishBorrow() {
if (!mounted) return;
unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id)); unawaited(context.read<ActiveProfileBinder>().rebindIfActive(widget.targetProfile.id));
if (widget.popOnSuccess) { if (widget.popOnSuccess) {
Navigator.of(context).pop(true); Navigator.of(context).pop(true);
@@ -374,7 +364,6 @@ class _BorrowConnectionScreenState extends State<BorrowConnectionScreen> {
} }
showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed);
} }
}
} }
class _BorrowCandidate { class _BorrowCandidate {
@@ -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
+17 -46
View File
@@ -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);
+10 -20
View File
@@ -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),
+2 -14
View File
@@ -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,8 +357,7 @@ 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,
@@ -388,8 +375,6 @@ class AppearanceSettingsScreen extends StatelessWidget {
subtitle: t.settings.visualEffectsReducedDescription, subtitle: t.settings.visualEffectsReducedDescription,
), ),
], ],
decode: (v) => v,
encode: (v) => v,
onAfterWrite: (value) { onAfterWrite: (value) {
DevicePerformance.setOverrideSync(value); DevicePerformance.setOverrideSync(value);
_restartApp(context); _restartApp(context);
@@ -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;
+30 -72
View File
@@ -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,7 +611,10 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
} }
Future<bool> _selectDownloadLocation() async { Future<bool> _selectDownloadLocation() async {
try { final changed = await guardSettingsOperation<bool, DownloadStorageException>(
context,
operation: 'Download directory selection',
body: () async {
String? selectedPath; String? selectedPath;
String pathType = 'file'; String pathType = 'file';
@@ -635,8 +635,9 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
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;
final isWritable = await writableChecker(dir);
if (!mounted) return false; if (!mounted) return false;
if (!isWritable) { if (!isWritable) {
showErrorSnackBar(context, t.settings.downloadLocationInvalid); showErrorSnackBar(context, t.settings.downloadLocationInvalid);
@@ -651,28 +652,9 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
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>(
context,
operation: 'Settings export',
body: () async {
final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)();
if (!mounted || path == null) return; if (!mounted || path == null) return;
showSuccessSnackBar(context, t.settings.exportSettingsSuccess); showSuccessSnackBar(context, t.settings.exportSettingsSuccess);
} on SettingsExportException catch (error, stackTrace) { },
if (!mounted) { );
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
return;
}
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
} on PlatformException catch (error, stackTrace) {
if (!mounted) {
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
return;
}
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
} on FileSystemException catch (error, stackTrace) {
if (!mounted) {
appLogger.e('Settings export failed', error: error, stackTrace: stackTrace);
return;
}
showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace);
}
} }
Future<void> _showImportSettingsDialog() async { Future<void> _showImportSettingsDialog() async {
@@ -757,6 +725,12 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
} }
Future<void> _handleImportSettings() async { Future<void> _handleImportSettings() async {
await guardSettingsOperation<void, SettingsExportException>(
context,
operation: 'Settings import',
body: () async {
// The two typed import failures carry their own message, so they are
// handled here instead of falling through to the generic guard.
try { try {
final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)();
if (!mounted) return; if (!mounted) return;
@@ -783,25 +757,9 @@ class _SettingsScreenState extends State<SettingsScreen> with FocusableTab, Moun
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 {
+26
View File
@@ -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());
}); });
+5 -2
View File
@@ -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: () {
+32 -45
View File
@@ -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,66 +188,22 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
final episode = nextEpisode; final episode = nextEpisode;
if (isInPip || !visible || episode == null) { if (episode == null) return const SizedBox.shrink();
return const SizedBox.shrink(); return _VideoPlayerPromptShell(
} visible: visible,
return _VideoPlayerPromptPosition(
chromeController: chromeController,
child: _VideoPlayerPromptInteractionHold(
chromeController: chromeController, chromeController: chromeController,
focusNodes: [cancelFocusNode, confirmFocusNode], focusNodes: [cancelFocusNode, confirmFocusNode],
child: _VideoPlayerPromptCard(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [ children: [
_PlayNextEpisodeHeader(episode: episode), _PlayNextEpisodeHeader(episode: episode),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( _VideoPlayerPromptActions(
children: [ cancelLabel: t.common.cancel,
Expanded( cancelFocusNode: cancelFocusNode,
child: FocusableButton( onCancel: onCancel,
focusNode: cancelFocusNode, confirmFocusNode: confirmFocusNode,
onPressed: onCancel, onConfirm: onPlayNext,
autoScroll: false, confirmChildren: [
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) ...[ if (autoPlayCountdown > 0) ...[
Text('$autoPlayCountdown'), Text('$autoPlayCountdown'),
const SizedBox(width: 4), const SizedBox(width: 4),
@@ -256,17 +212,7 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget {
Text(t.videoControls.playNext), Text(t.videoControls.playNext),
], ],
), ),
),
),
),
], ],
),
],
),
),
),
);
},
); );
} }
} }
@@ -346,21 +292,10 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ValueListenableBuilder<bool>( return _VideoPlayerPromptShell(
valueListenable: PipService().isPipActive, visible: visible,
builder: (context, isInPip, child) {
if (isInPip || !visible) {
return const SizedBox.shrink();
}
return _VideoPlayerPromptPosition(
chromeController: chromeController,
child: _VideoPlayerPromptInteractionHold(
chromeController: chromeController, chromeController: chromeController,
focusNodes: [pauseFocusNode, continueFocusNode], focusNodes: [pauseFocusNode, continueFocusNode],
child: _VideoPlayerPromptCard(
child: Column(
mainAxisSize: .min,
crossAxisAlignment: .start,
children: [ children: [
Text( Text(
t.videoControls.stillWatching, t.videoControls.stillWatching,
@@ -372,63 +307,117 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget {
style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600),
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
Row( _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
Widget build(BuildContext context) {
return ValueListenableBuilder<bool>(
valueListenable: PipService().isPipActive,
builder: (context, isInPip, child) {
if (isInPip || !visible) {
return const SizedBox.shrink();
}
return _VideoPlayerPromptPosition(
chromeController: chromeController,
child: _VideoPlayerPromptInteractionHold(
chromeController: chromeController,
focusNodes: focusNodes,
child: _VideoPlayerPromptCard(
child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children),
),
),
);
},
);
}
}
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: [ children: [
Expanded( Expanded(
child: FocusableButton( child: FocusableButton(
focusNode: pauseFocusNode, focusNode: cancelFocusNode,
onPressed: onPause, onPressed: onCancel,
autoScroll: false, autoScroll: false,
onNavigateRight: () => continueFocusNode.requestFocus(), onNavigateRight: () => confirmFocusNode.requestFocus(),
onNavigateUp: () {}, onNavigateUp: () {},
onNavigateDown: () {}, onNavigateDown: () {},
child: OutlinedButton( child: OutlinedButton(
onPressed: onPause, onPressed: onCancel,
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: Colors.white, foregroundColor: Colors.white,
side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), side: BorderSide(color: Colors.white.withValues(alpha: 0.5)),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
child: Text(t.videoControls.pauseButton), child: Text(cancelLabel),
), ),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded( Expanded(
child: FocusableButton( child: FocusableButton(
focusNode: continueFocusNode, focusNode: confirmFocusNode,
onPressed: onContinue, onPressed: onConfirm,
autoScroll: false, autoScroll: false,
onNavigateLeft: () => pauseFocusNode.requestFocus(), onNavigateLeft: () => cancelFocusNode.requestFocus(),
onNavigateUp: () {}, onNavigateUp: () {},
onNavigateDown: () {}, onNavigateDown: () {},
useBackgroundFocus: true, useBackgroundFocus: true,
child: FilledButton( child: FilledButton(
onPressed: onContinue, onPressed: onConfirm,
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: Colors.black, foregroundColor: Colors.black,
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
), ),
child: Row( child: Row(mainAxisAlignment: .center, children: confirmChildren),
mainAxisAlignment: .center,
children: [
Text('$countdown'),
const SizedBox(width: 4),
Text(t.videoControls.continueWatching),
],
),
), ),
), ),
), ),
], ],
),
],
),
),
),
);
},
); );
} }
} }
+19 -25
View File
@@ -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,14 +9,14 @@ 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';
Future<bool> startCompanionRemoteHost(BuildContext context) async { /// 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 companionRemote = context.read<CompanionRemoteProvider>();
if (companionRemote.isHostServerRunning) return true;
try {
// The host is an app-level service, not bound to this widget: everything
// it needs is captured up front, so an unmount mid-await must not abort a
// start the user asked for. Hence no `context.mounted` guards below.
final connections = context.read<ConnectionRegistry>(); final connections = context.read<ConnectionRegistry>();
final activeProfile = context.read<ActiveProfileProvider>(); final activeProfile = context.read<ActiveProfileProvider>();
final profileConnections = context.read<ProfileConnectionRegistry>(); final profileConnections = context.read<ProfileConnectionRegistry>();
@@ -27,7 +27,7 @@ Future<bool> startCompanionRemoteHost(BuildContext context) async {
profileConnections: profileConnections, profileConnections: profileConnections,
); );
final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id);
final ok = await companionRemote.ensureCryptoReady( return companionRemote.ensureCryptoReady(
home, home,
connections: connections, connections: connections,
activeProfile: activeProfile, activeProfile: activeProfile,
@@ -35,7 +35,14 @@ Future<bool> startCompanionRemoteHost(BuildContext context) async {
identity: identity, identity: identity,
plexHomeForConnection: plexHome.materializePlexHomeForConnection, plexHomeForConnection: plexHome.materializePlexHomeForConnection,
); );
if (!ok) return false; }
Future<bool> startCompanionRemoteHost(BuildContext context) async {
final companionRemote = context.read<CompanionRemoteProvider>();
if (companionRemote.isHostServerRunning) return true;
try {
if (!await ensureCompanionRemoteCryptoFromContext(context)) return false;
await companionRemote.startHostServer(); await companionRemote.startHostServer();
return companionRemote.isHostServerRunning; return companionRemote.isHostServerRunning;
+17 -40
View File
@@ -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;
} }
} }
+109 -117
View File
@@ -1323,46 +1323,23 @@ class DownloadManagerService {
); );
} }
var artworkSettled = !queueItem.downloadArtwork; final settled = await _runSupplementaryDownloads(
if (queueItem.downloadArtwork) {
final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client);
final chapterArtworkSettled = metadata.serverId == null
? false
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
}
var subtitlesSettled = !queueItem.downloadSubtitles;
if (queueItem.downloadSubtitles) {
try {
final resolution = await client.resolveDownload(
metadata,
mediaIndex: record?.mediaIndex ?? 0,
mediaSourceId: record?.mediaSourceId,
);
if (resolution.externalSubtitlesResolved) {
subtitlesSettled = await _downloadSubtitles(
globalKey, globalKey,
metadata, metadata,
resolution.externalSubtitles,
client, client,
downloadArtwork: queueItem.downloadArtwork,
downloadSubtitles: queueItem.downloadSubtitles,
record: record,
showYear: showYear, showYear: showYear,
); );
} else { if (settled.artwork && settled.subtitles) {
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
? false
: await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client);
artworkSettled = itemArtworkSettled && chapterArtworkSettled;
}
if (downloadSubtitles) {
var subtitles = ctx?.subtitles;
if (subtitles == null) {
try {
final resolution = await client.resolveDownload(
metadata, metadata,
mediaIndex: existingCheck.mediaIndex, client,
mediaSourceId: existingCheck.mediaSourceId, downloadArtwork: downloadArtwork,
downloadSubtitles: downloadSubtitles,
record: existingCheck,
showYear: ctx?.showYear,
preresolvedSubtitles: ctx?.subtitles,
); );
if (resolution.externalSubtitlesResolved) { artworkSettled = settled.artwork;
subtitles = resolution.externalSubtitles; subtitlesSettled = settled.subtitles;
}
} 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
+77
View File
@@ -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);
}
+40 -58
View File
@@ -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;
+15
View File
@@ -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,
+43 -58
View File
@@ -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,
),
pageSize: _pagedListPageSize,
abort: abort,
); );
abort?.throwIfAborted();
if (page.items.isEmpty) break;
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