Merge the deduplication and dead-code removal pass

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

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

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