From 352b88109b12ae36fd155f1eee71f5ef80cbcdf3 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sun, 26 Jul 2026 03:45:07 +0200 Subject: [PATCH] refactor: extract shared mixins and helpers, drop dead abstractions Introduces shared seams for paginated views, D-pad reorder, media control routing, async singletons and the device method channel, then points the open-coded copies at them. Also removes unused models and duplicated provider/server plumbing, folds the twice-implemented artifact store in the server, and factors the repeated Flutter toolchain prologue in CI into a composite action. --- .github/actions/setup-flutter-git/action.yml | 33 + .github/workflows/build.yml | 47 +- .github/workflows/ci.yml | 75 +- .github/workflows/e2e.yml | 10 +- .../edde746/plezy/exoplayer/ExoPlayerCore.kt | 17 +- .../plezy/exoplayer/ExoPlayerPlugin.kt | 63 +- .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 17 +- .../edde746/plezy/shared/SurfacePlayerCore.kt | 27 + lib/connection/connection_bootstrap.dart | 12 +- lib/database/app_database.dart | 443 ++++-------- lib/database/download_operations.dart | 58 -- lib/database/tables.dart | 2 +- lib/focus/dpad_reorder_mixin.dart | 207 ++++++ lib/focus/focusable_chip_mixin.dart | 36 +- lib/main.dart | 49 +- lib/media/library_query.dart | 31 + lib/media/server_capabilities.dart | 74 +- .../jellyfin_metadata_edit_adapter.dart | 155 +---- lib/metadata_edit/metadata_edit_models.dart | 132 +++- .../plex_metadata_edit_adapter.dart | 127 +--- lib/mixins/deletion_aware.dart | 19 + lib/mixins/paginated_item_loader.dart | 37 - lib/mixins/standard_paginated_view.dart | 74 ++ lib/models/livetv_channel.dart | 5 +- lib/models/media_provider_info.dart | 71 -- lib/models/media_provider_info.g.dart | 38 -- lib/models/mixins/multi_server_fields.dart | 15 - lib/models/shader_preset.dart | 123 ++-- lib/models/trakt/trakt_scrobble_request.dart | 29 +- lib/profiles/plex_home_service.dart | 12 +- lib/profiles/profile_activation.dart | 7 +- lib/profiles/profile_connection_cleanup.dart | 430 +++++------- lib/profiles/profile_selection_policy.dart | 14 + lib/providers/companion_remote_provider.dart | 202 +++--- lib/providers/discover_provider.dart | 66 +- lib/providers/download_metadata_store.dart | 2 +- lib/providers/download_provider.dart | 44 +- lib/providers/multi_server_provider.dart | 48 +- lib/providers/watch_state_store.dart | 47 +- lib/screens/actor_media_screen.dart | 37 +- lib/screens/auth_screen.dart | 4 +- .../base_media_list_detail_screen.dart | 40 +- lib/screens/collection_detail_screen.dart | 87 +-- lib/screens/discover_screen.dart | 139 +--- .../focusable_detail_screen_mixin.dart | 64 +- lib/screens/hub_detail_screen.dart | 37 +- .../libraries/content_state_builder.dart | 50 ++ lib/screens/libraries/folder_tree_view.dart | 50 +- .../libraries/tabs/base_library_tab.dart | 14 + .../libraries/tabs/library_browse_tab.dart | 34 +- .../tabs/library_collections_tab.dart | 34 +- .../libraries/tabs/library_playlists_tab.dart | 34 +- .../tabs/library_recommended_tab.dart | 72 +- lib/screens/livetv/live_tv_screen.dart | 77 +-- .../livetv/reorder_favorites_sheet.dart | 180 +---- lib/screens/main_screen.dart | 229 +++---- lib/screens/media_detail_screen.dart | 42 +- lib/screens/metadata_edit_screen.dart | 25 +- lib/screens/music/album_detail_screen.dart | 40 +- lib/screens/music/artist_detail_screen.dart | 68 +- .../playlist/playlist_detail_screen.dart | 109 +-- .../profile/borrow_connection_screen.dart | 47 +- lib/screens/profile/pin_entry_dialog.dart | 9 + .../profile/profile_detail_screen.dart | 63 +- .../profile/profile_switch_screen.dart | 7 +- lib/screens/profile/profile_teardown.dart | 30 +- .../settings/add_connection_screen.dart | 8 +- lib/screens/settings/add_jellyfin_screen.dart | 16 +- .../settings/add_plex_account_screen.dart | 5 +- .../settings/appearance_settings_screen.dart | 73 +- .../edit_jellyfin_connection_screen.dart | 8 +- .../settings/keyboard_shortcuts_screen.dart | 6 +- .../settings/playback_settings_screen.dart | 20 +- .../settings/services_settings_screen.dart | 104 +-- lib/screens/settings/settings_screen.dart | 212 +++--- lib/screens/settings/settings_utils.dart | 26 + .../settings/subtitle_styling_screen.dart | 12 +- .../tracker_library_filter_screen.dart | 4 +- .../settings/tracker_service_info.dart | 94 +++ .../settings/tracker_settings_screen.dart | 7 +- .../settings/trakt_settings_screen.dart | 2 +- lib/screens/video_player/parts/build.dart | 1 - lib/screens/video_player/parts/pip.dart | 7 +- .../video_player/parts/playback_services.dart | 2 +- lib/screens/video_player/parts/shader.dart | 77 +-- .../widgets/player_prompt_overlays.dart | 283 ++++---- lib/screens/video_player_screen.dart | 44 +- .../companion_remote_host_controller.dart | 51 +- lib/services/device_performance.dart | 57 +- lib/services/download_manager_service.dart | 240 ++++--- lib/services/downloaded_video_source.dart | 77 +++ lib/services/fullscreen_state_manager.dart | 98 ++- lib/services/jellyfin_client.dart | 15 + .../jellyfin_client/parts/browse.dart | 103 ++- .../jellyfin_client/parts/collections.dart | 34 +- .../jellyfin_client/parts/file_info.dart | 2 +- .../parts/images_downloads.dart | 3 +- .../jellyfin_client/parts/live_tv.dart | 5 +- .../jellyfin_client/parts/metadata_edit.dart | 5 +- lib/services/jellyfin_client/parts/music.dart | 6 +- .../jellyfin_client/parts/playback.dart | 149 ++-- .../jellyfin_client/parts/playlists.dart | 51 +- .../jellyfin_client/parts/watch_state.dart | 5 +- lib/services/jellyfin_endpoint_discovery.dart | 10 + .../jellyfin_sequential_launcher.dart | 1 + lib/services/keyboard_shortcuts_service.dart | 177 ++--- lib/services/macos_window_service.dart | 63 +- .../media_control_router.dart | 14 +- .../media_list_playback_launcher.dart | 11 + lib/services/multi_server_manager.dart | 249 +++---- .../music/music_playback_service_impl.dart | 144 ++-- lib/services/play_queue_launcher.dart | 21 +- .../playback_initialization_service.dart | 52 +- .../playback_initialization_types.dart | 33 + lib/services/playlist_items_loader.dart | 49 +- lib/services/plex_auth_service.dart | 21 +- lib/services/plex_client.dart | 272 ++++---- .../plex_client/parts/collections.dart | 32 +- lib/services/plex_client/parts/live_tv.dart | 41 +- .../plex_client/parts/metadata_edit.dart | 28 +- .../plex_client/parts/play_queues.dart | 19 +- lib/services/plex_client/parts/playlists.dart | 48 +- lib/services/seerr/seerr_http_client.dart | 14 +- lib/services/settings_export_service.dart | 118 +--- lib/services/settings_service.dart | 137 ++-- lib/services/shader_asset_loader.dart | 7 +- lib/services/shortcut_action.dart | 139 ++++ lib/services/storage_service.dart | 120 ++-- lib/services/system_shelf_service.dart | 135 ++-- .../trackers/anilist/anilist_tracker.dart | 10 - .../trackers/anime_list_tracker_base.dart | 6 +- lib/services/trackers/mal/mal_tracker.dart | 10 - .../trackers/simkl/simkl_tracker.dart | 4 - lib/services/trackers/tracker.dart | 9 +- .../trackers/tracker_coordinator.dart | 97 +-- lib/services/trackers/tracker_session.dart | 5 +- .../trackers/tracker_session_utils.dart | 6 - lib/services/trakt/trakt_client.dart | 4 +- .../trakt/trakt_scrobble_service.dart | 2 +- lib/services/video_pip_manager.dart | 20 +- lib/services/watch_state_resolver.dart | 14 + lib/theme/mono_tokens.dart | 11 + lib/utils/android_exit_diagnostics.dart | 9 +- lib/utils/async_singleton.dart | 62 ++ lib/utils/device_channel.dart | 5 + lib/utils/download_utils.dart | 44 ++ lib/utils/hub_icons.dart | 66 ++ lib/utils/media_event_keys.dart | 36 + lib/utils/media_server_http_client.dart | 40 +- lib/utils/music_navigation.dart | 45 ++ lib/utils/platform_detector.dart | 51 +- lib/utils/url_utils.dart | 30 + .../screens/watch_together_screen.dart | 140 ++-- .../services/watch_together_peer_service.dart | 22 +- lib/widgets/catalog_source_logo.dart | 37 +- .../companion_remote/discovery_view.dart | 33 +- lib/widgets/download_tree_view.dart | 305 +++------ lib/widgets/focusable_filter_chip.dart | 18 - lib/widgets/focusable_tab_chip.dart | 18 - lib/widgets/library_management_sheet.dart | 300 ++------ lib/widgets/media_context_menu.dart | 47 +- lib/widgets/rating_bottom_sheet.dart | 94 +-- lib/widgets/setting_tile.dart | 205 +++--- lib/widgets/settings_section.dart | 9 +- .../desktop_video_controls.dart | 58 +- .../video_controls/mobile_video_controls.dart | 51 +- .../models/track_controls_state.dart | 2 - .../video_controls/parts/key_events.dart | 88 +-- .../video_controls/parts/track_controls.dart | 1 - .../sheets/sheet_selection_column.dart | 93 +++ .../video_controls/sheets/track_sheet.dart | 509 ++++++-------- .../sheets/version_quality_sheet.dart | 126 ++-- .../sheets/video_settings_sheet.dart | 176 ++--- .../video_controls/widgets/content_strip.dart | 303 ++++----- .../widgets/content_strip_panel.dart | 52 ++ .../widgets/track_chapter_controls.dart | 174 ++--- linux/runner/mpv/mpv_player.cc | 211 ++---- linux/runner/mpv/mpv_player.h | 10 +- linux/runner/mpv/mpv_player_lifecycle_test.cc | 4 +- scripts/check_build_workflow.py | 60 +- scripts/check_update_packages_workflow.py | 10 +- scripts/check_workflow_action_pins.py | 335 +-------- scripts/check_workflow_security.py | 49 +- scripts/ci_checks.sh | 24 +- scripts/ci_guard_checks.sh | 30 + scripts/workflow_yaml.py | 358 ++++++++++ server/artifact_store.go | 434 ++++++++++++ server/main.go | 642 ++---------------- server/main_test.go | 48 +- shared/mpv/mpv_player_common.h | 241 +++++++ shared/mpv/mpv_player_common_test.cpp | 91 +++ test/database/app_database_test.dart | 5 +- test/database/download_operations_test.dart | 111 +-- test/mixins/paginated_item_loader_test.dart | 56 +- .../profile_connection_cleanup_test.dart | 86 +-- test/providers/download_provider_test.dart | 1 + test/providers/watch_state_store_test.dart | 9 +- .../download_manager_service_test.dart | 1 + .../media_control_router_test.dart | 6 +- .../offline_watch_sync_service_test.dart | 1 + test/startup_bootstrap_test.dart | 2 + test/test_helpers/download_fixtures.dart | 70 ++ test/test_helpers/download_fixtures_test.dart | 126 ++++ test/widgets/video_settings_sheet_test.dart | 5 +- .../src/lib/components/DownloadButtons.svelte | 12 +- website/src/lib/components/FAQ.svelte | 50 +- website/src/lib/components/Features.svelte | 53 +- website/src/lib/components/Reviews.svelte | 51 +- website/src/lib/components/Screenshots.svelte | 53 +- .../src/lib/components/SectionHeader.svelte | 60 ++ website/src/lib/content/downloads.ts | 47 +- .../src/lib/content/software_app_offers.ts | 8 +- website/src/routes/+error.svelte | 65 +- website/src/routes/+page.server.ts | 5 +- website/src/routes/layout.css | 81 +++ website/src/routes/scan/+page.svelte | 71 +- windows/runner/mpv/mpv_player.cpp | 170 +---- 217 files changed, 6813 insertions(+), 8773 deletions(-) create mode 100644 .github/actions/setup-flutter-git/action.yml create mode 100644 android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt create mode 100644 lib/focus/dpad_reorder_mixin.dart create mode 100644 lib/mixins/standard_paginated_view.dart delete mode 100644 lib/models/media_provider_info.dart delete mode 100644 lib/models/media_provider_info.g.dart delete mode 100644 lib/models/mixins/multi_server_fields.dart create mode 100644 lib/profiles/profile_selection_policy.dart create mode 100644 lib/screens/settings/tracker_service_info.dart create mode 100644 lib/services/downloaded_video_source.dart rename lib/{screens/video_player => services}/media_control_router.dart (80%) create mode 100644 lib/services/shortcut_action.dart create mode 100644 lib/utils/async_singleton.dart create mode 100644 lib/utils/device_channel.dart create mode 100644 lib/utils/hub_icons.dart create mode 100644 lib/utils/media_event_keys.dart create mode 100644 lib/widgets/video_controls/sheets/sheet_selection_column.dart create mode 100644 lib/widgets/video_controls/widgets/content_strip_panel.dart create mode 100644 scripts/ci_guard_checks.sh create mode 100644 scripts/workflow_yaml.py create mode 100644 server/artifact_store.go rename test/{screens/video_player => services}/media_control_router_test.dart (94%) create mode 100644 test/test_helpers/download_fixtures.dart create mode 100644 test/test_helpers/download_fixtures_test.dart create mode 100644 website/src/lib/components/SectionHeader.svelte diff --git a/.github/actions/setup-flutter-git/action.yml b/.github/actions/setup-flutter-git/action.yml new file mode 100644 index 00000000..573d4fca --- /dev/null +++ b/.github/actions/setup-flutter-git/action.yml @@ -0,0 +1,33 @@ +name: Set up Flutter from git +description: >- + Clone the pinned Flutter SDK from its release tag and put it on PATH, for + runners without a published archive. Flutter ships no windows-arm64 SDK, so + subosito/flutter-action cannot resolve the release for arm64 (no arm64 entry + in the stable manifest) and `channel: master` would clone master HEAD, whose + engine is not the patched revision install-patched-engine.ps1 asserts + (4c525dac). This file is the only place that pin lives: the tag is fetched so + the SDK reports its own version, then verified against the immutable commit, + so a moved tag fails the job instead of quietly changing SDKs. + +runs: + using: composite + steps: + - name: Clone Flutter from its immutable commit + shell: pwsh + run: | + $version = "3.44.0" + $expectedCommit = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" + $root = "$env:RUNNER_TEMP\flutter" + git init $root + git -C $root remote add origin https://github.com/flutter/flutter.git + git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}" + git -C $root checkout --detach "refs/tags/$version" + $actualCommit = git -C $root rev-parse HEAD + if ($LASTEXITCODE -ne 0 -or $actualCommit -ne $expectedCommit) { + throw "Flutter $version resolved to $actualCommit, expected $expectedCommit" + } + "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 + & "$root\bin\flutter.bat" --version + if ($LASTEXITCODE -ne 0) { + throw "Unable to bootstrap the Flutter SDK" + } diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index a6dc8c58..c5220d2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -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,27 +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 (git tag) + - name: Set up Flutter from its pinned commit if: matrix.flutter_setup == 'git' - # Flutter publishes no windows-arm64 SDK archive, so subosito can't - # resolve 3.44.0 for arm64: the stable manifest has no arm64 entry, and - # `channel: master` would git-clone master HEAD (whose engine != our - # patched 3.44.0). Clone the 3.44.0 tag directly to get engine rev - # 4c525dac, which install-patched-engine.ps1 asserts before swapping. - shell: pwsh - run: | - $root = "$env:RUNNER_TEMP\flutter" - git init $root - git -C $root remote add origin https://github.com/flutter/flutter.git - git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42 - git -C $root checkout --detach FETCH_HEAD - "$root\bin" | Out-File -FilePath $env:GITHUB_PATH -Append -Encoding utf8 - & "$root\bin\flutter.bat" --version + uses: ./.github/actions/setup-flutter-git - name: Cache Pub dependencies uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6 @@ -477,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' @@ -510,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 @@ -623,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 @@ -680,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 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fb7a75f2..26a2664c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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,30 +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/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: | @@ -130,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 @@ -187,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 @@ -281,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 @@ -355,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 @@ -478,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 @@ -566,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 diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index 26ca7c52..2e7ded8f 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -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() diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt index 5543ab1e..03880b97 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerCore.kt @@ -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" @@ -3400,7 +3401,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 { @@ -3493,7 +3494,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 @@ -3509,7 +3510,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { } } - fun updateFrame() { + override fun updateFrame() { if (disposing) return activity.runOnUiThread { if (disposing) return@runOnUiThread @@ -3524,15 +3525,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, @@ -3548,7 +3549,7 @@ class ExoPlayerCore(private val activity: Activity) : Player.Listener { mgr.setVideoFrameRate(fps, videoDurationMs, extraDelayMs, videoWidth, videoHeight, onComplete) } - fun clearVideoFrameRate() { + override fun clearVideoFrameRate() { frameRateManager?.clearVideoFrameRate() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt index 0fe6cb2c..07d0f408 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/exoplayer/ExoPlayerPlugin.kt @@ -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 @@ -45,6 +46,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. @@ -949,20 +954,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) } @@ -974,51 +971,31 @@ class ExoPlayerPlugin : val videoHeight = call.argument("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 - 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) - } + val core = activeSurfaceCore + if (core == null) { + result.success(false) + 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) } @@ -1228,11 +1205,7 @@ class ExoPlayerPlugin : fun onPipModeChanged(isInPipMode: Boolean) { activity?.runOnUiThread { - if (usingMpvFallback) { - mpvCore?.onPipModeChanged(isInPipMode) - } else { - playerCore?.onPipModeChanged(isInPipMode) - } + activeSurfaceCore?.onPipModeChanged(isInPipMode) } } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt index c81a093a..0d6b32fb 100644 --- a/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt +++ b/android/app/src/main/kotlin/com/edde746/plezy/mpv/MpvPlayerCore.kt @@ -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() } @@ -1042,7 +1043,7 @@ class MpvPlayerCore private constructor( } } - fun setVisible(visible: Boolean) { + override fun setVisible(visible: Boolean) { // Audio-only: no render layer to show or hide — tolerated no-op. if (audioOnly || disposing) return runOnMain { @@ -1067,11 +1068,11 @@ class MpvPlayerCore private constructor( } } - fun onPipModeChanged(isInPipMode: Boolean) { + override fun onPipModeChanged(isInPipMode: Boolean) { // MPV handles aspect ratio internally via its own surface management } - fun updateFrame() { + override fun updateFrame() { // Audio-only: no surface to refresh — tolerated no-op. if (audioOnly || disposing) return runOnMain { @@ -1106,7 +1107,7 @@ class MpvPlayerCore private constructor( // Frame Rate Matching - fun setVideoFrameRate( + override fun setVideoFrameRate( fps: Float, videoDurationMs: Long, extraDelayMs: Long, @@ -1128,7 +1129,7 @@ class MpvPlayerCore private constructor( } } - fun clearVideoFrameRate() { + override fun clearVideoFrameRate() { frameRateManager?.clearVideoFrameRate() } diff --git a/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt b/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt new file mode 100644 index 00000000..bac39951 --- /dev/null +++ b/android/app/src/main/kotlin/com/edde746/plezy/shared/SurfacePlayerCore.kt @@ -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 + ) +} diff --git a/lib/connection/connection_bootstrap.dart b/lib/connection/connection_bootstrap.dart index 92c0c377..4d7ed992 100644 --- a/lib/connection/connection_bootstrap.dart +++ b/lib/connection/connection_bootstrap.dart @@ -29,7 +29,7 @@ class ConnectionBootstrap { required this.profileRegistry, Future> Function(String accountToken)? plexHomeUserFetcher, Future> Function(String accountToken)? plexUserInfoFetcher, - }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? _fetchPlexHomeUsers, + }) : _plexHomeUserFetcher = plexHomeUserFetcher ?? fetchPlexHomeUsers, _plexUserInfoFetcher = plexUserInfoFetcher ?? _fetchPlexUserInfo; final StorageService storage; @@ -283,16 +283,6 @@ class ConnectionBootstrap { } } -Future> _fetchPlexHomeUsers(String accountToken) async { - final auth = await PlexAuthService.create(); - try { - final home = await auth.getHomeUsers(accountToken); - return home.users; - } finally { - auth.dispose(); - } -} - Future> _fetchPlexUserInfo(String accountToken) async { final auth = await PlexAuthService.create(); try { diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index 6c54a441..fc46363f 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -237,197 +237,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> _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 _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(row, 'kind'); - final configJson = _requiredRecoveryValue(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) { 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(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(row, 'id')), - kind: Value(_requiredRecoveryValue(row, 'kind')), - displayName: Value(_requiredRecoveryValue(row, 'displayName')), - configJson: Value(_requiredRecoveryValue(row, 'configJson')), - isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - lastAuthenticatedAt: Value(_nullableRecoveryValue(row, 'lastAuthenticatedAt')), - ), - ]; - final profileCompanions = [ - for (final row in profileRows) - ProfilesCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - kind: Value(_requiredRecoveryValue(row, 'kind')), - displayName: Value(_requiredRecoveryValue(row, 'displayName')), - avatarThumbUrl: Value(_nullableRecoveryValue(row, 'avatarThumbUrl')), - configJson: Value(_requiredRecoveryValue(row, 'configJson')), - sortOrder: Value(_requiredRecoveryValue(row, 'sortOrder')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), - ), - ]; - final joinCompanions = [ - for (final row in joinRows) - ProfileConnectionsCompanion( - profileId: Value(_requiredRecoveryValue(row, 'profileId')), - connectionId: Value(_requiredRecoveryValue(row, 'connectionId')), - userToken: Value(_requiredRecoveryValue(row, 'userToken')), - userIdentifier: Value(_requiredRecoveryValue(row, 'userIdentifier')), - isDefault: Value(_requiredRecoveryValue(row, 'isDefault')), - tokenAcquiredAt: Value(_nullableRecoveryValue(row, 'tokenAcquiredAt')), - lastUsedAt: Value(_nullableRecoveryValue(row, 'lastUsedAt')), - ), - ]; - final pendingCompanions = [ - for (final row in pendingRows) - OfflineWatchProgressCompanion( - id: Value(_requiredRecoveryValue(row, 'id')), - profileId: Value(_nullableRecoveryValue(row, 'profileId')), - serverId: Value(_requiredRecoveryValue(row, 'serverId')), - clientScopeId: Value(_nullableRecoveryValue(row, 'clientScopeId')), - ratingKey: Value(_requiredRecoveryValue(row, 'ratingKey')), - globalKey: Value(_requiredRecoveryValue(row, 'globalKey')), - actionType: Value(_requiredRecoveryValue(row, 'actionType')), - viewOffset: Value(_nullableRecoveryValue(row, 'viewOffset')), - duration: Value(_nullableRecoveryValue(row, 'duration')), - shouldMarkWatched: Value(_requiredRecoveryValue(row, 'shouldMarkWatched')), - createdAt: Value(_requiredRecoveryValue(row, 'createdAt')), - updatedAt: Value(_requiredRecoveryValue(row, 'updatedAt')), - syncAttempts: Value(_requiredRecoveryValue(row, 'syncAttempts')), - lastError: Value(_nullableRecoveryValue(row, 'lastError')), - ), - ]; - await transaction(() async { // Recovery completion (the durable marker removal) is deliberately // separate from this transaction. Replace the snapshot-owned rows so a @@ -437,54 +296,57 @@ class AppDatabase extends _$AppDatabase { await delete(profiles).go(); await delete(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> _decodeRecoveryRows( + static List _decodeRecoveryRows( Map group, String key, - Set expectedKeys, + T Function(Map 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 && - 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) _decodeRecoveryRow(row, fromJson) else throw _invalidRecoveryImage, ]; } - static T _requiredRecoveryValue(Map 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( + Map row, + T Function(Map 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(Map 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 => 19; @@ -649,89 +511,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 ( - 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 - '''); + await customStatement( + _rescopePinnedPlexMetadataStatement( + namespaceExpression: "'/~plex-transfer:'", + ownerFilter: '''AND NOT EXISTS ( + SELECT 1 + FROM download_owners AS owner + WHERE owner.global_key = metadata.global_key + )''', + ), + ); final transferRows = await customSelect(''' SELECT cache_key, data @@ -920,10 +720,6 @@ class AppDatabase extends _$AppDatabase { } } - Expression _clientScopePredicate(GeneratedColumn column, String? clientScopeId) { - return clientScopeId == null ? column.isNull() : column.equals(clientScopeId); - } - Expression _nullableTextPredicate(GeneratedColumn column, String? value) { return value == null ? column.isNull() : column.equals(value); } @@ -974,7 +770,7 @@ 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)]); } @@ -1083,7 +879,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)])) @@ -1145,7 +941,7 @@ class AppDatabase extends _$AppDatabase { (t) => t.globalKey.equals(globalKey) & _nullableTextPredicate(t.profileId, profileId) & - _clientScopePredicate(t.clientScopeId, clientScopeId), + _nullableTextPredicate(t.clientScopeId, clientScopeId), )) .go(); @@ -1287,29 +1083,21 @@ class AppDatabase extends _$AppDatabase { } } - Future updateSyncRuleCount(String globalKey, int episodeCount) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(episodeCount: Value(episodeCount))); + Future _writeSyncRule(String globalKey, SyncRulesCompanion values) async { + await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write(values); } - Future updateSyncRuleFilter(String globalKey, String downloadFilter) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(downloadFilter: Value(downloadFilter))); - } + Future updateSyncRuleCount(String globalKey, int episodeCount) => + _writeSyncRule(globalKey, SyncRulesCompanion(episodeCount: Value(episodeCount))); - Future updateSyncRuleEnabled(String globalKey, bool enabled) async { - await (update( - syncRules, - )..where((t) => t.globalKey.equals(globalKey))).write(SyncRulesCompanion(enabled: Value(enabled))); - } + Future updateSyncRuleFilter(String globalKey, String downloadFilter) => + _writeSyncRule(globalKey, SyncRulesCompanion(downloadFilter: Value(downloadFilter))); - Future updateSyncRuleLastExecuted(String globalKey) async { - await (update(syncRules)..where((t) => t.globalKey.equals(globalKey))).write( - SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch)), - ); - } + Future updateSyncRuleEnabled(String globalKey, bool enabled) => + _writeSyncRule(globalKey, SyncRulesCompanion(enabled: Value(enabled))); + + Future updateSyncRuleLastExecuted(String globalKey) => + _writeSyncRule(globalKey, SyncRulesCompanion(lastExecutedAt: Value(DateTime.now().millisecondsSinceEpoch))); Future deleteSyncRule(String globalKey) async { await (delete(syncRules)..where((t) => t.globalKey.equals(globalKey))).go(); @@ -1331,6 +1119,57 @@ class AppDatabase extends _$AppDatabase { } } +/// Builds the v17 statement that re-keys pinned legacy Plex metadata rows into +/// a scoped cache namespace. +/// +/// The owned and the ownerless branch run the same operation over the same +/// `download_metadata_ids` set and differ only in three spots: the expression +/// spliced into the new `cache_key` ([namespaceExpression]), an optional join +/// that exposes the owning profile ([ownerJoin]), and an optional extra +/// predicate that keeps each branch to its own rows ([ownerFilter]). +String _rescopePinnedPlexMetadataStatement({ + required String namespaceExpression, + String ownerJoin = '', + String ownerFilter = '', +}) => + ''' + WITH download_metadata_ids AS ( + SELECT global_key, server_id, rating_key AS metadata_id + FROM downloaded_media + UNION + SELECT global_key, server_id, parent_rating_key AS metadata_id + FROM downloaded_media + WHERE parent_rating_key IS NOT NULL + AND parent_rating_key != '' + UNION + SELECT global_key, server_id, grandparent_rating_key AS metadata_id + FROM downloaded_media + WHERE grandparent_rating_key IS NOT NULL + AND grandparent_rating_key != '' + ) + INSERT INTO api_cache (cache_key, data, pinned, cached_at) + SELECT DISTINCT + metadata.server_id + || $namespaceExpression + || substr(source.cache_key, length(metadata.server_id) + 2), + source.data, + source.pinned, + source.cached_at + FROM download_metadata_ids AS metadata + $ownerJoin + JOIN api_cache AS source + ON source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id + OR source.cache_key = + metadata.server_id || ':/library/metadata/' || metadata.metadata_id || '/children' + WHERE source.pinned = 1 + $ownerFilter + ON CONFLICT(cache_key) DO UPDATE SET + data = excluded.data, + pinned = excluded.pinned, + cached_at = excluded.cached_at +'''; + Future _resolveProductionDatabaseFile() async { final dbFolder = (Platform.isAndroid || Platform.isIOS) ? await getApplicationDocumentsDirectory() diff --git a/lib/database/download_operations.dart b/lib/database/download_operations.dart index eb22c743..94d47e2e 100644 --- a/lib/database/download_operations.dart +++ b/lib/database/download_operations.dart @@ -271,64 +271,6 @@ extension DownloadDatabaseOperations on AppDatabase { } } - Future 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(serverId), - Variable(clientScopeId), - Variable(ratingKey), - Variable(globalKey), - Variable(type), - Variable(parentRatingKey), - Variable(grandparentRatingKey), - Variable(status), - Variable(mediaIndex), - Variable(mediaSourceId), - ], - updates: {downloadedMedia}, - ); - } - Future addToQueue({ required String mediaGlobalKey, int priority = 0, diff --git a/lib/database/tables.dart b/lib/database/tables.dart index a144f17c..68477fa0 100644 --- a/lib/database/tables.dart +++ b/lib/database/tables.dart @@ -197,7 +197,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)(); diff --git a/lib/focus/dpad_reorder_mixin.dart b/lib/focus/dpad_reorder_mixin.dart new file mode 100644 index 00000000..38e07333 --- /dev/null +++ b/lib/focus/dpad_reorder_mixin.dart @@ -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 on State { + /// 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? _originalOrder; + bool _backKeyDownSeen = false; + + /// The list being reordered. Mutated in place while moving and replaced + /// wholesale when a move is cancelled. + List get reorderItems; + set reorderItems(List 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.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.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(); + } +} diff --git a/lib/focus/focusable_chip_mixin.dart b/lib/focus/focusable_chip_mixin.dart index 9757acec..3067b44d 100644 --- a/lib/focus/focusable_chip_mixin.dart +++ b/lib/focus/focusable_chip_mixin.dart @@ -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` 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 on State { 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 on State { /// 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() { diff --git a/lib/main.dart b/lib/main.dart index 1237a6ed..73c2c501 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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'; @@ -1069,8 +1070,7 @@ class _MainAppState extends State with WidgetsBindingObserver { pinPrompt: _rootPinPrompt, shouldDeferInitialBind: (_) async { final settings = await SettingsService.getInstance(); - return settings.read(SettingsService.requireProfileSelectionOnOpen) && - activeProfile.hasMultipleProfiles; + return activeProfile.requiresSelectionOnOpen(settings); }, ); }, @@ -1211,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 @@ -1298,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 createState() => _OrientationAwareSetupState(); -} - -class _OrientationAwareSetupState extends State { - @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, @@ -1354,6 +1328,15 @@ class _SetupScreenState extends State 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); } @@ -1416,12 +1399,12 @@ class _SetupScreenState extends State 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'}'); } @@ -1559,9 +1542,7 @@ class _SetupScreenState extends State 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) { diff --git a/lib/media/library_query.dart b/lib/media/library_query.dart index 73be12bb..51f2dc14 100644 --- a/lib/media/library_query.dart +++ b/lib/media/library_query.dart @@ -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'; @@ -84,3 +85,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> drainPages( + Future> Function(int start, int size) fetchPage, { + required int pageSize, + AbortController? abort, + bool stopOnShortPage = false, +}) async { + final all = []; + 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; +} diff --git a/lib/media/server_capabilities.dart b/lib/media/server_capabilities.dart index 485de4ef..993251a4 100644 --- a/lib/media/server_capabilities.dart +++ b/lib/media/server_capabilities.dart @@ -212,55 +212,35 @@ class ServerCapabilities { 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, + serverSidePlayQueue: serverSidePlayQueue, + serverSidePlaylists: serverSidePlaylists, + liveTv: liveTv, + liveTvDvr: liveTvDvr, + subtitleSearch: subtitleSearch, 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, + serverSideSync: serverSideSync, + richHubs: richHubs, + numericUserRating: numericUserRating, + userFavorites: userFavorites, + continueWatchingRemoval: continueWatchingRemoval, + externalSubtitleSearch: externalSubtitleSearch, + trackPreferencePersistence: trackPreferencePersistence, + endpointFailover: endpointFailover, + offlineWatchQueue: offlineWatchQueue, + discordRpc: discordRpc, + richMetadataEdit: richMetadataEdit, + alphaBar: alphaBar, + scrubThumbnails: scrubThumbnails, + folderGrouping: folderGrouping, + lyrics: lyrics, + instantMix: instantMix, + audioTranscoding: audioTranscoding, ); } } diff --git a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart index 55e40b0f..ea40cbad 100644 --- a/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart +++ b/lib/metadata_edit/jellyfin_metadata_edit_adapter.dart @@ -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 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.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('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('tagline')); - final existing = _stringList(dto['Taglines']); + final existing = metadataStringList(dto['Taglines']); dto['Taglines'] = tagline == null ? [] : [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 applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { - return applyArtworkFromUrl(draft, field, option.sourceUrl); - } - @override Future 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 values, MediaItem item) { @@ -194,29 +192,6 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { values['artwork:Logo'] = item.clearLogoPath; } - List _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 _tagFields(MediaKind kind) { MetadataEditField tag(String id, String label) => MetadataEditField(id: id, label: label, type: MetadataEditFieldType.stringList); @@ -234,100 +209,20 @@ class JellyfinMetadataEditAdapter extends MetadataEditAdapter { }; } - List _artworkFields(MediaKind kind) { - final fields = [ - // 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 _artworkFields(MediaKind kind) => + metadataArtworkFields(kind, posterKey: 'Primary', backdropKey: 'Backdrop', logoKey: 'Logo'); void _setChangedString(Map dto, MetadataEditDraft draft, String fieldId, String dtoKey) { if (!draft.fieldChanged(fieldId)) return; dto[dtoKey] = metadataEmptyToNull(draft.value(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); - } - } - return draft.fieldChanged(fieldId); - } + /// 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]); } -List _stringList(Object? value) => metadataStringList(value); - Map _stringMap(Object? value) { if (value is! Map) return {}; return value.map((key, value) => MapEntry(key.toString(), value?.toString() ?? '')); diff --git a/lib/metadata_edit/metadata_edit_models.dart b/lib/metadata_edit/metadata_edit_models.dart index bc817887..9b647bc1 100644 --- a/lib/metadata_edit/metadata_edit_models.dart +++ b/lib/metadata_edit/metadata_edit_models.dart @@ -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> fetchArtwork(MetadataEditDraft draft, MetadataEditField field); - Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option); + Future applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { + return applyArtworkFromUrl(draft, field, option.sourceUrl); + } Future 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 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 metadataArtworkFields( + MediaKind kind, { + required String posterKey, + required String backdropKey, + required String logoKey, + String? squareKey, + Set logoKinds = const {MediaKind.movie, MediaKind.show}, +}) { + final fields = [ + // 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) { diff --git a/lib/metadata_edit/plex_metadata_edit_adapter.dart b/lib/metadata_edit/plex_metadata_edit_adapter.dart index 5557f33a..47ca56cf 100644 --- a/lib/metadata_edit/plex_metadata_edit_adapter.dart +++ b/lib/metadata_edit/plex_metadata_edit_adapter.dart @@ -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 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 applyArtworkOption(MetadataEditDraft draft, MetadataEditField field, MetadataArtworkOption option) { - return applyArtworkFromUrl(draft, field, option.sourceUrl); - } - @override Future applyArtworkFromUrl(MetadataEditDraft draft, MetadataEditField field, String url) async { final element = field.artwork?.key; @@ -213,29 +207,6 @@ class PlexMetadataEditAdapter extends MetadataEditAdapter { } } - List _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 _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 _artworkFields(MediaKind kind) { - final fields = [ - // 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, - ), - ); - } - 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 _artworkFields(MediaKind kind) => metadataArtworkFields( + kind, + posterKey: 'posters', + backdropKey: 'arts', + logoKey: 'clearLogos', + squareKey: 'squareArts', + logoKinds: const {MediaKind.movie, MediaKind.show, MediaKind.collection}, + ); List _advancedFields(MediaKind kind) { final fields = []; diff --git a/lib/mixins/deletion_aware.dart b/lib/mixins/deletion_aware.dart index 81f820cf..cb15c6b7 100644 --- a/lib/mixins/deletion_aware.dart +++ b/lib/mixins/deletion_aware.dart @@ -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 on State { 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 on WatchStateAware, DeletionAware { + @override + String? get deletionServerId => watchStateServerId; + + @override + Set? get deletionGlobalKeys => watchedGlobalKeys; + + @override + Set? get deletionIds => watchedIds; +} diff --git a/lib/mixins/paginated_item_loader.dart b/lib/mixins/paginated_item_loader.dart index f13f4272..1d729df6 100644 --- a/lib/mixins/paginated_item_loader.dart +++ b/lib/mixins/paginated_item_loader.dart @@ -104,43 +104,6 @@ mixin PaginatedItemLoader on State { 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 loadInitialPaginatedItems({ - required int pageSize, - required VoidCallback resetViewState, - required void Function(List 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 diff --git a/lib/mixins/standard_paginated_view.dart b/lib/mixins/standard_paginated_view.dart new file mode 100644 index 00000000..cbae10a4 --- /dev/null +++ b/lib/mixins/standard_paginated_view.dart @@ -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 on PaginatedItemLoader { + set items(List 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 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 on PaginatedItemLoader, ItemUpdatable { + @override + void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { + for (final entry in loadedItems.entries) { + if (entry.value.globalKey == sourceGlobalKey) { + loadedItems[entry.key] = updatedItem; + return; + } + } + } +} diff --git a/lib/models/livetv_channel.dart b/lib/models/livetv_channel.dart index 3ce2cb36..80cdaa73 100644 --- a/lib/models/livetv_channel.dart +++ b/lib/models/livetv_channel.dart @@ -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 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) diff --git a/lib/models/media_provider_info.dart b/lib/models/media_provider_info.dart deleted file mode 100644 index 5e68e4db..00000000 --- a/lib/models/media_provider_info.dart +++ /dev/null @@ -1,71 +0,0 @@ -import 'package:json_annotation/json_annotation.dart'; - -import '../utils/json_utils.dart'; - -part 'media_provider_info.g.dart'; - -List _parseFeatures(Object? raw) => parseFlexibleJsonList(raw, MediaProviderFeature.fromJson); - -List> _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 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 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> directories; - @JsonKey(name: 'Action', fromJson: _parseRawMaps) - final List> actions; - @JsonKey(name: 'Pivot', fromJson: _parseRawMaps) - final List> 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 json) => _$MediaProviderFeatureFromJson(json); -} diff --git a/lib/models/media_provider_info.g.dart b/lib/models/media_provider_info.g.dart deleted file mode 100644 index de23170f..00000000 --- a/lib/models/media_provider_info.g.dart +++ /dev/null @@ -1,38 +0,0 @@ -// GENERATED CODE - DO NOT MODIFY BY HAND - -part of 'media_provider_info.dart'; - -// ************************************************************************** -// JsonSerializableGenerator -// ************************************************************************** - -MediaProviderInfo _$MediaProviderInfoFromJson(Map 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 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']), -); diff --git a/lib/models/mixins/multi_server_fields.dart b/lib/models/mixins/multi_server_fields.dart deleted file mode 100644 index 1ba3e4a8..00000000 --- a/lib/models/mixins/multi_server_fields.dart +++ /dev/null @@ -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; -} diff --git a/lib/models/shader_preset.dart b/lib/models/shader_preset.dart index e55f9e88..14b01a1b 100644 --- a/lib/models/shader_preset.dart +++ b/lib/models/shader_preset.dart @@ -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', - type: ShaderPresetType.artcnn, - artcnnConfig: ArtCNNConfig(model: model, variant: variant), - ); - } + 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 _builtInPresets = List.unmodifiable([ none, diff --git a/lib/models/trakt/trakt_scrobble_request.dart b/lib/models/trakt/trakt_scrobble_request.dart index d15b8d8a..9be26a2d 100644 --- a/lib/models/trakt/trakt_scrobble_request.dart +++ b/lib/models/trakt/trakt_scrobble_request.dart @@ -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 toHistoryAddBody({String? watchedAt}) => switch (this) { + Map 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 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}, - ], - }, - ], - }, - ], - }, - }; } diff --git a/lib/profiles/plex_home_service.dart b/lib/profiles/plex_home_service.dart index 0dc70c42..75a0c619 100644 --- a/lib/profiles/plex_home_service.dart +++ b/lib/profiles/plex_home_service.dart @@ -26,7 +26,7 @@ class PlexHomeService { this._storage, Future> 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> _defaultHomeUserFetcher(String accountToken) async { - final auth = await PlexAuthService.create(); - try { - final home = await auth.getHomeUsers(accountToken); - return home.users; - } finally { - auth.dispose(); - } -} diff --git a/lib/profiles/profile_activation.dart b/lib/profiles/profile_activation.dart index 416cdee5..c444c9bb 100644 --- a/lib/profiles/profile_activation.dart +++ b/lib/profiles/profile_activation.dart @@ -234,6 +234,8 @@ Future _preVerifyPlexHomePin(BuildContext context, Profile final connections = context.read(); final pcRegistry = context.read(); final binder = context.read(); + // 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 _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, diff --git a/lib/profiles/profile_connection_cleanup.dart b/lib/profiles/profile_connection_cleanup.dart index b754b598..8328baa9 100644 --- a/lib/profiles/profile_connection_cleanup.dart +++ b/lib/profiles/profile_connection_cleanup.dart @@ -9,65 +9,6 @@ import 'profile_connection_registry.dart'; import 'profile_merge.dart'; import 'profile_registry.dart'; -Future 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 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,228 +37,201 @@ Future planPlexAccountConnectionRemoval({ return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); } -/// Sign out of a Plex account: remove the account [Connection], every join -/// row referencing it, and everything owned by its virtual Plex Home -/// profiles — including borrowed Jellyfin connections left unreferenced, -/// which previously survived as orphans and wedged the session (#1423). -/// -/// Pass a read-only [plannedRemoval] from -/// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup -/// must finish before this destructive commit. Omitting it preserves the -/// atomic add/cancel-account cleanup path. -/// -/// All cleanup is explicit and completes before this returns; correctness -/// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which -/// runs later and no-ops. -Future removePlexAccountConnectionAndCleanup({ - required PlexAccountConnection account, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, - PlexAccountRemoval? plannedRemoval, -}) async { - final removal = - plannedRemoval ?? - await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); - final removedVirtualProfileIds = removal.removedVirtualProfileIds; - final borrowerProfileIds = removal.borrowerProfileIds; - final rows = await profileConnections.listAll(); - // Remove direct join rows first so per-profile pref cleanup observes each - // row going away; the FK cascade from the connection delete is then a no-op. - for (final row in rows.where((r) => r.connectionId == account.id)) { - await removeProfileConnectionAndCleanup( - profileId: row.profileId, - connection: account, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - } - await connections.remove(account.id); - await storage.clearPlexHomeUsersCache(account.id); - - // The account's virtual profiles die with the connection; their borrowed - // connections and per-profile prefs must go too. - for (final profileId in removedVirtualProfileIds) { - await removeAllProfileConnectionsAndCleanup( - profileId: profileId, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - await storage.clearProfileLastUsed(profileId); - await storage.clearUserScopedPreferencesForProfile(profileId); - } - - return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); -} - /// Where the session should land after a profile or connection removal. 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. -/// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed -/// accounts are harmless because the connection map is re-read here. -Future<({PostRemovalRoute route, List profiles})> resolvePostRemovalState({ - required ProfileRegistry profileRegistry, - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required Map> plexHomeUsers, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); - final conns = await connections.list(); - if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); +/// 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 merged = mergeLocalWithPlexHome( - locals: await profileRegistry.list(), - plexHomeByConnectionId: plexHomeUsers, - connectionsById: {for (final c in conns) c.id: c}, - storage: storage, - ); - if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); - return (route: PostRemovalRoute.staySignedIn, profiles: merged); -} + final ProfileConnectionRegistry profileConnections; + final ConnectionRegistry connections; + final StorageService storage; + final MultiServerManager? serverManager; -Future pruneUnreferencedJellyfinConnections({ - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - final all = await connections.list(); - final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); - var removed = 0; + Future 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, + ); - for (final connection in all.whereType()) { - if (referencedConnectionIds.contains(connection.id)) continue; - await _removeJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, + if (connection is JellyfinConnection) { + await _removeUnreferencedJellyfinConnection(connection); + } + } + + Future removeAllProfileConnections(String profileId) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection == null) { + await profileConnections.remove(profileId, row.connectionId); + continue; + } + await removeProfileConnection(profileId: profileId, connection: connection); + } + } + + /// Sign out of a Plex account: remove the account [Connection], every join + /// row referencing it, and everything owned by its virtual Plex Home + /// profiles — including borrowed Jellyfin connections left unreferenced, + /// which previously survived as orphans and wedged the session (#1423). + /// + /// Pass a read-only [plannedRemoval] from + /// [planPlexAccountConnectionRemoval] when failure-prone caller-owned cleanup + /// must finish before this destructive commit. Omitting it preserves the + /// atomic add/cancel-account cleanup path. + /// + /// All cleanup is explicit and completes before this returns; correctness + /// must not depend on [PlexHomeService]'s stream-driven `_onChange`, which + /// runs later and no-ops. + Future removePlexAccountConnection( + PlexAccountConnection account, { + PlexAccountRemoval? plannedRemoval, + }) async { + final removal = + plannedRemoval ?? + await planPlexAccountConnectionRemoval(account: account, profileConnections: profileConnections); + final removedVirtualProfileIds = removal.removedVirtualProfileIds; + final borrowerProfileIds = removal.borrowerProfileIds; + final rows = await profileConnections.listAll(); + // Remove direct join rows first so per-profile pref cleanup observes each + // row going away; the FK cascade from the connection delete is then a no-op. + for (final row in rows.where((r) => r.connectionId == account.id)) { + await removeProfileConnection(profileId: row.profileId, connection: account); + } + await connections.remove(account.id); + await storage.clearPlexHomeUsersCache(account.id); + + // The account's virtual profiles die with the connection; their borrowed + // connections and per-profile prefs must go too. + for (final profileId in removedVirtualProfileIds) { + await removeAllProfileConnections(profileId); + await storage.clearProfileLastUsed(profileId); + await storage.clearUserScopedPreferencesForProfile(profileId); + } + + return (removedVirtualProfileIds: removedVirtualProfileIds, borrowerProfileIds: borrowerProfileIds); + } + + /// In-session mirror of the boot guard (`main.dart`: "stored connections + /// exist but no profiles resolved — returning to auth"): prune orphaned + /// Jellyfin connections, then decide whether any selectable profile remains. + /// [plexHomeUsers] is [PlexHomeService.current]; stale entries for removed + /// accounts are harmless because the connection map is re-read here. + Future<({PostRemovalRoute route, List profiles})> resolvePostRemovalState({ + required ProfileRegistry profileRegistry, + required Map> plexHomeUsers, + }) async { + await pruneUnreferencedJellyfinConnections(); + final conns = await connections.list(); + if (conns.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); + + final merged = mergeLocalWithPlexHome( + locals: await profileRegistry.list(), + plexHomeByConnectionId: plexHomeUsers, + connectionsById: {for (final c in conns) c.id: c}, storage: storage, - serverManager: serverManager, ); - removed++; + if (merged.isEmpty) return (route: PostRemovalRoute.signedOut, profiles: const []); + return (route: PostRemovalRoute.staySignedIn, profiles: merged); } - return removed; -} + Future pruneUnreferencedJellyfinConnections() async { + final all = await connections.list(); + final referencedConnectionIds = (await profileConnections.listAll()).map((row) => row.connectionId).toSet(); + var removed = 0; -Future _removeUnreferencedJellyfinConnection( - JellyfinConnection connection, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; - await _removeJellyfinConnection( - connection, - profileConnections: profileConnections, - connections: connections, - storage: storage, - serverManager: serverManager, - ); -} + for (final connection in all.whereType()) { + if (referencedConnectionIds.contains(connection.id)) continue; + await _removeJellyfinConnection(connection); + removed++; + } -Future _removeJellyfinConnection( - JellyfinConnection connection, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, - required StorageService storage, - MultiServerManager? serverManager, -}) async { - await connections.remove(connection.id); - serverManager?.removeJellyfinConnection(connection); - final serverId = ServerId.tryParse(connection.serverMachineId); - if (serverId != null && - !await _isServerReferenced(serverId, profileConnections: profileConnections, connections: connections)) { - await storage.clearLibraryPreferencesForServerEverywhere(serverId); + return removed; } -} -Future _clearProfileServerPrefsNoLongerReferenced({ - required String profileId, - required Set 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 activeProfileId = storage.getActiveProfileId(); + Future _removeUnreferencedJellyfinConnection(JellyfinConnection connection) async { + if ((await profileConnections.listForConnection(connection.id)).isNotEmpty) return; + await _removeJellyfinConnection(connection); + } - for (final serverId in removedServerIds) { - if (remainingProfileServerIds.contains(serverId)) continue; - final serverStillReferenced = await _isServerReferenced( - serverId, - profileConnections: profileConnections, - connections: connections, - ); - if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { - await storage.clearLibraryPreferencesForServer( - serverId, - profileId: profileId, - includeLegacy: activeProfileId == profileId, - ); - } else { + Future _removeJellyfinConnection(JellyfinConnection connection) async { + await connections.remove(connection.id); + serverManager?.removeJellyfinConnection(connection); + final serverId = ServerId.tryParse(connection.serverMachineId); + if (serverId != null && !await _isServerReferenced(serverId)) { await storage.clearLibraryPreferencesForServerEverywhere(serverId); } } -} -/// 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> _serverIdsForProfile( - String profileId, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, -}) async { - final rows = await profileConnections.listForProfile(profileId); - if (rows.isEmpty) return const {}; + Future _clearProfileServerPrefsNoLongerReferenced({ + required String profileId, + required Set removedServerIds, + required bool clearEverywhereWhenUnreferenced, + }) async { + if (removedServerIds.isEmpty) return; + final remainingProfileServerIds = await _serverIdsForProfile(profileId); + final activeProfileId = storage.getActiveProfileId(); - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - return { - for (final row in rows) - if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), - }; -} + for (final serverId in removedServerIds) { + if (remainingProfileServerIds.contains(serverId)) continue; + final serverStillReferenced = await _isServerReferenced(serverId); + if (serverStillReferenced || !clearEverywhereWhenUnreferenced) { + await storage.clearLibraryPreferencesForServer( + serverId, + profileId: profileId, + includeLegacy: activeProfileId == profileId, + ); + } else { + await storage.clearLibraryPreferencesForServerEverywhere(serverId); + } + } + } -Future _isServerReferenced( - ServerId serverId, { - required ProfileConnectionRegistry profileConnections, - required ConnectionRegistry connections, -}) async { - final rows = await profileConnections.listAll(); - if (rows.isEmpty) return false; + /// 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> _serverIdsForProfile(String profileId) async { + final rows = await profileConnections.listForProfile(profileId); + if (rows.isEmpty) return const {}; - final all = await connections.list(); - final byId = {for (final connection in all) connection.id: connection}; - for (final row in rows) { - final connection = byId[row.connectionId]; - if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + return { + for (final row in rows) + if (byId[row.connectionId] case final connection?) ..._serverIdsForConnection(connection), + }; + } + + Future _isServerReferenced(ServerId serverId) async { + final rows = await profileConnections.listAll(); + if (rows.isEmpty) return false; + + final all = await connections.list(); + final byId = {for (final connection in all) connection.id: connection}; + for (final row in rows) { + final connection = byId[row.connectionId]; + if (connection != null && _serverIdsForConnection(connection).contains(serverId)) return true; + } + return false; } - return false; } // [ServerId]-typed for the preference APIs, which drops ids that fail to diff --git a/lib/profiles/profile_selection_policy.dart b/lib/profiles/profile_selection_policy.dart new file mode 100644 index 00000000..b7126dbc --- /dev/null +++ b/lib/profiles/profile_selection_policy.dart @@ -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; +} diff --git a/lib/providers/companion_remote_provider.dart b/lib/providers/companion_remote_provider.dart index 652fe112..d7157e3c 100644 --- a/lib/providers/companion_remote_provider.dart +++ b/lib/providers/companion_remote_provider.dart @@ -684,57 +684,32 @@ 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(), + String? winner; + final connected = await _runRemoteConnect( + generation: generation, + seedConnectingSession: true, + rethrowOnFailure: true, + join: (peer) async { + winner = await peer.joinSessionRacingWithContexts( + _deviceName, + _platform, + host.addresses, + _authContexts, + authContextId: authContext.id, + expectedHostClientId: host.clientId, + ); + }, + onConnected: (peer) { + _lastHostAddresses = [winner!]; + _lastAuthContextId = peer.selectedAuthContextId ?? authContext.id; + _lastHostClientId = peer.selectedHostClientId ?? host.clientId; + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + }, + failureLog: 'CompanionRemote: Failed to connect to host', + onFailure: _failRemoteConnectSession, ); - _setupPeerServiceListeners(candidate, generation); - safeNotifyListeners(); - - try { - final winner = await candidate.joinSessionRacingWithContexts( - _deviceName, - _platform, - host.addresses, - _authContexts, - authContextId: authContext.id, - expectedHostClientId: host.clientId, - ); - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _peerService = candidate; - _lastHostAddresses = [winner]; - _lastAuthContextId = candidate.selectedAuthContextId ?? authContext.id; - _lastHostClientId = candidate.selectedHostClientId ?? host.clientId; - _session = _session?.copyWith(status: RemoteSessionStatus.connected); - safeNotifyListeners(); + if (connected) { appLogger.d('CompanionRemote: Connected to ${host.name} via $winner'); - } catch (error, stackTrace) { - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _cleanupSubscriptions(); - await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Failed to connect to host', error: error, stackTrace: stackTrace); - _session = _session?.copyWith( - status: RemoteSessionStatus.error, - errorMessage: _localizedRemoteError( - error, - (details) => t.companionRemote.pairing.failedToConnect(error: details), - ), - ); - safeNotifyListeners(); - rethrow; } } @@ -754,48 +729,84 @@ class CompanionRemoteProvider with ChangeNotifier, DisposableChangeNotifierMixin appLogger.d('CompanionRemote: Connecting to manual host $hostAddress'); + await _runRemoteConnect( + generation: generation, + seedConnectingSession: true, + rethrowOnFailure: true, + join: (peer) => peer.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts), + onConnected: (peer) { + _lastAuthContextId = peer.selectedAuthContextId; + _lastHostClientId = peer.selectedHostClientId ?? ''; + _session = _session?.copyWith(status: RemoteSessionStatus.connected); + }, + failureLog: 'CompanionRemote: Failed to connect to manual host', + onFailure: _failRemoteConnectSession, + ); + } + + void _failRemoteConnectSession(Object error) { + _session = _session?.copyWith( + status: RemoteSessionStatus.error, + errorMessage: _localizedRemoteError( + error, + (details) => t.companionRemote.pairing.failedToConnect(error: details), + ), + ); + safeNotifyListeners(); + } + + /// Runs the candidate-peer connect lifecycle shared by the discovered/manual + /// connect paths and by reconnect attempts: create a candidate, wire its + /// listeners, then promote it to [_peerService] or dispose it. The generation + /// guards live here so a candidate that lost ownership while joining is + /// disposed rather than promoted, in exactly one place. Returns true only + /// when the candidate was promoted. + Future _runRemoteConnect({ + required int generation, + required Future 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; - _session = RemoteSession( - role: RemoteSessionRole.remote, - status: RemoteSessionStatus.connecting, - createdAt: DateTime.now(), - ); + if (seedConnectingSession) { + _session = RemoteSession( + role: RemoteSessionRole.remote, + status: RemoteSessionStatus.connecting, + createdAt: DateTime.now(), + ); + } _setupPeerServiceListeners(candidate, generation); - safeNotifyListeners(); + if (seedConnectingSession) safeNotifyListeners(); try { - await candidate.joinSessionWithContexts(_deviceName, _platform, hostAddress, _authContexts); + await join(candidate); if (!_ownsPeer(candidate, generation)) { await _disposePeerOnce(candidate); - return; + return false; } _pendingRemotePeer = null; _peerService = candidate; - _lastAuthContextId = candidate.selectedAuthContextId; - _lastHostClientId = candidate.selectedHostClientId ?? ''; - _session = _session?.copyWith(status: RemoteSessionStatus.connected); + onConnected(candidate); safeNotifyListeners(); + return true; } catch (error, stackTrace) { if (!_ownsPeer(candidate, generation)) { await _disposePeerOnce(candidate); - return; + return false; } _pendingRemotePeer = null; _cleanupSubscriptions(); await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Failed to connect to manual host', error: error, stackTrace: stackTrace); - _session = _session?.copyWith( - status: RemoteSessionStatus.error, - errorMessage: _localizedRemoteError( - error, - (details) => t.companionRemote.pairing.failedToConnect(error: details), - ), - ); - safeNotifyListeners(); - rethrow; + 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; - _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); - _reconnectAttempts = 0; - safeNotifyListeners(); + ), + onConnected: (peer) { + _lastAuthContextId = peer.selectedAuthContextId ?? authContextId; + _lastHostClientId = peer.selectedHostClientId ?? _lastHostClientId; + _session = _session?.copyWith(status: RemoteSessionStatus.connected, errorMessage: null); + _reconnectAttempts = 0; + }, + failureLog: 'CompanionRemote: Reconnect failed', + onFailure: (_) { + if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) { + _scheduleReconnect(generation); + } + }, + ); + if (reconnected) { appLogger.d('CompanionRemote: Reconnected successfully'); - } catch (error, stackTrace) { - if (!_ownsPeer(candidate, generation)) { - await _disposePeerOnce(candidate); - return; - } - - _pendingRemotePeer = null; - _cleanupSubscriptions(); - await _disposePeerOnce(candidate); - appLogger.e('CompanionRemote: Reconnect failed', error: error, stackTrace: stackTrace); - if (generation == _remoteGeneration && _session?.status == RemoteSessionStatus.reconnecting) { - _scheduleReconnect(generation); - } } } diff --git a/lib/providers/discover_provider.dart b/lib/providers/discover_provider.dart index 1905c58d..c9123f66 100644 --- a/lib/providers/discover_provider.dart +++ b/lib/providers/discover_provider.dart @@ -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? get _watchedIds { - final keys = {}; - 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? get _watchedIds => hierarchicalEventIds(_onDeck); - Set? get _watchedGlobalKeys { - final keys = {}; - 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? 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 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? get _deletionIds { - final keys = {}; - 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? get _deletionIds => hierarchicalEventIds(_visibleItems); - _onDeck.forEach(addItem); - for (final hub in _hubs) { - hub.items.forEach(addItem); - } - return keys; - } - - Set? get _deletionGlobalKeys { - final keys = {}; - 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? get _deletionGlobalKeys => hierarchicalEventGlobalKeys(_visibleItems); void _onDeletion(DeletionEvent event) { // On-deck and hubs are server-backed: a download-only deletion leaves the diff --git a/lib/providers/download_metadata_store.dart b/lib/providers/download_metadata_store.dart index 3cef5056..406b84f6 100644 --- a/lib/providers/download_metadata_store.dart +++ b/lib/providers/download_metadata_store.dart @@ -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, ), diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index bf50e991..41a3ab23 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -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'; @@ -17,6 +16,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'; @@ -25,7 +25,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/watch_state_notifier.dart'; @@ -925,46 +924,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. diff --git a/lib/providers/multi_server_provider.dart b/lib/providers/multi_server_provider.dart index 3e09fe52..644d3d0a 100644 --- a/lib/providers/multi_server_provider.dart +++ b/lib/providers/multi_server_provider.dart @@ -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 _visible(List 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 get onlineServerIds { - final all = _serverManager.onlineServerIds; - final filter = _visibleServerIds; - if (filter == null) return all; - return all.where(filter.contains).toList(); - } + List get onlineServerIds => _visible(_serverManager.onlineServerIds); /// Get all server IDs (visibility-filtered). - List get serverIds { - final all = _serverManager.serverIds; - final filter = _visibleServerIds; - if (filter == null) return all; - return all.where(filter.contains).toList(); - } + List 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(); diff --git a/lib/providers/watch_state_store.dart b/lib/providers/watch_state_store.dart index 385ce3e6..a02c1949 100644 --- a/lib/providers/watch_state_store.dart +++ b/lib/providers/watch_state_store.dart @@ -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,9 @@ class WatchStateStore extends ChangeNotifier with DisposableChangeNotifierMixin return _exactEntryFor(globalKey); } - WatchStatePatch? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; + WatchStateSnapshot? patchForGlobalKey(String globalKey) => _entryFor(globalKey)?.patch; - WatchStatePatch? patchForItem(MediaItem item) { + WatchStateSnapshot? patchForItem(MediaItem item) { var best = _entryFor(item.globalKey); if (item.parentChain.isNotEmpty) { final serverId = serverIdOrNull(item.serverId); @@ -147,14 +121,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 +183,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 +207,7 @@ extension WatchStateResolution on BuildContext { /// ancestor). Use in `build`. MediaItem withFreshWatchState(MediaItem item) { try { - final patch = select((store) => store.patchForItem(item)); + final patch = select((store) => store.patchForItem(item)); return WatchStateStore.applyPatch(item, patch); } on ProviderNotFoundException { return item; diff --git a/lib/screens/actor_media_screen.dart b/lib/screens/actor_media_screen.dart index e63b9608..9157a8a0 100644 --- a/lib/screens/actor_media_screen.dart +++ b/lib/screens/actor_media_screen.dart @@ -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 with GridFocusNodeMixin, FocusableDetailScreenMixin, - PaginatedItemLoader { + PaginatedItemLoader, + PaginatedItemUpdatable, + StandardPaginatedView { static const int _pageSize = 200; @override @@ -84,39 +87,17 @@ class _ActorMediaScreenState extends BaseMediaListDetailScreen } @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 loadItems() async { - await loadInitialPaginatedItems( + Future 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); - }, ); } diff --git a/lib/screens/auth_screen.dart b/lib/screens/auth_screen.dart index bc15eeb2..06d09a52 100644 --- a/lib/screens/auth_screen.dart +++ b/lib/screens/auth_screen.dart @@ -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 { 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( diff --git a/lib/screens/base_media_list_detail_screen.dart b/lib/screens/base_media_list_detail_screen.dart index edae8c09..e3a91392 100644 --- a/lib/screens/base_media_list_detail_screen.dart +++ b/lib/screens/base_media_list_detail_screen.dart @@ -4,6 +4,7 @@ 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'; @@ -41,19 +42,34 @@ abstract class BaseMediaListDetailScreen 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().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 multiServerProvider = Provider.of(context, listen: false); - serverId = multiServerProvider.onlineServerIds.firstOrNull; - } - return serverId; + final serverId = _mediaItemServerId; + if (serverId != null) return serverId; + final multiServerProvider = Provider.of(context, listen: false); + return multiServerProvider.onlineServerIds.firstOrNull; } MediaServerClient _getMediaClientForMediaItem() { diff --git a/lib/screens/collection_detail_screen.dart b/lib/screens/collection_detail_screen.dart index e79b48f3..e8be7c13 100644 --- a/lib/screens/collection_detail_screen.dart +++ b/lib/screens/collection_detail_screen.dart @@ -1,17 +1,16 @@ 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/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 +34,9 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen, FocusableDetailScreenMixin, - PaginatedItemLoader { + PaginatedItemLoader, + PaginatedItemUpdatable, + StandardPaginatedView { static const int _pageSize = 200; @override @@ -75,49 +76,21 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( + Future 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 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((p) => p.hasSyncRule(ruleKey)); @@ -127,19 +100,16 @@ class _CollectionDetailScreenState extends BaseMediaListDetailScreen _manageCollectionSyncRule() => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _collectionSyncRuleKey()); - - Future _removeCollectionSyncRule() => removeSyncRuleAndSnack( - context, - downloadProvider: context.read(), - globalKey: _collectionSyncRuleKey(), - displayTitle: widget.collection.displayTitle, - ); - - String _collectionSyncRuleKey() { - final serverId = widget.collection.serverId ?? mediaClient.serverId; - return context.read().syncRuleKeyForClient( - mediaClient, - widget.collection.id, - serverId: ServerId(serverId), - ); - } - Future _deleteCollection() async { final confirmed = await showDeleteConfirmation( context, diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 39f5aaae..43c6414f 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -51,6 +51,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'; @@ -180,17 +181,7 @@ class _DiscoverScreenState extends State if (_tvBrowseHubsCache != null && key == _tvBrowseHubsCacheKey) return _tvBrowseHubsCache!; final hubs = []; if (_onDeck.isNotEmpty) { - hubs.add( - MediaHub( - id: 'continue_watching', - title: t.discover.continueWatching, - type: 'mixed', - identifier: '_continue_watching_', - size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), - more: _hasMoreContinueWatching, - items: _onDeck, - ), - ); + hubs.add(_continueWatchingHub); } hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty)); _tvBrowseHubsCache = hubs; @@ -198,6 +189,18 @@ class _DiscoverScreenState extends State return hubs; } + /// The synthesized Continue Watching row, rendered ahead of the backend hubs + /// on both the mobile list and the TV rail. + MediaHub get _continueWatchingHub => MediaHub( + id: 'continue_watching', + title: t.discover.continueWatching, + type: 'mixed', + identifier: '_continue_watching_', + size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), + more: _hasMoreContinueWatching, + items: _onDeck, + ); + void _setSpotlightItem(MediaItem item) => _spotlight.select(item); void _scrollToTop() { @@ -615,101 +618,6 @@ class _DiscoverScreenState extends State 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(); @@ -1011,6 +919,7 @@ class _DiscoverScreenState extends State final bottomPadding = MediaQuery.paddingOf(context).bottom; final theme = Theme.of(context); + final continueWatchingHub = _onDeck.isEmpty ? null : _continueWatchingHub; return Material( color: theme.scaffoldBackgroundColor, child: Stack( @@ -1034,21 +943,13 @@ class _DiscoverScreenState extends State 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, @@ -1066,7 +967,7 @@ class _DiscoverScreenState extends State 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 @@ -1152,7 +1053,7 @@ class _DiscoverScreenState extends State hubs: browseHubs, 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, diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index 02c7bc9c..ff784071 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -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 on State, 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 items, + required List 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, - onRefresh: onRefresh, - collectionId: collectionId, - onListRefresh: onListRefresh, - fullBleedImage: useFullCardLayout && position.isGrid, - cardShapeOverride: shape, - onNavigateUp: position.isFirstRow ? navigateToAppBar : null, - onBack: handleBackFromContent, - onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus), - ); - }, - ); - }, + return buildSparseFocusableGrid( + totalItems: items.length, + itemAt: (index) => items[index], + onRefresh: onRefresh, + collectionId: collectionId, + onListRefresh: onListRefresh, + 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 on State, 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, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 2334ca08..1aaf4242 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -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 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 }, ), if (_filteredItems.isNotEmpty && (_isLoadingPage || _pageLoadError != null)) - _buildContinuationStatusSliver(), + ContinuationStatusSliver( + error: _pageLoadError, + onRetry: _retryHubContinuation, + retryFocusNode: _continuationRetryFocusNode, + onNavigateUp: () => _focusNodeForIndex(_filteredItems.length - 1).requestFocus(), + onBack: handleBackFromContent, + ), ], ), ), diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index d0c7f2d1..b279e896 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -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'; @@ -101,6 +102,55 @@ class SliverEmptyState extends StatelessWidget { ); } +/// Footer sliver for continuation (append-to-list) pagination: a spinner while +/// the next page loads, or the error message with a focusable retry button. +class ContinuationStatusSliver extends StatelessWidget { + /// Failure from the last page load; null while the page is still loading. + final Object? error; + final VoidCallback onRetry; + final FocusNode retryFocusNode; + final VoidCallback? onNavigateUp; + final VoidCallback? onBack; + + const ContinuationStatusSliver({ + super.key, + required this.error, + required this.onRetry, + required this.retryFocusNode, + this.onNavigateUp, + this.onBack, + }); + + @override + Widget build(BuildContext context) { + final exception = error; + final message = exception == null ? null : t.messages.errorLoading(error: exception.toString()); + return SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.all(24), + child: Center( + child: message == null + ? const CircularProgressIndicator() + : Column( + mainAxisSize: .min, + children: [ + Text(message, textAlign: TextAlign.center), + const SizedBox(height: 8), + FocusableButton( + focusNode: retryFocusNode, + onPressed: onRetry, + onNavigateUp: onNavigateUp, + onBack: onBack, + child: TextButton(onPressed: onRetry, child: Text(t.common.retry)), + ), + ], + ), + ), + ), + ); + } +} + /// A widget that handles loading, error, empty, and content states /// Provides a consistent UI pattern across the app for data-driven screens class ContentStateBuilder extends StatelessWidget { diff --git a/lib/screens/libraries/folder_tree_view.dart b/lib/screens/libraries/folder_tree_view.dart index bfde4f39..f08ca328 100644 --- a/lib/screens/libraries/folder_tree_view.dart +++ b/lib/screens/libraries/folder_tree_view.dart @@ -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 { } } - Future _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 _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; + launcher = JellyfinSequentialLauncher(context: context); + } else { + final client = context.getPlexClientForServer(ServerId(widget.serverId!)); + launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); } - - final folderKey = folder.backendFolderKey; - if (folderKey == null) return; - final client = context.getPlexClientForServer(ServerId(widget.serverId!)); - final launcher = PlexPlayQueueLauncher(context: context, client: client, serverId: widget.serverId); - await launcher.launchFromFolder( - folderKey: folderKey, - shuffle: false, - libraryId: folder.libraryId, - libraryTitle: folder.libraryTitle, - ); - } - - Future _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 { 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, diff --git a/lib/screens/libraries/tabs/base_library_tab.dart b/lib/screens/libraries/tabs/base_library_tab.dart index be58fff8..79e2583c 100644 --- a/lib/screens/libraries/tabs/base_library_tab.dart +++ b/lib/screens/libraries/tabs/base_library_tab.dart @@ -216,6 +216,20 @@ abstract class BaseLibraryTabState> 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; diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index b8598d6d..3b44e326 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -55,6 +55,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 +105,14 @@ class _LibraryBrowseTabState extends BaseLibraryTabState, + PaginatedItemUpdatable, 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 +132,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState? get deletionIds => loadedItems.values.map((e) => e.id).toSet(); - - @override - Set? get deletionGlobalKeys { - if (loadedItems.isEmpty) return {}; - - final keys = {}; - 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 +199,6 @@ class _LibraryBrowseTabState extends BaseLibraryTabState 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 _filters = []; List _sortOptions = []; diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index df59c57a..4dbd3cbc 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -5,6 +5,7 @@ import '../../../media/library_query.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'; @@ -43,6 +44,7 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState, PaginatedItemLoader, + StandardPaginatedView, SkeletonUpgradeScheduler { static const int _pageSize = 36; @@ -78,35 +80,11 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( + Future 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: 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); - }, + errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), + onLoaded: (_, _) => markItemsLoaded(), ); } diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index 9e6b7848..12127c31 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -7,6 +7,7 @@ import '../../../media/media_kind.dart'; import '../../../media/media_playlist.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'; @@ -45,6 +46,7 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState, PaginatedItemLoader, + StandardPaginatedView, SkeletonUpgradeScheduler { static const int _pageSize = 200; @@ -84,35 +86,11 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState loadItems() async { - String? loadErrorMessage; - await loadInitialPaginatedItems( + Future 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: 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); - }, + errorMessageFor: (error, stackTrace) => localizedLoadErrorMessage(error, stackTrace, context: errorContext), + onLoaded: (_, _) => markItemsLoaded(), ); } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 5826a780..b997ede5 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -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 { } class _LibraryRecommendedTabState extends BaseLibraryTabState - with ItemUpdatable, WatchStateAware, DeletionAware { + with ItemUpdatable, WatchStateAware, DeletionAware, DeletionMirrorsWatchState { /// GlobalKeys for each hub section to enable vertical navigation final List> _hubKeys = []; final _tvBrowseRailKey = GlobalKey(); @@ -72,45 +73,18 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState widget.library.serverId; - @override - String? get deletionServerId => widget.library.serverId; + /// Every item on screen, across all hubs. + Iterable 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? get deletionIds => watchedIds; + Set? get watchedIds => hierarchicalEventIds(_visibleItems); @override - Set? get deletionGlobalKeys => watchedGlobalKeys; - - @override - Set? get watchedIds { - final keys = {}; - 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? get watchedGlobalKeys { - final keys = {}; - 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? get watchedGlobalKeys => + hierarchicalEventGlobalKeys(_visibleItems, fallbackServerId: widget.library.serverId); @override void updateItemInLists(String sourceGlobalKey, MediaItem updatedItem) { @@ -316,7 +290,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState _getHubIcon(hub), + iconForHub: (hub, _) => hubIconFor(hub), onFocusedItemChanged: _setSpotlightItem, onRefresh: updateItem, onRemoveFromContinueWatching: _refreshContinueWatching, @@ -371,24 +345,4 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState 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 _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 _broadcastToDvrs({ + required String actionLabel, + required String successMessage, + required Future Function(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) action, + }) async { final multiServer = context.read(); + Future runSafely(LiveTvDvrSupport dvr, LiveTvServerInfo serverInfo) async { + try { + await action(dvr, serverInfo); + } catch (e) { + appLogger.d('$actionLabel failed for DVR ${serverInfo.dvrKey}: $e'); + } + } + final futures = >[]; 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)); + final dvr = multiServer.getClientForServer(ServerId(serverInfo.serverId))?.liveTvDvr; + if (dvr == null) continue; + futures.add(runSafely(dvr, serverInfo)); } - if (futures.isEmpty) return; + if (futures.isEmpty) return false; await Future.wait(futures); - if (!mounted) return; - showSnackBar(context, t.liveTv.guideReloadRequested); - } - - Future _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'); - } + if (!mounted) return false; + showSnackBar(context, successMessage); + return true; } Future _processRecordingRules() async { - final multiServer = context.read(); - final futures = >[]; - 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 _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). diff --git a/lib/screens/livetv/reorder_favorites_sheet.dart b/lib/screens/livetv/reorder_favorites_sheet.dart index 83dd05e7..f1f0be53 100644 --- a/lib/screens/livetv/reorder_favorites_sheet.dart +++ b/lib/screens/livetv/reorder_favorites_sheet.dart @@ -1,13 +1,11 @@ import 'package:flutter/material.dart'; import '../../media/ids.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../focus/dpad_navigator.dart'; +import '../../focus/dpad_reorder_mixin.dart'; import '../../focus/focus_theme.dart'; import '../../focus/input_mode_tracker.dart'; -import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../models/livetv_channel.dart'; import '../../providers/multi_server_provider.dart'; @@ -34,18 +32,33 @@ class ReorderFavoritesSheet extends StatefulWidget { State createState() => _ReorderFavoritesSheetState(); } -class _ReorderFavoritesSheetState extends State { +class _ReorderFavoritesSheetState extends State + with DpadReorderListMixin { late List _tempFavorites; - // Keyboard navigation state - int _focusedIndex = 0; - int _focusedColumn = 0; // 0 = row, 1 = remove button - int? _movingIndex; - int? _originalIndex; - List? _originalOrder; final FocusNode _listFocusNode = FocusNode(); final ScrollController _scrollController = ScrollController(); - bool _backKeyDownSeen = false; + + // Keyboard navigation: column 0 = row, column 1 = remove button. + @override + List get reorderItems => _tempFavorites; + + @override + set reorderItems(List value) => _tempFavorites = value; + + @override + int get lastReorderColumn => 1; + + @override + ScrollController? get reorderScrollController => _scrollController; + + @override + void onReorderMoveConfirmed() => widget.onReorder(_tempFavorites); + + @override + void onReorderColumnActivated(int column, int index) { + if (column == 1) _removeItem(index); + } @override void initState() { @@ -60,139 +73,6 @@ class _ReorderFavoritesSheetState extends State { super.dispose(); } - void _ensureFocusedVisible() { - if (!_scrollController.hasClients) return; - - const double itemHeight = 72.0; - const double listTopPadding = 8.0; - final double targetTop = listTopPadding + (_focusedIndex * itemHeight); - final double targetBottom = targetTop + itemHeight; - - final double viewportTop = _scrollController.offset; - final double viewportHeight = _scrollController.position.viewportDimension; - final double viewportBottom = viewportTop + viewportHeight; - - if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; - - final double destination = (targetTop - viewportHeight * 0.25).clamp( - 0.0, - _scrollController.position.maxScrollExtent, - ); - - _scrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } - - KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { - final key = event.logicalKey; - - if (key.isBackKey) { - if (event is KeyDownEvent) { - _backKeyDownSeen = true; - } else if (event is KeyUpEvent && !_backKeyDownSeen) { - return KeyEventResult.handled; - } - if (event is KeyUpEvent) { - _backKeyDownSeen = false; - } - } - - final backResult = handleBackKeyAction(event, () { - if (_movingIndex != null) { - setState(() { - if (_originalOrder != null) { - _tempFavorites = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - } else { - OverlaySheetController.popAdaptive(context); - } - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (!event.isActionable) return KeyEventResult.ignored; - - if (_movingIndex != null) { - if (key.isUpKey && _movingIndex! > 0) { - setState(() { - final item = _tempFavorites.removeAt(_movingIndex!); - _tempFavorites.insert(_movingIndex! - 1, item); - _movingIndex = _movingIndex! - 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _movingIndex! < _tempFavorites.length - 1) { - setState(() { - final item = _tempFavorites.removeAt(_movingIndex!); - _tempFavorites.insert(_movingIndex! + 1, item); - _movingIndex = _movingIndex! + 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - widget.onReorder(_tempFavorites); - setState(() { - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - return KeyEventResult.handled; - } - } else { - if (key.isUpKey && _focusedIndex > 0) { - setState(() { - _focusedIndex--; - _focusedColumn = 0; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _focusedIndex < _tempFavorites.length - 1) { - setState(() { - _focusedIndex++; - _focusedColumn = 0; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isLeftKey && _focusedColumn > 0) { - setState(() => _focusedColumn--); - return KeyEventResult.handled; - } - if (key.isRightKey && _focusedColumn < 1) { - setState(() => _focusedColumn++); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - if (_focusedColumn == 0) { - setState(() { - _movingIndex = _focusedIndex; - _originalIndex = _focusedIndex; - _originalOrder = List.from(_tempFavorites); - }); - } else if (_focusedColumn == 1) { - _removeItem(_focusedIndex); - } - return KeyEventResult.handled; - } - } - - if (key.isDpadDirection) { - return KeyEventResult.handled; - } - - return KeyEventResult.ignored; - } - void _onReorder(int oldIndex, int newIndex) { setState(() { final item = _tempFavorites.removeAt(oldIndex); @@ -205,8 +85,8 @@ class _ReorderFavoritesSheetState extends State { final removed = _tempFavorites[index]; setState(() { _tempFavorites.removeAt(index); - if (_focusedIndex >= _tempFavorites.length) { - _focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length); + if (focusedIndex >= _tempFavorites.length) { + focusedIndex = (_tempFavorites.length - 1).clamp(0, _tempFavorites.length); } }); widget.onRemove(removed); @@ -229,7 +109,7 @@ class _ReorderFavoritesSheetState extends State { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: isKeyboardMode, - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleReorderKeyEvent, child: ReorderableListView.builder( scrollController: _scrollController, onReorderItem: _onReorder, @@ -239,8 +119,8 @@ class _ReorderFavoritesSheetState extends State { itemBuilder: (context, index) { final fav = _tempFavorites[index]; final channel = widget.channelMap[fav.stableKey]; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; + final isFocused = isKeyboardMode && index == focusedIndex; + final isMoving = index == movingIndex; return _buildFavoriteTile( key: ValueKey(fav.stableKey), @@ -249,7 +129,7 @@ class _ReorderFavoritesSheetState extends State { index: index, isFocused: isFocused, isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, + focusedColumn: isFocused ? focusedColumn : null, ); }, ), diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index 5158fd02..35b59f9c 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -1,5 +1,6 @@ import 'dart:async'; import '../media/ids.dart'; +import '../media/media_server_client.dart'; import '../navigation/main_screen_scope.dart'; import 'dart:io' show Platform, exit; @@ -34,6 +35,7 @@ import '../profiles/active_profile_binder.dart'; import '../connection/connection_registry.dart'; import '../profiles/active_profile_provider.dart'; import '../profiles/plex_home_service.dart'; +import '../profiles/profile_selection_policy.dart'; import '../providers/catalog_sources_provider.dart'; import '../providers/download_provider.dart'; import '../providers/multi_server_provider.dart'; @@ -233,13 +235,12 @@ class _MainScreenState extends State bool _isShowingProfileSelection = false; late List _screens; - final GlobalKey> _discoverKey = GlobalKey(); - final GlobalKey> _exploreKey = GlobalKey(); - final GlobalKey> _librariesKey = GlobalKey(); - final GlobalKey> _liveTvKey = GlobalKey(); - final GlobalKey> _searchKey = GlobalKey(); - final GlobalKey> _downloadsKey = GlobalKey(); - final GlobalKey> _settingsKey = GlobalKey(); + + /// One [GlobalKey] per tab, so a tab's live [State] can be reached from + /// anywhere in this class via [_onScreen]. Deliberately untyped: every + /// consumer discards the concrete `State` type and pattern-matches on a + /// capability mixin (Refreshable, FocusableTab, …) instead. + final Map _screenKeys = {for (final id in NavigationTabId.values) id: GlobalKey()}; final GlobalKey _sideNavKey = GlobalKey(); /// Measures the mobile bottom navigation area for the music mini-player. @@ -441,23 +442,11 @@ class _MainScreenState extends State } void tryDownloadResume() { - if (_downloadResumeFired || !mounted) return; // Wait for any online client before firing the resume — the download // pipeline is backend-neutral (resumeQueuedDownloads accepts a // MediaServerClient and per-item resolution picks up the right // backend), so a Jellyfin-only setup can resume too. - final onlineClient = manager.onlineClients.values.firstOrNull; - if (onlineClient == null) return; - _downloadResumeFired = true; - _serverStatusSub?.cancel(); - _serverStatusSub = null; - final downloadProvider = context.read(); - unawaited( - downloadProvider.ensureInitialized().then((_) { - if (!mounted) return; - downloadProvider.resumeQueuedDownloads(onlineClient); - }), - ); + _resumeQueuedDownloadsOnce(manager.onlineClients.values.firstOrNull); } // Listen for binding-settle so the once-only priming runs after both @@ -495,36 +484,35 @@ class _MainScreenState extends State if (!mounted) return; context.read().onServersConnected(); unawaited(context.read().refreshMetadataFromCache()); - _resumeQueuedDownloadsIfPossible(mp); + _resumeQueuedDownloadsOnce( + mp.onlineServerIds.map((id) => mp.getClientForServer(ServerId(id))).nonNulls.firstOrNull, + ); } } if (!mounted) return; - if (_discoverKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_librariesKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_searchKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } + _fullRefreshContentTabs(); } - void _resumeQueuedDownloadsIfPossible(MultiServerProvider mp) { + /// Single-shot "resume queued downloads once any client is online" rule, + /// shared by the startup status-stream path and [_primeOnlineServices] — + /// each caller resolves its own candidate client (unfiltered manager view + /// vs the visibility-filtered provider) and hands it here. No-op once the + /// resume has fired, or while no client is online yet. + void _resumeQueuedDownloadsOnce(MediaServerClient? onlineClient) { if (_downloadResumeFired || !mounted) return; - for (final serverId in mp.onlineServerIds) { - final onlineClient = mp.getClientForServer(ServerId(serverId)); - if (onlineClient == null) continue; - _downloadResumeFired = true; - unawaited( - context.read().ensureInitialized().then((_) { - if (!mounted) return; - context.read().resumeQueuedDownloads(onlineClient); - }), - ); - return; - } + if (onlineClient == null) return; + _downloadResumeFired = true; + // The status subscription exists only to drive this one-shot. + _serverStatusSub?.cancel(); + _serverStatusSub = null; + final downloadProvider = context.read(); + unawaited( + downloadProvider.ensureInitialized().then((_) { + if (!mounted) return; + downloadProvider.resumeQueuedDownloads(onlineClient); + }), + ); } void _onActiveProfileChanged() { @@ -594,11 +582,15 @@ class _MainScreenState extends State // has no profile to bind, and the user lands on an empty screen with // no way back to the picker. final hasNoActive = activeProfile.active == null && activeProfile.profiles.isNotEmpty; - final requireOnOpen = - settingsService.read(SettingsService.requireProfileSelectionOnOpen) && activeProfile.hasMultipleProfiles; - if (!hasNoActive && !requireOnOpen) return; + if (!hasNoActive && !activeProfile.requiresSelectionOnOpen(settingsService)) return; + await _pushProfileSelection(); + } + + /// Push the picker in "must choose" mode, suppressing the tvOS menu-button + /// passthrough for as long as it is up. + Future _pushProfileSelection() async { _isShowingProfileSelection = true; _setTvosMenuPassthrough(false); await Navigator.of( @@ -838,9 +830,7 @@ class _MainScreenState extends State _selectTab(NavigationTabId.search, focusSearchInput: !hasQuery); if (hasQuery) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.submitSearchQuery(trimmed); - } + _onScreen(NavigationTabId.search, (screen) => screen.submitSearchQuery(trimmed)); }); } }; @@ -925,21 +915,11 @@ class _MainScreenState extends State Future _showProfileSelectionOnResume() async { final settingsService = await SettingsService.getInstance(); - if (!settingsService.read(SettingsService.requireProfileSelectionOnOpen)) return; if (!mounted) return; - final activeProfile = context.read(); - if (!activeProfile.hasMultipleProfiles) return; + if (!context.read().requiresSelectionOnOpen(settingsService)) return; - _isShowingProfileSelection = true; - _setTvosMenuPassthrough(false); - await Navigator.of( - context, - rootNavigator: true, - ).push(MaterialPageRoute(builder: (context) => const ProfileSwitchScreen(requireSelection: true))); - if (!mounted) return; - _isShowingProfileSelection = false; - _updateTvosMenuPassthrough(); + await _pushProfileSelection(); } /// IndexedStack that disables tickers for offscreen children to prevent @@ -965,17 +945,17 @@ class _MainScreenState extends State return [ for (final tab in _getVisibleTabs(offline)) switch (tab.id) { - NavigationTabId.discover => DiscoverScreen(key: _discoverKey), - NavigationTabId.explore => ExploreScreen(key: _exploreKey), + NavigationTabId.discover => DiscoverScreen(key: _screenKeys[tab.id]), + NavigationTabId.explore => ExploreScreen(key: _screenKeys[tab.id]), NavigationTabId.libraries => LibrariesScreen( - key: _librariesKey, + key: _screenKeys[tab.id], onLibraryOrderChanged: _onLibraryOrderChanged, onLibrarySelected: _handleLibrariesScreenSelected, ), - NavigationTabId.liveTv => LiveTvScreen(key: _liveTvKey), - NavigationTabId.search => SearchScreen(key: _searchKey), - NavigationTabId.downloads => DownloadsScreen(key: _downloadsKey), - NavigationTabId.settings => SettingsScreen(key: _settingsKey), + NavigationTabId.liveTv => LiveTvScreen(key: _screenKeys[tab.id]), + NavigationTabId.search => SearchScreen(key: _screenKeys[tab.id]), + NavigationTabId.downloads => DownloadsScreen(key: _screenKeys[tab.id]), + NavigationTabId.settings => SettingsScreen(key: _screenKeys[tab.id]), }, ]; } @@ -1030,16 +1010,22 @@ class _MainScreenState extends State }()); } - void _handleLiveTvChanged() { - final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; - if (hasLiveTv == _lastHasLiveTv) return; - _lastHasLiveTv = hasLiveTv; - + /// Rebuilds navigation after a tab's availability flipped: _currentTab may + /// need normalizing, and passthrough depends on it being the first tab. + void _handleTabAvailabilityChanged() { setState(() { _screens = _buildScreens(_isOffline); _currentTab = _normalizeTabForMode(_currentTab, _isOffline); }); _updateTvosMenuPassthrough(); + } + + void _handleLiveTvChanged() { + final hasLiveTv = _multiServerProvider?.hasLiveTv ?? false; + if (hasLiveTv == _lastHasLiveTv) return; + _lastHasLiveTv = hasLiveTv; + + _handleTabAvailabilityChanged(); // A preferred startup section (only Live TV can be deferred) just became // available — switch to it via _selectTab so it gets the usual visibility @@ -1055,13 +1041,7 @@ class _MainScreenState extends State if (hasExplore == _lastHasExplore) return; _lastHasExplore = hasExplore; - setState(() { - _screens = _buildScreens(_isOffline); - _currentTab = _normalizeTabForMode(_currentTab, _isOffline); - }); - // Same as the live-TV handler: the passthrough flag depends on whether - // _currentTab is the first tab, which the normalize above can change. - _updateTvosMenuPassthrough(); + _handleTabAvailabilityChanged(); } void _handleOfflineStatusChanged() { @@ -1154,17 +1134,8 @@ class _MainScreenState extends State // This preserves the user's focus position when returning from sidebar. WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; - if (restorePreviousFocus) { - if (_contentFocusScope.focusedChild == null) { - if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } - } - } else { - if (_screenKeyFor(_currentTab)?.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } - } + if (restorePreviousFocus && _contentFocusScope.focusedChild != null) return; + _onScreen(_currentTab, (screen) => screen.focusActiveTabIfReady()); }); } @@ -1363,9 +1334,7 @@ class _MainScreenState extends State if (_isSidebarFocused) _focusContent(); // Schedule focus after the frame so the search screen is visible in the IndexedStack WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.focusSearchInput(); - } + _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); return KeyEventResult.handled; } @@ -1386,9 +1355,7 @@ class _MainScreenState extends State _miniPlayerInsets?.setNavBarSuspended(true); // Called when a child route is pushed on top (e.g., video player) if (_currentTab == NavigationTabId.discover) { - if (_discoverKey.currentState case final TabVisibilityAware aware) { - aware.onTabHidden(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.onTabHidden()); } } @@ -1407,9 +1374,7 @@ class _MainScreenState extends State _updateTvosMenuPassthrough(); _miniPlayerInsets?.setNavBarSuspended(false); if (_currentTab == NavigationTabId.discover) { - if (_discoverKey.currentState case final TabVisibilityAware aware) { - aware.onTabShown(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.onTabShown()); _onDiscoverBecameVisible(); } } @@ -1417,9 +1382,7 @@ class _MainScreenState extends State void _onDiscoverBecameVisible() { appLogger.d('Navigated to home'); // Refresh content when returning to discover page - if (_discoverKey.currentState case final Refreshable refreshable) { - refreshable.refresh(); - } + _onScreen(NavigationTabId.discover, (screen) => screen.refresh()); } void _onLibraryOrderChanged() { @@ -1464,15 +1427,7 @@ class _MainScreenState extends State playbackStateProvider.clearShuffle(); - if (_discoverKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_librariesKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } - if (_searchKey.currentState case final FullRefreshable refreshable) { - refreshable.fullRefresh(); - } + _fullRefreshContentTabs(); // Refresh user-level settings (audio/sub defaults) for the new identity. if (mounted) { @@ -1500,14 +1455,9 @@ class _MainScreenState extends State if (previousTab != tab) { // Notify previous screen it's being hidden - if (_screenKeyFor(previousTab)?.currentState case final TabVisibilityAware aware) { - aware.onTabHidden(); - } + _onScreen(previousTab, (screen) => screen.onTabHidden()); // Notify and focus new screen - final newState = _screenKeyFor(tab)?.currentState; - if (newState case final TabVisibilityAware aware) { - aware.onTabShown(); - } + _onScreen(tab, (screen) => screen.onTabShown()); // Back-to-home keeps the sidebar focused (chain: content → sidebar → // home → exit); stealing focus here left _isSidebarFocused stuck true // while real focus sat on a content card (#1411). @@ -1515,9 +1465,7 @@ class _MainScreenState extends State // search input, since focusing it auto-opens the on-screen keyboard; the // query submit focuses results instead. if (!_isSidebarFocused && (tab != NavigationTabId.search || focusSearchInput)) { - if (newState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } + _onScreen(tab, (screen) => screen.focusActiveTabIfReady()); } } @@ -1531,9 +1479,7 @@ class _MainScreenState extends State // submit runs the search and focuses results without opening the keyboard. if (tab == NavigationTabId.search && focusSearchInput) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_searchKey.currentState case final SearchInputFocusable searchable) { - searchable.focusSearchInput(); - } + _onScreen(NavigationTabId.search, (screen) => screen.focusSearchInput()); }); } } @@ -1543,12 +1489,8 @@ class _MainScreenState extends State _selectedLibraryGlobalKey = libraryGlobalKey; _selectTab(NavigationTabId.libraries); // Tell LibrariesScreen to load this library after tab switch - if (_librariesKey.currentState case final LibraryLoadable loadable) { - loadable.loadLibraryByKey(libraryGlobalKey); - } - if (_librariesKey.currentState case final FocusableTab focusable) { - focusable.focusActiveTabIfReady(); - } + _onScreen(NavigationTabId.libraries, (screen) => screen.loadLibraryByKey(libraryGlobalKey)); + _onScreen(NavigationTabId.libraries, (screen) => screen.focusActiveTabIfReady()); } void _openSettings() { @@ -1637,17 +1579,20 @@ class _MainScreenState extends State ); } - /// Get the GlobalKey for a given tab. - GlobalKey? _screenKeyFor(NavigationTabId tab) { - return switch (tab) { - NavigationTabId.discover => _discoverKey, - NavigationTabId.explore => _exploreKey, - NavigationTabId.libraries => _librariesKey, - NavigationTabId.liveTv => _liveTvKey, - NavigationTabId.search => _searchKey, - NavigationTabId.downloads => _downloadsKey, - NavigationTabId.settings => _settingsKey, - }; + /// Invoke [fn] on the tab's current [State] when it exists and implements + /// the capability [T]. Screens are only built for visible tabs and mount a + /// frame later, so a missing key or a non-matching state is a no-op. + void _onScreen(NavigationTabId tab, void Function(T state) fn) { + if (_screenKeys[tab]?.currentState case final T state) fn(state); + } + + /// Full-refresh the primary content tabs. Shared by the online-entry hook + /// ([_primeOnlineServices]) and the profile-switch invalidation + /// ([_invalidateAllScreens]), which refresh the same set. + void _fullRefreshContentTabs() { + for (final tab in const [NavigationTabId.discover, NavigationTabId.libraries, NavigationTabId.search]) { + _onScreen(tab, (screen) => screen.fullRefresh()); + } } Widget _buildBottomNavigationBar(BuildContext context, {required bool hideLabels}) { diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index e692356f..44776ddc 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -267,7 +267,13 @@ PageRoute mediaDetailRoute({ } class _MediaDetailScreenState extends State - with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware { + with + WatchStateAware, + DeletionAware, + DeletionMirrorsWatchState, + MountedSetStateMixin, + ServerBoundMediaMixin, + RouteAware { /// Public input alias — used as the live source of truth until the detail /// fetch returns. Holds backend-neutral [MediaItem] data. MediaItem get _metadata => _fullMetadata ?? widget.metadata; @@ -393,7 +399,9 @@ class _MediaDetailScreenState extends State @override bool get isServerBoundOffline => widget.isOffline; - // WatchStateAware: watch the show/movie and all season/episode ratingKeys + // WatchStateAware: watch the show/movie and all season/episode ratingKeys. + // DeletionMirrorsWatchState reuses these three getters for deletion events — + // the same items are on screen either way. @override Set? get watchedIds { final keys = {_metadata.id}; @@ -533,36 +541,6 @@ class _MediaDetailScreenState extends State } } - @override - Set? get deletionIds { - final keys = {_metadata.id}; - for (final season in _seasons) { - keys.add(season.id); - } - for (final ep in _episodes) { - keys.add(ep.id); - } - return keys; - } - - @override - String? get deletionServerId => serverBoundServerId; - - @override - Set? get deletionGlobalKeys { - final serverId = serverBoundServerId; - if (serverId == null) return null; - - final keys = {toServerBoundGlobalKey(_metadata.id, serverId: ServerId(serverId))}; - for (final season in _seasons) { - keys.add(toServerBoundGlobalKey(season.id, serverId: ServerId(season.serverId ?? serverId))); - } - for (final ep in _episodes) { - keys.add(toServerBoundGlobalKey(ep.id, serverId: ServerId(ep.serverId ?? serverId))); - } - return keys; - } - @override void onDeletionEvent(DeletionEvent event) { // Download-only deletions should only remove items when viewing offline content diff --git a/lib/screens/metadata_edit_screen.dart b/lib/screens/metadata_edit_screen.dart index 68e2e46c..813524be 100644 --- a/lib/screens/metadata_edit_screen.dart +++ b/lib/screens/metadata_edit_screen.dart @@ -122,23 +122,14 @@ class _MetadataEditScreenState extends State { final draft = _draft; if (draft == null || _isCommitting) return; final currentValue = draft.value(field.id) ?? ''; - final result = multiline - ? await showTextInputDialog( - context, - title: field.label, - labelText: field.label, - initialValue: currentValue, - allowEmpty: true, - multiline: true, - ) - : await showTextInputDialog( - context, - title: field.label, - labelText: field.label, - hintText: '', - initialValue: currentValue, - allowEmpty: true, - ); + final result = await showTextInputDialog( + context, + title: field.label, + labelText: field.label, + initialValue: currentValue, + allowEmpty: true, + multiline: multiline, + ); if (result != null && mounted && !_isCommitting && identical(_draft, draft)) { setState(() => draft.setValue(field.id, result)); diff --git a/lib/screens/music/album_detail_screen.dart b/lib/screens/music/album_detail_screen.dart index c096137b..7895fc63 100644 --- a/lib/screens/music/album_detail_screen.dart +++ b/lib/screens/music/album_detail_screen.dart @@ -27,14 +27,12 @@ import '../../utils/snackbar_helper.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/desktop_app_bar.dart'; import '../../widgets/download_status_icon.dart'; -import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/media_context_menu.dart'; import '../../widgets/music/mini_player.dart'; import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/music/track_row.dart'; import '../../widgets/optimized_media_image.dart'; -import '../../widgets/overlay_sheet.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -388,35 +386,15 @@ class _AlbumDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), - ), - ], - ), - ), - ), - ), + return buildDetailScaffold( + slivers: [ + CustomAppBar(title: Text(widget.album.displayTitle)), + SliverToBoxAdapter(child: _buildHeader()), + ...buildStateSlivers(), + if (hasItems) _buildTrackList(), + // Keep the last rows reachable above the floating mini-player. + SliverToBoxAdapter(child: SizedBox(height: context.watch()?.overlayHeight ?? 0)), + ], ); } } diff --git a/lib/screens/music/artist_detail_screen.dart b/lib/screens/music/artist_detail_screen.dart index 60b31c68..ee8ad628 100644 --- a/lib/screens/music/artist_detail_screen.dart +++ b/lib/screens/music/artist_detail_screen.dart @@ -5,7 +5,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/key_event_utils.dart'; import '../../i18n/strings.g.dart'; import '../../media/ids.dart'; import '../../media/media_item.dart'; @@ -16,17 +15,14 @@ import '../../utils/formatters.dart'; import '../../utils/error_message_utils.dart'; import '../../utils/media_image_helper.dart'; import '../../utils/music_navigation.dart'; -import '../../utils/platform_detector.dart'; import '../../utils/provider_extensions.dart'; import '../../utils/snackbar_helper.dart'; import '../../widgets/collapsible_text.dart'; import '../../widgets/desktop_app_bar.dart'; -import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/music/mini_player.dart'; import '../../widgets/music/music_detail_header.dart'; import '../../widgets/music/music_actions.dart'; import '../../widgets/optimized_media_image.dart'; -import '../../widgets/overlay_sheet.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; @@ -81,31 +77,17 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen _playAll({bool shuffle = false}) async { - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - List tracks; - try { - tracks = await mediaClient.fetchPlayableDescendants(widget.artist.id); - } catch (e, stackTrace) { - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - final message = localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle); - showErrorSnackBar(context, message); - return; - } - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - if (tracks.isEmpty) { - showAppSnackBar(context, emptyMessage); - return; - } - await playTracks( + await playFetchedTracks( context, - tracks: tracks, + fetch: () => mediaClient.fetchPlayableDescendants(widget.artist.id), playContext: MusicPlayContext( id: widget.artist.id, title: widget.artist.displayTitle, kind: MusicPlayContextKind.artist, ), + onError: (e, stackTrace) => + showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.artist.displayTitle)), + onEmpty: () => showAppSnackBar(context, emptyMessage), shuffle: shuffle, ); } @@ -193,36 +175,16 @@ class _ArtistDetailScreenState extends BaseMediaListDetailScreen()?.overlayHeight ?? 0), - ), - ], - ), - ), - ), - ), + return buildDetailScaffold( + slivers: [ + CustomAppBar(title: Text(widget.artist.displayTitle)), + SliverToBoxAdapter(child: _buildHeader()), + ...buildStateSlivers(), + // Albums arrive newest-first from both backends — no client-side sort. + if (hasItems) buildFocusableGrid(items: items, onRefresh: updateItem, shape: CardShape.square), + // Keep the last rows reachable above the floating mini-player. + SliverToBoxAdapter(child: SizedBox(height: context.watch()?.overlayHeight ?? 0)), + ], ); } } diff --git a/lib/screens/playlist/playlist_detail_screen.dart b/lib/screens/playlist/playlist_detail_screen.dart index 612b86b5..061c70a7 100644 --- a/lib/screens/playlist/playlist_detail_screen.dart +++ b/lib/screens/playlist/playlist_detail_screen.dart @@ -1,11 +1,9 @@ import 'dart:async'; -import '../../media/ids.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../focus/focusable_action_bar.dart'; -import '../../focus/focusable_button.dart'; import '../../media/library_query.dart'; import '../../media/media_item.dart'; import '../../media/media_kind.dart'; @@ -34,6 +32,7 @@ import '../../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../../widgets/listenable_selector.dart'; import '../base_media_list_detail_screen.dart'; import '../focusable_detail_screen_mixin.dart'; +import '../libraries/content_state_builder.dart'; import '../../mixins/grid_focus_node_mixin.dart'; import '../../widgets/overlay_sheet.dart'; @@ -92,24 +91,15 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen(); - final intent = service.beginPlayIntent(); - List tracks; - if (_isPlaylistFullyLoaded) { - tracks = items; - } else { - try { - tracks = await fetchAllPlaylistItems(mediaClient, widget.playlist.id); - } catch (e, stackTrace) { - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - final message = localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title); - showErrorSnackBar(context, message); - return; - } - } - if (!mounted || !service.isPlayIntentCurrent(intent)) return; - await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: _musicPlayContext, shuffle: shuffle); + await playFetchedTracks( + context, + fetch: () async => _isPlaylistFullyLoaded ? items : await fetchAllPlaylistItems(mediaClient, widget.playlist.id), + playContext: _musicPlayContext, + onError: (e, stackTrace) => + showErrorSnackBar(context, localizedLoadErrorMessage(e, stackTrace, context: widget.playlist.title)), + startTrack: startTrack, + shuffle: shuffle, + ); } @override @@ -117,7 +107,7 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen((p) => p.hasSyncRule(ruleKey)); @@ -127,19 +117,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen().syncRuleKeyForClient( - mediaClient, - widget.playlist.id, - serverId: ServerId(serverId), - ); - } - - Future _managePlaylistSyncRule() => - manageSyncRule(context, downloadProvider: context.read(), globalKey: _playlistSyncRuleKey()); - - Future _removePlaylistSyncRule() => removeSyncRuleAndSnack( - context, - downloadProvider: context.read(), - globalKey: _playlistSyncRuleKey(), - displayTitle: widget.playlist.title, - ); - // Focus management for regular (non-smart) reorderable lists final FocusNode _listFocusNode = FocusNode(debugLabel: 'playlist_list'); final FocusNode _continuationRetryFocusNode = FocusNode(debugLabel: 'playlist_continuation_retry'); @@ -780,7 +746,14 @@ class _PlaylistDetailScreenState extends BaseMediaListDetailScreen { candidateSliver = SliverList( delegate: SliverChildBuilderDelegate((context, index) { final cand = candidates[index]; - // M3E connected-group geometry: large outer corners, small - // inner corners, hairline gaps between tiles. final tokensRef = tokens(context); - final tileRadii = BorderRadius.vertical( - top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(index == candidates.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); + final tileRadii = groupItemRadii(context, index, candidates.length); return Padding( padding: EdgeInsets.fromLTRB(16, index == 0 ? 4 : tokensRef.groupGap, 16, 0), child: FocusableWrapper( @@ -299,6 +294,8 @@ class _BorrowConnectionScreenState extends State { final parentId = cand.source.parentConnectionId; final homeUuid = cand.source.plexHomeUserUuid; if (parentId == null || homeUuid == null) return false; + // Built before the await: capturing the prompt needs a live element. + final promptForPin = dialogPinPrompt(context, cand.source.displayName); final parent = await context.read().getPlexAccount(parentId); if (parent == null) { if (mounted) showErrorSnackBar(context, t.profiles.sourceProfileMissingParentAccount); @@ -308,10 +305,7 @@ class _BorrowConnectionScreenState extends State { account: parent, homeUserUuid: homeUuid, requiresPin: true, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, + promptForPin: promptForPin, logLabel: cand.source.displayName, ); if (!result.succeeded) { @@ -330,10 +324,7 @@ class _BorrowConnectionScreenState extends State { account: account, homeUserUuid: cand.pc.userIdentifier, requiresPin: cand.source.plexProtected, - promptForPin: ({String? errorMessage}) async { - if (!mounted) return null; - return showPinEntryDialog(context, cand.source.displayName, errorMessage: errorMessage); - }, + promptForPin: dialogPinPrompt(context, cand.source.displayName), persistTo: pcRegistry, persistProfileId: widget.targetProfile.id, logLabel: cand.source.displayName, @@ -344,14 +335,7 @@ class _BorrowConnectionScreenState extends State { } return; } - if (mounted) { - unawaited(context.read().rebindIfActive(widget.targetProfile.id)); - if (widget.popOnSuccess) { - Navigator.of(context).pop(true); - return; - } - showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); - } + _finishBorrow(); } Future _borrowJellyfin(_BorrowCandidate cand) async { @@ -366,14 +350,19 @@ class _BorrowConnectionScreenState extends State { tokenAcquiredAt: DateTime.now(), ), ); - if (mounted) { - unawaited(context.read().rebindIfActive(widget.targetProfile.id)); - if (widget.popOnSuccess) { - Navigator.of(context).pop(true); - return; - } - showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); + _finishBorrow(); + } + + /// Shared tail of every successful borrow: rebind the target profile when + /// it is the active one, then pop with the result or confirm in place. + void _finishBorrow() { + if (!mounted) return; + unawaited(context.read().rebindIfActive(widget.targetProfile.id)); + if (widget.popOnSuccess) { + Navigator.of(context).pop(true); + return; } + showSuccessSnackBar(context, t.profiles.borrowConnectionBorrowed); } } diff --git a/lib/screens/profile/pin_entry_dialog.dart b/lib/screens/profile/pin_entry_dialog.dart index fd9a693c..5eee1f67 100644 --- a/lib/screens/profile/pin_entry_dialog.dart +++ b/lib/screens/profile/pin_entry_dialog.dart @@ -8,6 +8,7 @@ import '../../focus/key_event_utils.dart'; import '../../focus/focusable_button.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; +import '../../profiles/plex_home_switch.dart'; import '../../utils/platform_detector.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/clickable_cursor.dart'; @@ -698,6 +699,14 @@ Future showPinEntryDialog(BuildContext context, String userName, {Strin ); } +/// The [PlexHomeSwitchPinPrompt] every UI-side `mintPlexHomeUserToken` caller +/// needs: show [showPinEntryDialog] for [displayName], or cancel the switch +/// once [context] is gone. Only the *use* is guarded — build it before the +/// caller's first await, while [context] is still live. +PlexHomeSwitchPinPrompt dialogPinPrompt(BuildContext context, String displayName) => + ({String? errorMessage}) async => + context.mounted ? showPinEntryDialog(context, displayName, errorMessage: errorMessage) : null; + /// Two-step "set + confirm" PIN entry. Returns the matching PIN, or null /// when the user cancels. On mismatch, surfaces a snackbar via [onMismatch] /// (or no-op if not provided) and returns null — the helper keeps the UX diff --git a/lib/screens/profile/profile_detail_screen.dart b/lib/screens/profile/profile_detail_screen.dart index bf652cef..bfae71ee 100644 --- a/lib/screens/profile/profile_detail_screen.dart +++ b/lib/screens/profile/profile_detail_screen.dart @@ -10,21 +10,13 @@ import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../models/plex/plex_home_user.dart'; import '../../profiles/active_profile_binder.dart'; -import '../../profiles/active_profile_provider.dart'; import '../../profiles/plex_home_service.dart'; import '../../profiles/profile.dart'; import '../../profiles/profile_avatar.dart'; -import '../../profiles/profile_connection_cleanup.dart'; import '../../profiles/profile_connection.dart'; import '../../profiles/profile_connection_registry.dart'; import '../../profiles/profile_registry.dart'; import '../../profiles/profiles_view.dart'; -import '../../providers/download_provider.dart'; -import '../../providers/discover_provider.dart'; -import '../../providers/hidden_libraries_provider.dart'; -import '../../providers/multi_server_provider.dart'; -import '../../services/storage_service.dart'; -import '../../services/system_shelf_service.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; import '../../widgets/app_icon.dart'; @@ -167,20 +159,11 @@ class _ProfileDetailScreenState extends State with Controll isDestructive: true, ); if (!confirmed || !mounted) return; - final downloads = context.read(); - final pcRegistry = context.read(); - final connRegistry = context.read(); - final storage = context.read(); - final multiServer = context.read(); - final hiddenLibraries = context.read(); - final discover = context.read(); - final binder = context.read(); - final active = context.read(); - final shelf = SystemShelfService(); - final endedOwner = active.activeId == _profile.id ? _profile.id : null; + final scope = SessionTeardownScope.of(context); + final endedOwner = scope.active.activeId == _profile.id ? _profile.id : null; if (endedOwner != null) { - await shelf.endProfileSession(endedOwner); + await scope.shelf.endProfileSession(endedOwner); } try { @@ -189,38 +172,26 @@ class _ProfileDetailScreenState extends State with Controll // Plex account sharing the server, another Jellyfin user). final retainedServerIds = await _retainedServerIds( excludingConnectionId: conn.id, - profileConnections: pcRegistry, - connections: connRegistry, + profileConnections: scope.profileConnections, + connections: scope.connections, ); - await downloads.releaseDownloadsForProfileServers( + await scope.downloads.releaseDownloadsForProfileServers( _profile.id, _serverIdsForConnection(conn).difference(retainedServerIds), ); - await removeProfileConnectionAndCleanup( - profileId: _profile.id, - connection: conn, - profileConnections: pcRegistry, - connections: connRegistry, - storage: storage, - serverManager: multiServer.serverManager, - ); - await hiddenLibraries?.refresh(); - await binder.rebindIfActive(_profile.id); - if (endedOwner != null && active.activeId == endedOwner) { - shelf.beginProfileSession(endedOwner); - if (multiServer.hasConnectedServers) await discover?.load(); + await scope.cleanup.removeProfileConnection(profileId: _profile.id, connection: conn); + await scope.hiddenLibraries?.refresh(); + // Deliberately not `resumeFreshSystemShelf`: a rebind failure on the + // success path must reach the catch below so the recovery attempt — + // and the rethrow — still run. + await scope.binder.rebindIfActive(_profile.id); + if (endedOwner != null && scope.active.activeId == endedOwner) { + scope.shelf.beginProfileSession(endedOwner); + if (scope.multiServer.hasConnectedServers) await scope.discover?.load(); } } catch (_) { - if (endedOwner != null && active.activeId == endedOwner) { - try { - await binder.rebindIfActive(endedOwner); - if (active.activeId == endedOwner) { - shelf.beginProfileSession(endedOwner); - if (multiServer.hasConnectedServers) await discover?.load(); - } - } catch (_) { - // Keep the shelf empty when the surviving profile cannot be rebound. - } + if (endedOwner != null) { + await resumeFreshSystemShelf(scope, endedOwner); } rethrow; } diff --git a/lib/screens/profile/profile_switch_screen.dart b/lib/screens/profile/profile_switch_screen.dart index b4abf2c2..56424695 100644 --- a/lib/screens/profile/profile_switch_screen.dart +++ b/lib/screens/profile/profile_switch_screen.dart @@ -224,13 +224,8 @@ class _ProfileSwitchScreenState extends State with MountedS delegate: SliverChildBuilderDelegate((context, index) { final profile = profiles[index]; final isActive = profile.id == activeId; - // M3E connected-group geometry: large outer corners, small inner - // corners, hairline gaps between tiles. final tokensRef = tokens(context); - final tileRadii = BorderRadius.vertical( - top: Radius.circular(index == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(index == profiles.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); + final tileRadii = groupItemRadii(context, index, profiles.length); final isFirstSelectable = autofocusFirst && index == 0; final profileFocusNode = _profileFocusNode(profile); final menuFocusNode = _profileMenuFocusNode(profile); diff --git a/lib/screens/profile/profile_teardown.dart b/lib/screens/profile/profile_teardown.dart index 52b4a70d..e1d2df77 100644 --- a/lib/screens/profile/profile_teardown.dart +++ b/lib/screens/profile/profile_teardown.dart @@ -49,6 +49,13 @@ class SessionTeardownScope { MultiServerManager get serverManager => multiServer.serverManager; + ProfileConnectionCleanup get cleanup => ProfileConnectionCleanup( + profileConnections: profileConnections, + connections: connections, + storage: storage, + serverManager: serverManager, + ); + SessionTeardownScope.of(BuildContext context) : active = context.read(), binder = context.read(), @@ -80,13 +87,9 @@ Future settleSessionAfterRemoval( bool rebindIfActiveKept = false, String? endedShelfOwner, }) async { - final result = await resolvePostRemovalState( + final result = await scope.cleanup.resolvePostRemovalState( profileRegistry: scope.profileRegistry, - profileConnections: scope.profileConnections, - connections: scope.connections, plexHomeUsers: scope.plexHome.current, - storage: scope.storage, - serverManager: scope.serverManager, ); if (result.route == PostRemovalRoute.signedOut) { @@ -199,13 +202,7 @@ Future deleteProfile(BuildContext context, Profile profile) async { await scope.downloads.deleteDownloadsForProfile(profile.id); await scope.database.deleteSyncRulesForProfile(profile.id); await scope.database.deleteWatchActionsForProfile(profile.id); - await removeAllProfileConnectionsAndCleanup( - profileId: profile.id, - profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, - ); + await scope.cleanup.removeAllProfileConnections(profile.id); await scope.profileRegistry.remove(profile.id); await scope.storage.clearProfileLastUsed(profile.id); await scope.storage.clearUserScopedPreferencesForProfile(profile.id); @@ -263,14 +260,7 @@ Future confirmAndSignOutPlexAccount(BuildContext context, {required String await scope.downloads.releaseDownloadsForProfileServers(profileId, accountServerIds); } - await removePlexAccountConnectionAndCleanup( - account: account, - profileConnections: scope.profileConnections, - connections: scope.connections, - storage: scope.storage, - serverManager: scope.serverManager, - plannedRemoval: removal, - ); + await scope.cleanup.removePlexAccountConnection(account, plannedRemoval: removal); for (final profileId in removal.removedVirtualProfileIds) { await scope.database.deleteSyncRulesForProfile(profileId); await scope.database.deleteWatchActionsForProfile(profileId); diff --git a/lib/screens/settings/add_connection_screen.dart b/lib/screens/settings/add_connection_screen.dart index 9feeed0a..5d9e5d84 100644 --- a/lib/screens/settings/add_connection_screen.dart +++ b/lib/screens/settings/add_connection_screen.dart @@ -55,12 +55,6 @@ class AddConnectionScreen extends StatelessWidget { ), ]; final tokensRef = tokens(context); - // M3E connected-group geometry: large outer corners, small inner corners, - // hairline gaps. - BorderRadius radiiFor(int i) => BorderRadius.vertical( - top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(i == options.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); return FocusedScrollScaffold( title: Text( scoped @@ -75,7 +69,7 @@ class AddConnectionScreen extends StatelessWidget { for (var i = 0; i < options.length; i++) ...[ if (i > 0) SizedBox(height: tokensRef.groupGap), _BackendCard( - borderRadius: radiiFor(i), + borderRadius: groupItemRadii(context, i, options.length), leading: options[i].backend != null ? BackendBadge(backend: options[i].backend!, size: 28) : const AppIcon(Symbols.share_rounded, fill: 1, size: 28), diff --git a/lib/screens/settings/add_jellyfin_screen.dart b/lib/screens/settings/add_jellyfin_screen.dart index 8604bf63..aa98305a 100644 --- a/lib/screens/settings/add_jellyfin_screen.dart +++ b/lib/screens/settings/add_jellyfin_screen.dart @@ -342,13 +342,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta _discoveredServerFocusNodes[_localServers.last.id]?.requestFocus(); } - List _enteredUrls() { - return _urlController.text - .split(RegExp(r'[\n,]+')) - .map((url) => url.trim()) - .where((url) => url.isNotEmpty) - .toList(growable: false); - } + List _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlController.text); /// Shared persistence path for both username/password and Quick Connect: /// atomically provision the optional first-run profile, connection, and @@ -642,12 +636,6 @@ class _AddJellyfinScreenState extends State with AsyncFormSta if (_localServers.isEmpty) return const []; final tokensRef = tokens(context); - // M3E connected-group geometry: large outer corners, small inner corners, - // hairline gaps between tiles. - BorderRadius radiiFor(int i) => BorderRadius.vertical( - top: Radius.circular(i == 0 ? tokensRef.radiusLg : tokensRef.radiusXs), - bottom: Radius.circular(i == _localServers.length - 1 ? tokensRef.radiusLg : tokensRef.radiusXs), - ); return [ const SizedBox(height: 16), Text(t.addServer.localServers, style: theme.textTheme.titleSmall), @@ -656,7 +644,7 @@ class _AddJellyfinScreenState extends State with AsyncFormSta if (i > 0) SizedBox(height: tokensRef.groupGap), _DiscoveredJellyfinServerTile( server: server, - borderRadius: radiiFor(i), + borderRadius: groupItemRadii(context, i, _localServers.length), focusNode: _discoveredServerFocusNodes[server.id], onNavigateUp: () { final index = _localServers.indexOf(server); diff --git a/lib/screens/settings/add_plex_account_screen.dart b/lib/screens/settings/add_plex_account_screen.dart index 0207fd97..404a3fd2 100644 --- a/lib/screens/settings/add_plex_account_screen.dart +++ b/lib/screens/settings/add_plex_account_screen.dart @@ -86,12 +86,11 @@ class _AddPlexAccountScreenState extends State with AsyncF // to the profile, remove it again so a cancelled attach doesn't // leave a global account behind. if (!registration.existedBefore) { - await removePlexAccountConnectionAndCleanup( - account: connection, + await ProfileConnectionCleanup( profileConnections: pcRegistry, connections: connRegistry, storage: storage, - ); + ).removePlexAccountConnection(connection); } if (mounted) Navigator.of(context).pop(false); return true; diff --git a/lib/screens/settings/appearance_settings_screen.dart b/lib/screens/settings/appearance_settings_screen.dart index 95d7723b..4dc430d5 100644 --- a/lib/screens/settings/appearance_settings_screen.dart +++ b/lib/screens/settings/appearance_settings_screen.dart @@ -212,14 +212,12 @@ class AppearanceSettingsScreen extends StatelessWidget { Widget _themeSelector() { return Consumer( builder: (context, themeProvider, _) { - return SettingSelectionTile( + return SettingSelectionTile( pref: SettingsService.themeMode, icon: themeProvider.themeModeIcon, title: t.settings.theme, subtitleBuilder: themeModeLabel, options: settings.ThemeMode.values.map((m) => DialogOption(value: m, title: themeModeLabel(m))).toList(), - decode: (v) => v, - encode: (v) => v, ); }, ); @@ -293,7 +291,7 @@ class AppearanceSettingsScreen extends StatelessWidget { ); } - Widget _viewModeSelector() => SettingSegmentedTile( + Widget _viewModeSelector() => SettingSegmentedTile( pref: SettingsService.viewMode, icon: Symbols.view_list_rounded, title: t.settings.viewMode, @@ -301,11 +299,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: ViewMode.grid, label: Text(t.settings.gridView)), ButtonSegment(value: ViewMode.list, label: Text(t.settings.listView)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _episodePosterModeSelector() => SettingSegmentedTile( + Widget _episodePosterModeSelector() => SettingSegmentedTile( pref: SettingsService.episodePosterMode, icon: Symbols.image_rounded, title: t.settings.episodePosterMode, @@ -314,11 +310,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: EpisodePosterMode.seasonPoster, label: Text(t.settings.seasonPoster)), ButtonSegment(value: EpisodePosterMode.episodeThumbnail, label: Text(t.settings.episodeThumbnail)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _continueWatchingActionSelector() => SettingSegmentedTile( + Widget _continueWatchingActionSelector() => SettingSegmentedTile( pref: SettingsService.continueWatchingAction, icon: Symbols.play_circle_rounded, title: t.settings.continueWatchingAction, @@ -326,11 +320,9 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: ContinueWatchingAction.play, label: Text(t.settings.continueWatchingPlay)), ButtonSegment(value: ContinueWatchingAction.details, label: Text(t.settings.continueWatchingDetails)), ], - decode: (v) => v, - encode: (v) => v, ); - Widget _episodeActionSelector() => SettingSegmentedTile( + Widget _episodeActionSelector() => SettingSegmentedTile( pref: SettingsService.episodeAction, icon: Symbols.tv_rounded, title: t.settings.episodeAction, @@ -338,8 +330,6 @@ class AppearanceSettingsScreen extends StatelessWidget { ButtonSegment(value: EpisodeAction.play, label: Text(t.settings.episodePlay)), ButtonSegment(value: EpisodeAction.details, label: Text(t.settings.episodeDetails)), ], - decode: (v) => v, - encode: (v) => v, ); // Sections offered as a startup destination, in display order. Live TV is @@ -353,14 +343,12 @@ class AppearanceSettingsScreen extends StatelessWidget { String _startupSectionLabel(NavigationTabId id) => allNavigationTabs.firstWhere((t) => t.id == id).getLabel(); - Widget _startupSectionSelector() => SettingSelectionTile( + Widget _startupSectionSelector() => SettingSelectionTile( pref: SettingsService.startupSection, icon: Symbols.start_rounded, title: t.settings.startupSection, subtitleBuilder: _startupSectionLabel, options: _startupSectionOptions.map((id) => DialogOption(value: id, title: _startupSectionLabel(id))).toList(), - decode: (v) => v, - encode: (v) => v, ); String _visualEffectsLabel(VisualEffectsSetting value) => switch (value) { @@ -369,32 +357,29 @@ class AppearanceSettingsScreen extends StatelessWidget { VisualEffectsSetting.reduced => t.settings.visualEffectsReduced, }; - Widget _visualEffectsSelector(BuildContext context) => - SettingSelectionTile( - pref: SettingsService.visualEffects, - icon: Symbols.animation_rounded, - title: t.settings.visualEffects, - subtitleBuilder: _visualEffectsLabel, - options: [ - DialogOption( - value: VisualEffectsSetting.auto, - title: t.settings.visualEffectsAuto, - subtitle: t.settings.visualEffectsAutoDescription, - ), - DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull), - DialogOption( - value: VisualEffectsSetting.reduced, - title: t.settings.visualEffectsReduced, - subtitle: t.settings.visualEffectsReducedDescription, - ), - ], - decode: (v) => v, - encode: (v) => v, - onAfterWrite: (value) { - DevicePerformance.setOverrideSync(value); - _restartApp(context); - }, - ); + Widget _visualEffectsSelector(BuildContext context) => SettingSelectionTile( + pref: SettingsService.visualEffects, + icon: Symbols.animation_rounded, + title: t.settings.visualEffects, + subtitleBuilder: _visualEffectsLabel, + options: [ + DialogOption( + value: VisualEffectsSetting.auto, + title: t.settings.visualEffectsAuto, + subtitle: t.settings.visualEffectsAutoDescription, + ), + DialogOption(value: VisualEffectsSetting.full, title: t.settings.visualEffectsFull), + DialogOption( + value: VisualEffectsSetting.reduced, + title: t.settings.visualEffectsReduced, + subtitle: t.settings.visualEffectsReducedDescription, + ), + ], + onAfterWrite: (value) { + DevicePerformance.setOverrideSync(value); + _restartApp(context); + }, + ); String _getLanguageDisplayName(AppLocale locale) { switch (locale) { diff --git a/lib/screens/settings/edit_jellyfin_connection_screen.dart b/lib/screens/settings/edit_jellyfin_connection_screen.dart index 7cb961fb..0b096cc2 100644 --- a/lib/screens/settings/edit_jellyfin_connection_screen.dart +++ b/lib/screens/settings/edit_jellyfin_connection_screen.dart @@ -69,13 +69,7 @@ class _EditJellyfinConnectionScreenState extends State _enteredUrls() { - return _urlsController.text - .split(RegExp(r'[\n,]+')) - .map((url) => url.trim()) - .where((url) => url.isNotEmpty) - .toList(growable: false); - } + List _enteredUrls() => JellyfinEndpointDiscovery.parseUserEnteredUrls(_urlsController.text); @override Widget build(BuildContext context) { diff --git a/lib/screens/settings/keyboard_shortcuts_screen.dart b/lib/screens/settings/keyboard_shortcuts_screen.dart index ab34ccc1..786e711c 100644 --- a/lib/screens/settings/keyboard_shortcuts_screen.dart +++ b/lib/screens/settings/keyboard_shortcuts_screen.dart @@ -5,7 +5,7 @@ import '../../i18n/strings.g.dart'; import '../../models/hotkey_model.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../utils/app_logger.dart'; -import '../../services/shader_service.dart'; +import '../../services/shortcut_action.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../focus/focusable_button.dart'; @@ -26,9 +26,7 @@ class KeyboardShortcutsScreen extends StatelessWidget { listenable: keyboardService, builder: (context, _) { final hotkeys = keyboardService.hotkeys; - final actions = hotkeys.keys - .where((action) => action != 'shader_toggle' || ShaderService.isPlatformSupported) - .toList(); + final actions = hotkeys.keys.where((action) => ShortcutAction.fromId(action)?.isSupported ?? true).toList(); return FocusedScrollScaffold( title: Text(t.settings.keyboardShortcuts), slivers: [ diff --git a/lib/screens/settings/playback_settings_screen.dart b/lib/screens/settings/playback_settings_screen.dart index fc6f6c41..3f7fd680 100644 --- a/lib/screens/settings/playback_settings_screen.dart +++ b/lib/screens/settings/playback_settings_screen.dart @@ -266,7 +266,7 @@ class _PlaybackSettingsScreenState extends State { ], ); - Widget _playerBackendSelector() => SettingSegmentedTile( + Widget _playerBackendSelector() => SettingSegmentedTile( pref: SettingsService.useExoPlayer, icon: Symbols.play_circle_rounded, title: t.settings.playerBackend, @@ -274,8 +274,6 @@ class _PlaybackSettingsScreenState extends State { ButtonSegment(value: true, label: Text(t.settings.exoPlayer)), ButtonSegment(value: false, label: Text(t.settings.mpv)), ], - decode: (s) => s, - encode: (s) => s, ); Widget _externalPlayerTile() => SettingsBuilder( @@ -391,7 +389,7 @@ class _PlaybackSettingsScreenState extends State { subtitle: t.settings.tunneledPlaybackDescription, ); - Widget _dvConversionModeTile() => SettingSelectionTile( + Widget _dvConversionModeTile() => SettingSelectionTile( pref: SettingsService.dvConversionMode, icon: Symbols.hdr_strong_rounded, title: t.settings.dvConversionMode, @@ -399,8 +397,6 @@ class _PlaybackSettingsScreenState extends State { options: DvConversionModePreference.values .map((m) => DialogOption(value: m, title: _dvConversionModeLabel(m))) .toList(), - decode: (m) => m, - encode: (m) => m, ); String _dvConversionModeLabel(DvConversionModePreference mode) => switch (mode) { @@ -412,7 +408,7 @@ class _PlaybackSettingsScreenState extends State { Widget _bufferSizeTile() { final bufferOptions = const [0, 64, 128, 256, 512, 1024]; - return SettingSelectionTile( + return SettingSelectionTile( pref: SettingsService.bufferSize, icon: Symbols.memory_rounded, title: t.settings.bufferSize, @@ -420,8 +416,6 @@ class _PlaybackSettingsScreenState extends State { options: bufferOptions .map((s) => DialogOption(value: s, title: s == 0 ? t.settings.bufferSizeAuto : '${s}MB')) .toList(), - decode: (s) => s, - encode: (s) => s, onAfterWrite: (value) async { if (Platform.isAndroid && value > 0) { final heapMB = await PlayerAndroid.getHeapSize(); @@ -433,7 +427,7 @@ class _PlaybackSettingsScreenState extends State { ); } - Widget _defaultQualityTile() => SettingSelectionTile( + Widget _defaultQualityTile() => SettingSelectionTile( pref: SettingsService.defaultQualityPreset, icon: Symbols.high_quality_rounded, title: t.settings.defaultQualityTitle, @@ -441,18 +435,14 @@ class _PlaybackSettingsScreenState extends State { options: TranscodeQualityPreset.displayOrder .map((p) => DialogOption(value: p, title: qualityPresetLabel(p))) .toList(), - decode: (p) => p, - encode: (p) => p, ); - Widget _musicQualityTile() => SettingSelectionTile( + Widget _musicQualityTile() => SettingSelectionTile( pref: SettingsService.musicQualityPreset, icon: Symbols.music_note_rounded, title: t.settings.musicQualityTitle, subtitleBuilder: _musicQualityLabel, options: AudioQualityPreset.values.map((p) => DialogOption(value: p, title: _musicQualityLabel(p))).toList(), - decode: (p) => p, - encode: (p) => p, ); String _musicQualityLabel(AudioQualityPreset preset) => diff --git a/lib/screens/settings/services_settings_screen.dart b/lib/screens/settings/services_settings_screen.dart index 0db542aa..835749ab 100644 --- a/lib/screens/settings/services_settings_screen.dart +++ b/lib/screens/settings/services_settings_screen.dart @@ -3,9 +3,8 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import '../../i18n/strings.g.dart'; +import '../../models/catalog/catalog_item.dart'; import '../../providers/seerr_account_provider.dart'; -import '../../providers/trackers_provider.dart'; -import '../../providers/trakt_account_provider.dart'; import '../../widgets/app_icon.dart'; import '../../widgets/catalog_source_logo.dart'; import '../../widgets/focused_scroll_scaffold.dart'; @@ -13,8 +12,7 @@ import '../../widgets/focusable_list_tile.dart'; import '../../widgets/settings_section.dart'; import 'seerr_connect_screen.dart'; import 'seerr_settings_screen.dart'; -import 'tracker_settings_screen.dart'; -import 'trakt_settings_screen.dart'; +import 'tracker_service_info.dart'; /// Unified hub for all connected services: the watch-progress trackers /// (Trakt, MyAnimeList, AniList, Simkl) and the Seerr request server. Each @@ -38,7 +36,7 @@ class ServicesSettingsScreen extends StatelessWidget { ).textTheme.bodyMedium?.copyWith(color: Theme.of(context).colorScheme.onSurfaceVariant), ), ), - SettingsGroup(children: [_trakt(), _mal(), _anilist(), _simkl(), _seerr()]), + SettingsGroup(children: [for (final info in TrackerServiceInfo.all) _TrackerHubRow(info), _seerr()]), const SizedBox(height: 24), ]), ), @@ -46,78 +44,9 @@ class ServicesSettingsScreen extends StatelessWidget { ); } - Widget _trakt() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/trakt_circlemark.svg', size: 24), - title: t.trakt.title, - username: account.isConnected ? account.username : null, - onTap: () { - if (account.isConnected) { - Navigator.push(context, MaterialPageRoute(builder: (_) => const TraktSettingsScreen())); - } else { - startTraktConnection(context); - } - }, - ), - ); - - Widget _mal() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/mal_mark.svg', size: 24), - title: t.services.names.mal, - username: account.isMalConnected ? account.malUsername : null, - onTap: () { - if (account.isMalConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.mal())), - ); - } else { - startMalConnection(context); - } - }, - ), - ); - - Widget _anilist() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/anilist_mark.svg', size: 24), - title: t.services.names.anilist, - username: account.isAnilistConnected ? account.anilistUsername : null, - onTap: () { - if (account.isAnilistConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.anilist())), - ); - } else { - startAnilistConnection(context); - } - }, - ), - ); - - Widget _simkl() => Consumer( - builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/simkl_mark.svg', size: 24), - title: t.services.names.simkl, - username: account.isSimklConnected ? account.simklUsername : null, - onTap: () { - if (account.isSimklConnected) { - Navigator.push( - context, - MaterialPageRoute(builder: (_) => TrackerSettingsScreen(config: TrackerConfig.simkl())), - ); - } else { - startSimklConnection(context); - } - }, - ), - ); - Widget _seerr() => Consumer( builder: (context, account, _) => _ServiceHubRow( - leading: const CatalogSourceLogo.asset('assets/seerr_mark.svg', size: 24), + leading: const CatalogSourceLogo(CatalogSourceId.seerr, size: 24), title: t.services.names.seerr, username: account.isConnected ? account.displayName : null, onTap: () { @@ -132,6 +61,31 @@ class ServicesSettingsScreen extends StatelessWidget { ); } +/// Hub row for a watch tracker. Owns the `watch` on that service's account +/// provider so only this row rebuilds when the connection state changes. +class _TrackerHubRow extends StatelessWidget { + final TrackerServiceInfo info; + + const _TrackerHubRow(this.info); + + @override + Widget build(BuildContext context) { + final connected = info.isConnected(context); + return _ServiceHubRow( + leading: CatalogSourceLogo(info.logoSource, size: 24), + title: info.displayName, + username: connected ? info.username(context) : null, + onTap: () { + if (connected) { + Navigator.push(context, MaterialPageRoute(builder: (_) => info.buildSettingsScreen())); + } else { + info.startConnection(context); + } + }, + ); + } +} + class _ServiceHubRow extends StatelessWidget { final Widget leading; final String title; diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index 0954ad35..26267f55 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -26,12 +26,9 @@ import '../../services/saf_storage_service.dart'; import '../../services/settings_export_service.dart'; import '../../providers/theme_provider.dart'; import '../../providers/seerr_account_provider.dart'; -import '../../providers/trackers_provider.dart'; -import '../../providers/trakt_account_provider.dart'; import '../../services/keyboard_shortcuts_service.dart'; import '../../services/settings_service.dart' as settings; import '../../services/update_service.dart'; -import '../../utils/app_logger.dart'; import '../../utils/dialogs.dart'; import '../../utils/snackbar_helper.dart'; import '../../utils/platform_detector.dart'; @@ -55,6 +52,7 @@ import 'playback_settings_screen.dart'; import '../profile/profile_switch_screen.dart'; import 'services_settings_screen.dart'; import 'settings_utils.dart'; +import 'tracker_service_info.dart'; import '../../widgets/loading_indicator_box.dart'; class SettingsScreen extends StatefulWidget { @@ -263,13 +261,12 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Widget _buildServicesTile() { - return Consumer3( - builder: (context, trakt, trackers, seerr, _) { + // The tracker account providers are watched through [TrackerServiceInfo]. + return Consumer( + builder: (context, seerr, _) { final connectedNames = [ - if (trakt.isConnected) t.trakt.title, - if (trackers.isMalConnected) t.services.names.mal, - if (trackers.isAnilistConnected) t.services.names.anilist, - if (trackers.isSimklConnected) t.services.names.simkl, + for (final info in TrackerServiceInfo.all) + if (info.isConnected(context)) info.displayName, if (seerr.isConnected) t.services.names.seerr, ]; final subtitle = connectedNames.isEmpty ? t.settings.servicesDescription : connectedNames.join(' · '); @@ -614,65 +611,50 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _selectDownloadLocation() async { - try { - String? selectedPath; - String pathType = 'file'; + final changed = await guardSettingsOperation( + context, + operation: 'Download directory selection', + body: () async { + String? selectedPath; + String pathType = 'file'; - if (Platform.isAndroid) { - final safStorage = SafStorageService.instance; - if (!safStorage.supportsDirectoryPicker) { - showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable); - return false; + if (Platform.isAndroid) { + final safStorage = SafStorageService.instance; + if (!safStorage.supportsDirectoryPicker) { + showErrorSnackBar(context, t.settings.downloadLocationPickerUnavailable); + return false; + } + selectedPath = await safStorage.pickDirectory(); + if (!mounted) return false; + if (selectedPath != null) pathType = 'saf'; + } else { + selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); + if (!mounted) return false; } - selectedPath = await safStorage.pickDirectory(); - if (!mounted) return false; - if (selectedPath != null) pathType = 'saf'; - } else { - selectedPath = await FilePickerService.instance.getDirectoryPath(dialogTitle: t.settings.selectFolder); - if (!mounted) return false; - } - if (selectedPath == null) return false; + if (selectedPath == null) return false; - if (pathType == 'file') { - final dir = Directory(selectedPath); - final isWritable = - await (widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable)(dir); - if (!mounted) return false; - if (!isWritable) { - showErrorSnackBar(context, t.settings.downloadLocationInvalid); - return false; + if (pathType == 'file') { + final dir = Directory(selectedPath); + final writableChecker = + widget.downloadDirectoryWritableChecker ?? DownloadStorageService.instance.isDirectoryWritable; + final isWritable = await writableChecker(dir); + if (!mounted) return false; + if (!isWritable) { + showErrorSnackBar(context, t.settings.downloadLocationInvalid); + return false; + } } - } - await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); - if (!mounted) return false; + await context.read().setDownloadLocation(path: selectedPath, pathType: pathType); + if (!mounted) return false; - // ignore: no-empty-block - setState triggers rebuild to reflect new download path - setState(() {}); - showSuccessSnackBar(context, t.settings.downloadLocationChanged); - return true; - } on DownloadStorageException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Download directory selection failed', error: error, stackTrace: stackTrace); - return false; - } - showSettingsFailure(context, operation: 'Download directory selection', error: error, stackTrace: stackTrace); - return false; - } + // ignore: no-empty-block - setState triggers rebuild to reflect new download path + setState(() {}); + showSuccessSnackBar(context, t.settings.downloadLocationChanged); + return true; + }, + ); + return changed ?? false; } Future _resetDownloadLocation() async { @@ -720,29 +702,15 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _handleExportSettings() async { - try { - final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); - if (!mounted || path == null) return; - showSuccessSnackBar(context, t.settings.exportSettingsSuccess); - } on SettingsExportException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings export failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings export', error: error, stackTrace: stackTrace); - } + await guardSettingsOperation( + context, + operation: 'Settings export', + body: () async { + final path = await (widget.settingsExporter ?? SettingsExportService.exportToFile)(); + if (!mounted || path == null) return; + showSuccessSnackBar(context, t.settings.exportSettingsSuccess); + }, + ); } Future _showImportSettingsDialog() async { @@ -757,51 +725,41 @@ class _SettingsScreenState extends State with FocusableTab, Moun } Future _handleImportSettings() async { - try { - final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); - if (!mounted) return; - if (result == null) return; // user cancelled file picker + await guardSettingsOperation( + context, + operation: 'Settings import', + body: () async { + // The two typed import failures carry their own message, so they are + // handled here instead of falling through to the generic guard. + try { + final result = await (widget.settingsImporter ?? SettingsExportService.importFromFile)(); + if (!mounted) return; + if (result == null) return; // user cancelled file picker - final themeProvider = context.read(); - final hiddenLibrariesProvider = context.read(); - final librariesProvider = context.read(); + final themeProvider = context.read(); + final hiddenLibrariesProvider = context.read(); + final librariesProvider = context.read(); - // Import wrote directly to SharedPreferences, bypassing `write`. Push - // fresh values into active listenables before providers re-read settings. - _settingsService.refreshListenables(); - unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); - await Future.wait([ - themeProvider.reload(), - hiddenLibrariesProvider.refresh(), - if (_keyboardService != null) _keyboardService!.refreshFromStorage(), - ]); - unawaited(librariesProvider.refresh()); + // Import wrote directly to SharedPreferences, bypassing `write`. Push + // fresh values into active listenables before providers re-read settings. + _settingsService.refreshListenables(); + unawaited(LocaleSettings.setLocale(_settingsService.read(settings.SettingsService.appLocale))); + await Future.wait([ + themeProvider.reload(), + hiddenLibrariesProvider.refresh(), + if (_keyboardService != null) _keyboardService!.refreshFromStorage(), + ]); + unawaited(librariesProvider.refresh()); - if (!mounted) return; - showSuccessSnackBar(context, t.settings.importSettingsSuccess); - } on NoUserSignedInException { - if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); - } on InvalidExportFileException { - if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile); - } on SettingsExportException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } on PlatformException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } on FileSystemException catch (error, stackTrace) { - if (!mounted) { - appLogger.e('Settings import failed', error: error, stackTrace: stackTrace); - return; - } - showSettingsFailure(context, operation: 'Settings import', error: error, stackTrace: stackTrace); - } + if (!mounted) return; + showSuccessSnackBar(context, t.settings.importSettingsSuccess); + } on NoUserSignedInException { + if (mounted) showErrorSnackBar(context, t.settings.importSettingsNoUser); + } on InvalidExportFileException { + if (mounted) showErrorSnackBar(context, t.settings.importSettingsInvalidFile); + } + }, + ); } Future _checkForUpdates() async { diff --git a/lib/screens/settings/settings_utils.dart b/lib/screens/settings/settings_utils.dart index 8443923d..7004f78e 100644 --- a/lib/screens/settings/settings_utils.dart +++ b/lib/screens/settings/settings_utils.dart @@ -56,6 +56,32 @@ void showSettingsFailure( if (context.mounted) showErrorSnackBar(context, t.settings.saveFailed); } +/// Runs [body] and reports the recoverable failures that every settings +/// file/platform operation shares — [PlatformException], [FileSystemException] +/// and the site-specific domain exception [E] — through [showSettingsFailure]. +/// Any other exception type is rethrown so programming errors are not swallowed. +/// +/// [context] is resolved before [body] starts, so a failure that lands after the +/// caller was disposed is still logged; only the snackbar is skipped. Returns +/// `null` when the operation failed. +Future guardSettingsOperation( + BuildContext context, { + required String operation, + required Future Function() body, +}) async { + try { + return await body(); + } on Object catch (error, stackTrace) { + if (error is! E && error is! PlatformException && error is! FileSystemException) rethrow; + if (context.mounted) { + showSettingsFailure(context, operation: operation, error: error, stackTrace: stackTrace); + } else { + appLogger.e('$operation failed', error: error, stackTrace: stackTrace); + } + return null; + } +} + void _showSettingsInputDialog({ required BuildContext context, required String title, diff --git a/lib/screens/settings/subtitle_styling_screen.dart b/lib/screens/settings/subtitle_styling_screen.dart index 6562e064..2ae4c00a 100644 --- a/lib/screens/settings/subtitle_styling_screen.dart +++ b/lib/screens/settings/subtitle_styling_screen.dart @@ -48,18 +48,16 @@ class SubtitleStylingScreen extends StatelessWidget { SettingsGroup( title: t.subtitlingStyling.text, children: [ - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subAssOverride, icon: Symbols.subtitles_rounded, title: t.subtitlingStyling.assOverride, subtitleBuilder: _assOverrideLabel, options: SubAssOverride.values.map((v) => DialogOption(value: v, title: _assOverrideLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), // iOS/tvOS avfoundation VO: screen vs video-resolution basis. if (Platform.isIOS) - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subtitleRenderResolution, icon: Symbols.aspect_ratio_rounded, title: t.subtitlingStyling.renderResolution, @@ -68,13 +66,11 @@ class SubtitleStylingScreen extends StatelessWidget { SubtitleRenderResolution.screen, SubtitleRenderResolution.video, ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), // Android libass overlay: full or a fractional render scale (perf knob for // render-bound low-end TVs; heavy/animated signs raster faster at < 1). if (Platform.isAndroid) - SettingSelectionTile( + SettingSelectionTile( pref: SettingsService.subtitleRenderResolution, icon: Symbols.aspect_ratio_rounded, title: t.subtitlingStyling.renderResolution, @@ -86,8 +82,6 @@ class SubtitleStylingScreen extends StatelessWidget { SubtitleRenderResolution.third, SubtitleRenderResolution.quarter, ].map((v) => DialogOption(value: v, title: _renderResolutionLabel(v))).toList(), - decode: (v) => v, - encode: (v) => v, ), SettingNumberTile( pref: SettingsService.subtitleFontSize, diff --git a/lib/screens/settings/tracker_library_filter_screen.dart b/lib/screens/settings/tracker_library_filter_screen.dart index e16a4344..88ba1082 100644 --- a/lib/screens/settings/tracker_library_filter_screen.dart +++ b/lib/screens/settings/tracker_library_filter_screen.dart @@ -86,7 +86,7 @@ class TrackerLibraryFilterScreen extends StatelessWidget { ), SettingsGroup( children: [ - SettingSegmentedTile( + SettingSegmentedTile( pref: modePref, icon: Symbols.filter_list_rounded, title: t.services.libraryFilter.mode, @@ -100,8 +100,6 @@ class TrackerLibraryFilterScreen extends StatelessWidget { label: Text(t.services.libraryFilter.modeWhitelist), ), ], - decode: (v) => v, - encode: (v) => v, ), ], ), diff --git a/lib/screens/settings/tracker_service_info.dart b/lib/screens/settings/tracker_service_info.dart new file mode 100644 index 00000000..63c81222 --- /dev/null +++ b/lib/screens/settings/tracker_service_info.dart @@ -0,0 +1,94 @@ +import 'package:flutter/widgets.dart'; +import 'package:provider/provider.dart'; + +import '../../i18n/strings.g.dart'; +import '../../models/catalog/catalog_item.dart'; +import '../../providers/trackers_provider.dart'; +import '../../providers/trakt_account_provider.dart'; +import '../../services/trackers/anilist/anilist_tracker.dart'; +import '../../services/trackers/mal/mal_tracker.dart'; +import '../../services/trackers/simkl/simkl_tracker.dart'; +import '../../services/trackers/tracker.dart'; +import '../../services/trackers/tracker_constants.dart'; +import '../../services/trakt/trakt_scrobble_service.dart'; +import 'tracker_settings_screen.dart'; +import 'trakt_settings_screen.dart'; + +/// One watch tracker, described once for every place that lists services: the +/// services hub, the rating sheet, and the settings summary line. +/// +/// [isConnected] and [username] take a [BuildContext] because each service +/// keeps its account state on a different provider; they read it with `watch`, +/// so the calling element rebuilds exactly like the per-service `Consumer` +/// these entries replaced. +class TrackerServiceInfo { + final TrackerService service; + final String displayName; + + /// Which brand mark to draw; the asset path itself lives only in + /// `CatalogSourceLogo`. + final CatalogSourceId logoSource; + + final TrackerRatingSource ratingSource; + final bool Function(BuildContext) isConnected; + final String? Function(BuildContext) username; + final Future Function(BuildContext) startConnection; + final Widget Function() buildSettingsScreen; + + const TrackerServiceInfo({ + required this.service, + required this.displayName, + required this.logoSource, + required this.ratingSource, + required this.isConnected, + required this.username, + required this.startConnection, + required this.buildSettingsScreen, + }); + + /// Entry for a service that shares [TrackerSettingsScreen]: [config] already + /// carries the name and the [TrackersProvider] accessors. + TrackerServiceInfo.shared( + TrackerConfig config, { + required this.logoSource, + required this.ratingSource, + required this.startConnection, + }) : service = config.service, + displayName = config.displayName, + isConnected = ((context) => config.isConnected(context.watch())), + username = ((context) => config.username(context.watch())), + buildSettingsScreen = (() => TrackerSettingsScreen(config: config)); + + /// Display order shared by every list. Built per call because [displayName] + /// reads the active locale. + static List get all => [ + TrackerServiceInfo( + service: TrackerService.trakt, + displayName: t.trakt.title, + logoSource: CatalogSourceId.trakt, + ratingSource: TraktScrobbleService.instance, + isConnected: (context) => context.watch().isConnected, + username: (context) => context.watch().username, + startConnection: startTraktConnection, + buildSettingsScreen: () => const TraktSettingsScreen(), + ), + TrackerServiceInfo.shared( + TrackerConfig.mal(), + logoSource: CatalogSourceId.mal, + ratingSource: MalTracker.instance, + startConnection: startMalConnection, + ), + TrackerServiceInfo.shared( + TrackerConfig.anilist(), + logoSource: CatalogSourceId.anilist, + ratingSource: AnilistTracker.instance, + startConnection: startAnilistConnection, + ), + TrackerServiceInfo.shared( + TrackerConfig.simkl(), + logoSource: CatalogSourceId.simkl, + ratingSource: SimklTracker.instance, + startConnection: startSimklConnection, + ), + ]; +} diff --git a/lib/screens/settings/tracker_settings_screen.dart b/lib/screens/settings/tracker_settings_screen.dart index 3802dffc..659f3b15 100644 --- a/lib/screens/settings/tracker_settings_screen.dart +++ b/lib/screens/settings/tracker_settings_screen.dart @@ -67,7 +67,6 @@ class TrackerConfig { final String displayName; final bool Function(TrackersProvider) isConnected; final String? Function(TrackersProvider) username; - final Pref scrobblePref; final Future Function(bool) onScrobbleChanged; final Future Function(TrackersProvider) disconnect; @@ -76,17 +75,17 @@ class TrackerConfig { required this.displayName, required this.isConnected, required this.username, - required this.scrobblePref, required this.onScrobbleChanged, required this.disconnect, }); + Pref get scrobblePref => SettingsService.scrobblePref(service); + static TrackerConfig mal() => TrackerConfig( service: TrackerService.mal, displayName: t.services.names.mal, isConnected: (a) => a.isMalConnected, username: (a) => a.malUsername, - scrobblePref: SettingsService.enableMalScrobble, onScrobbleChanged: MalTracker.instance.setEnabled, disconnect: (a) => a.disconnectMal(), ); @@ -96,7 +95,6 @@ class TrackerConfig { displayName: t.services.names.anilist, isConnected: (a) => a.isAnilistConnected, username: (a) => a.anilistUsername, - scrobblePref: SettingsService.enableAnilistScrobble, onScrobbleChanged: AnilistTracker.instance.setEnabled, disconnect: (a) => a.disconnectAnilist(), ); @@ -106,7 +104,6 @@ class TrackerConfig { displayName: t.services.names.simkl, isConnected: (a) => a.isSimklConnected, username: (a) => a.simklUsername, - scrobblePref: SettingsService.enableSimklScrobble, onScrobbleChanged: SimklTracker.instance.setEnabled, disconnect: (a) => a.disconnectSimkl(), ); diff --git a/lib/screens/settings/trakt_settings_screen.dart b/lib/screens/settings/trakt_settings_screen.dart index 2cbe95db..e1aab594 100644 --- a/lib/screens/settings/trakt_settings_screen.dart +++ b/lib/screens/settings/trakt_settings_screen.dart @@ -71,7 +71,7 @@ class TraktSettingsScreen extends StatelessWidget { service: TrackerService.trakt, toggles: [ TrackerSettingsToggle( - pref: SettingsService.enableTraktScrobble, + pref: SettingsService.scrobblePref(TrackerService.trakt), icon: Symbols.auto_timer_rounded, title: t.trakt.scrobble, subtitle: t.trakt.scrobbleDescription, diff --git a/lib/screens/video_player/parts/build.dart b/lib/screens/video_player/parts/build.dart index 9baf4e0e..bce65d67 100644 --- a/lib/screens/video_player/parts/build.dart +++ b/lib/screens/video_player/parts/build.dart @@ -42,7 +42,6 @@ extension _VideoPlayerBuildMethods on VideoPlayerScreenState { _lastVideoLayoutSize = pendingSize; _lastVideoLayoutPlayer = currentPlayer; _videoFilterManager?.updatePlayerSize(pendingSize); - _videoPIPManager?.updatePlayerSize(pendingSize); _updateAmbientLightingOnResize(pendingSize); unawaited(currentPlayer.updateFrame()); }); diff --git a/lib/screens/video_player/parts/pip.dart b/lib/screens/video_player/parts/pip.dart index 9d3ec897..aa05c811 100644 --- a/lib/screens/video_player/parts/pip.dart +++ b/lib/screens/video_player/parts/pip.dart @@ -44,7 +44,10 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { unawaited(_videoFilterManager!.updateVideoFilter()); } - _videoPIPManager ??= VideoPIPManager(player: currentPlayer, initialPlayerSize: initialPlayerSize); + _videoPIPManager ??= VideoPIPManager( + player: currentPlayer, + playerSize: () => _lastVideoLayoutPlayer == currentPlayer ? _lastVideoLayoutSize : null, + ); _videoPIPManager!.onBeforeEnterPip = _preparePipFiltersForEntry; _attachPipStateListener(); } @@ -92,7 +95,7 @@ extension _VideoPlayerPipMethods on VideoPlayerScreenState { return; } - final isInPip = _videoPIPManager?.isPipActive.value ?? PipService().isPipActive.value; + final isInPip = PipService().isPipActive.value; _setAndroidAutoPipTransitionInFlight(false, reason: 'pip_state_changed'); _recordLifecycleState('pip_state_changed', action: isInPip ? 'entered' : 'exited'); diff --git a/lib/screens/video_player/parts/playback_services.dart b/lib/screens/video_player/parts/playback_services.dart index 7ce9b50d..dcafffe7 100644 --- a/lib/screens/video_player/parts/playback_services.dart +++ b/lib/screens/video_player/parts/playback_services.dart @@ -468,7 +468,7 @@ extension _VideoPlayerPlaybackServiceMethods on VideoPlayerScreenState { final mediaControlsManager = MediaControlsManager(); _mediaControlsManager = mediaControlsManager; - final mediaControlRouter = VideoPlayerMediaControlRouter( + final mediaControlRouter = MediaControlRouter( canControlPlayback: _canControlPlayback, canNavigateMediaItems: _canNavigateMediaItems, onPlay: () { diff --git a/lib/screens/video_player/parts/shader.dart b/lib/screens/video_player/parts/shader.dart index 2f2ec96a..4361102c 100644 --- a/lib/screens/video_player/parts/shader.dart +++ b/lib/screens/video_player/parts/shader.dart @@ -22,6 +22,36 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { } } + /// Enable ambient lighting for the current video/player geometry. + /// Returns false when the aspect ratios cannot be determined yet. + Future _enableAmbientLighting(AmbientLightingService ambientLighting, ShaderProvider shaderProvider) async { + // Get video display aspect ratio + final dwidth = await player?.getProperty('dwidth'); + final dheight = await player?.getProperty('dheight'); + if (dwidth == null || dheight == null) return false; + final w = double.tryParse(dwidth); + final h = double.tryParse(dheight); + if (w == null || h == null || h == 0) return false; + final videoAspect = w / h; + + // Get player widget aspect ratio + final playerSize = _videoFilterManager?.playerSize; + if (playerSize == null || playerSize.height == 0) return false; + final outputAspect = playerSize.width / playerSize.height; + + // Clear shaders — ambient lighting and shaders are mutually exclusive + if (shaderProvider.isShaderEnabled) { + await _shaderService!.applyPreset(ShaderPreset.none); + shaderProvider.setCurrentPreset(ShaderPreset.none); + } + + // Force contain mode when enabling ambient lighting + _videoFilterManager?.resetToContain(); + + await ambientLighting.enable(videoAspect, outputAspect); + return true; + } + /// Restore ambient lighting from persisted setting Future _restoreAmbientLighting() async { if (!mounted) return; @@ -34,27 +64,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { final ambientLighting = _ambientLightingService; if (ambientLighting == null || !ambientLighting.isSupported) return; - // Same enable logic as _toggleAmbientLighting - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - _videoFilterManager?.resetToContain(); - await ambientLighting.enable(videoAspect, outputAspect); + if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return; if (mounted) _setPlayerState(() {}); } @@ -117,30 +127,7 @@ extension _VideoPlayerShaderMethods on VideoPlayerScreenState { await ambientLighting.disable(); unawaited(_videoFilterManager?.updateVideoFilter()); } else { - // Get video display aspect ratio - final dwidth = await player?.getProperty('dwidth'); - final dheight = await player?.getProperty('dheight'); - if (dwidth == null || dheight == null) return; - final w = double.tryParse(dwidth); - final h = double.tryParse(dheight); - if (w == null || h == null || h == 0) return; - final videoAspect = w / h; - - // Get player widget aspect ratio - final playerSize = _videoFilterManager?.playerSize; - if (playerSize == null || playerSize.height == 0) return; - final outputAspect = playerSize.width / playerSize.height; - - // Clear shaders — ambient lighting and shaders are mutually exclusive - if (shaderProvider.isShaderEnabled) { - await _shaderService!.applyPreset(ShaderPreset.none); - shaderProvider.setCurrentPreset(ShaderPreset.none); - } - - // Force contain mode when enabling ambient lighting - _videoFilterManager?.resetToContain(); - - await ambientLighting.enable(videoAspect, outputAspect); + if (!await _enableAmbientLighting(ambientLighting, shaderProvider)) return; } // Persist ambient lighting state diff --git a/lib/screens/video_player/widgets/player_prompt_overlays.dart b/lib/screens/video_player/widgets/player_prompt_overlays.dart index 05ebbf50..698aa541 100644 --- a/lib/screens/video_player/widgets/player_prompt_overlays.dart +++ b/lib/screens/video_player/widgets/player_prompt_overlays.dart @@ -188,85 +188,31 @@ class VideoPlayerPlayNextOverlay extends StatelessWidget { @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: PipService().isPipActive, - builder: (context, isInPip, child) { - final episode = nextEpisode; - if (isInPip || !visible || episode == null) { - return const SizedBox.shrink(); - } - return _VideoPlayerPromptPosition( - chromeController: chromeController, - child: _VideoPlayerPromptInteractionHold( - chromeController: chromeController, - focusNodes: [cancelFocusNode, confirmFocusNode], - child: _VideoPlayerPromptCard( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - _PlayNextEpisodeHeader(episode: episode), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: cancelFocusNode, - onPressed: onCancel, - autoScroll: false, - onNavigateRight: () => confirmFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onCancel, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.common.cancel), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: confirmFocusNode, - onPressed: onPlayNext, - autoScroll: false, - onNavigateLeft: () => cancelFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - useBackgroundFocus: true, - child: FilledButton( - onPressed: onPlayNext, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - if (autoPlayCountdown > 0) ...[ - Text('$autoPlayCountdown'), - const SizedBox(width: 4), - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), - ] else - Text(t.videoControls.playNext), - ], - ), - ), - ), - ), - ], - ), - ], - ), - ), - ), - ); - }, + final episode = nextEpisode; + if (episode == null) return const SizedBox.shrink(); + return _VideoPlayerPromptShell( + visible: visible, + chromeController: chromeController, + focusNodes: [cancelFocusNode, confirmFocusNode], + children: [ + _PlayNextEpisodeHeader(episode: episode), + const SizedBox(height: 12), + _VideoPlayerPromptActions( + cancelLabel: t.common.cancel, + cancelFocusNode: cancelFocusNode, + onCancel: onCancel, + confirmFocusNode: confirmFocusNode, + onConfirm: onPlayNext, + confirmChildren: [ + if (autoPlayCountdown > 0) ...[ + Text('$autoPlayCountdown'), + const SizedBox(width: 4), + const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 18), + ] else + Text(t.videoControls.playNext), + ], + ), + ], ); } } @@ -344,6 +290,49 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { required this.onContinue, }); + @override + Widget build(BuildContext context) { + return _VideoPlayerPromptShell( + visible: visible, + chromeController: chromeController, + focusNodes: [pauseFocusNode, continueFocusNode], + children: [ + Text( + t.videoControls.stillWatching, + style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), + ), + const SizedBox(height: 4), + Text( + t.videoControls.pausingIn(seconds: '$countdown'), + style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), + ), + const SizedBox(height: 12), + _VideoPlayerPromptActions( + cancelLabel: t.videoControls.pauseButton, + cancelFocusNode: pauseFocusNode, + onCancel: onPause, + confirmFocusNode: continueFocusNode, + onConfirm: onContinue, + confirmChildren: [Text('$countdown'), const SizedBox(width: 4), Text(t.videoControls.continueWatching)], + ), + ], + ); + } +} + +class _VideoPlayerPromptShell extends StatelessWidget { + final bool visible; + final PlayerChromeController chromeController; + final List focusNodes; + final List children; + + const _VideoPlayerPromptShell({ + required this.visible, + required this.chromeController, + required this.focusNodes, + required this.children, + }); + @override Widget build(BuildContext context) { return ValueListenableBuilder( @@ -356,75 +345,9 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { chromeController: chromeController, child: _VideoPlayerPromptInteractionHold( chromeController: chromeController, - focusNodes: [pauseFocusNode, continueFocusNode], + focusNodes: focusNodes, child: _VideoPlayerPromptCard( - child: Column( - mainAxisSize: .min, - crossAxisAlignment: .start, - children: [ - Text( - t.videoControls.stillWatching, - style: TextStyle(color: Colors.white.withValues(alpha: 0.7), fontSize: 12, fontWeight: .w500), - ), - const SizedBox(height: 4), - Text( - t.videoControls.pausingIn(seconds: '$countdown'), - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: .w600), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: FocusableButton( - focusNode: pauseFocusNode, - onPressed: onPause, - autoScroll: false, - onNavigateRight: () => continueFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - child: OutlinedButton( - onPressed: onPause, - style: OutlinedButton.styleFrom( - foregroundColor: Colors.white, - side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Text(t.videoControls.pauseButton), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - child: FocusableButton( - focusNode: continueFocusNode, - onPressed: onContinue, - autoScroll: false, - onNavigateLeft: () => pauseFocusNode.requestFocus(), - onNavigateUp: () {}, - onNavigateDown: () {}, - useBackgroundFocus: true, - child: FilledButton( - onPressed: onContinue, - style: FilledButton.styleFrom( - backgroundColor: Colors.white, - foregroundColor: Colors.black, - padding: const EdgeInsets.symmetric(vertical: 12), - ), - child: Row( - mainAxisAlignment: .center, - children: [ - Text('$countdown'), - const SizedBox(width: 4), - Text(t.videoControls.continueWatching), - ], - ), - ), - ), - ), - ], - ), - ], - ), + child: Column(mainAxisSize: .min, crossAxisAlignment: .start, children: children), ), ), ); @@ -433,6 +356,72 @@ class VideoPlayerStillWatchingOverlay extends StatelessWidget { } } +class _VideoPlayerPromptActions extends StatelessWidget { + final String cancelLabel; + final FocusNode cancelFocusNode; + final VoidCallback onCancel; + final FocusNode confirmFocusNode; + final VoidCallback onConfirm; + final List confirmChildren; + + const _VideoPlayerPromptActions({ + required this.cancelLabel, + required this.cancelFocusNode, + required this.onCancel, + required this.confirmFocusNode, + required this.onConfirm, + required this.confirmChildren, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Expanded( + child: FocusableButton( + focusNode: cancelFocusNode, + onPressed: onCancel, + autoScroll: false, + onNavigateRight: () => confirmFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + child: OutlinedButton( + onPressed: onCancel, + style: OutlinedButton.styleFrom( + foregroundColor: Colors.white, + side: BorderSide(color: Colors.white.withValues(alpha: 0.5)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Text(cancelLabel), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: FocusableButton( + focusNode: confirmFocusNode, + onPressed: onConfirm, + autoScroll: false, + onNavigateLeft: () => cancelFocusNode.requestFocus(), + onNavigateUp: () {}, + onNavigateDown: () {}, + useBackgroundFocus: true, + child: FilledButton( + onPressed: onConfirm, + style: FilledButton.styleFrom( + backgroundColor: Colors.white, + foregroundColor: Colors.black, + padding: const EdgeInsets.symmetric(vertical: 12), + ), + child: Row(mainAxisAlignment: .center, children: confirmChildren), + ), + ), + ), + ], + ); + } +} + class _VideoPlayerPromptPosition extends StatelessWidget { final PlayerChromeController chromeController; final Widget child; diff --git a/lib/screens/video_player_screen.dart b/lib/screens/video_player_screen.dart index 3910c919..03924649 100644 --- a/lib/screens/video_player_screen.dart +++ b/lib/screens/video_player_screen.dart @@ -58,6 +58,7 @@ import '../services/playback_source_resolver.dart'; import '../services/multi_server_manager.dart'; import '../services/offline_watch_sync_service.dart'; import '../services/display_mode_service.dart'; +import '../services/media_control_router.dart'; import '../services/settings_service.dart'; import '../services/sleep_timer_service.dart'; import '../services/track_manager.dart'; @@ -87,7 +88,6 @@ import 'video_player/completion_latch.dart'; import 'video_player/frame_rate_matcher.dart'; import 'video_player/live_stream_retry.dart'; import 'video_player/live_timeline_report.dart'; -import 'video_player/media_control_router.dart'; import 'video_player/wakelock_controller.dart'; import 'video_player/live_tv_session_args.dart'; import 'video_player/live_tv_session_state.dart'; @@ -1368,6 +1368,22 @@ class VideoPlayerScreenState extends State with WidgetsBindin return exitPosition; } + /// Pause/hide the player, flush stopped progress, restore system UI and + /// orientation, then leave the player route. No-op when the route cannot pop. + Future _exitPlayerRoute({required bool navigateHome}) async { + final navigator = Navigator.of(context); + if (!navigator.canPop()) return; + + _isExiting.value = true; + final exitPosition = await _pauseAndHidePlayerForRouteExit(); + if (!mounted) return; + await _sendStoppedProgressOnce(positionOverride: exitPosition); + if (!mounted) return; + await _restoreSystemUiAndOrientation(); + if (!mounted) return; + _finishPlayerNavigation(navigator, navigateHome: navigateHome); + } + /// Handle back button press /// For non-host participants in Watch Together, shows leave session confirmation Future _handleBackButton({bool navigateHome = false}) async { @@ -1390,36 +1406,14 @@ class VideoPlayerScreenState extends State with WidgetsBindin if (confirmed && mounted) { await _watchTogetherProvider!.leaveSession(); - if (mounted) { - final navigator = Navigator.of(context); - if (navigator.canPop()) { - _isExiting.value = true; - final exitPosition = await _pauseAndHidePlayerForRouteExit(); - if (!mounted) return; - await _sendStoppedProgressOnce(positionOverride: exitPosition); - if (!mounted) return; - await _restoreSystemUiAndOrientation(); - if (!mounted) return; - _finishPlayerNavigation(navigator, navigateHome: navigateHome); - } - } + if (mounted) await _exitPlayerRoute(navigateHome: navigateHome); } return; } // Default behavior for hosts or non-session users if (!mounted) return; - final navigator = Navigator.of(context); - if (navigator.canPop()) { - _isExiting.value = true; - final exitPosition = await _pauseAndHidePlayerForRouteExit(); - if (!mounted) return; - await _sendStoppedProgressOnce(positionOverride: exitPosition); - if (!mounted) return; - await _restoreSystemUiAndOrientation(); - if (!mounted) return; - _finishPlayerNavigation(navigator, navigateHome: navigateHome); - } + await _exitPlayerRoute(navigateHome: navigateHome); } finally { _isHandlingBack = false; } diff --git a/lib/services/companion_remote/companion_remote_host_controller.dart b/lib/services/companion_remote/companion_remote_host_controller.dart index 822f592e..0c2f8882 100644 --- a/lib/services/companion_remote/companion_remote_host_controller.dart +++ b/lib/services/companion_remote/companion_remote_host_controller.dart @@ -9,33 +9,40 @@ import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../utils/app_logger.dart'; +/// Resolves the active profile's Plex identity and primes companion-remote +/// crypto with it, returning whether crypto ended up ready. +/// +/// Crypto is an app-level service, not bound to any one widget: everything the +/// bootstrap needs is captured up front, so an unmount mid-await must not abort +/// work the user asked for. Hence no `context.mounted` guards below. +Future ensureCompanionRemoteCryptoFromContext(BuildContext context) async { + final companionRemote = context.read(); + final connections = context.read(); + final activeProfile = context.read(); + final profileConnections = context.read(); + final plexHome = context.read(); + final identity = await resolveActivePlexIdentity( + activeProfile: activeProfile, + connections: connections, + profileConnections: profileConnections, + ); + final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); + return companionRemote.ensureCryptoReady( + home, + connections: connections, + activeProfile: activeProfile, + profileConnections: profileConnections, + identity: identity, + plexHomeForConnection: plexHome.materializePlexHomeForConnection, + ); +} + Future startCompanionRemoteHost(BuildContext context) async { final companionRemote = context.read(); if (companionRemote.isHostServerRunning) return true; try { - // The host is an app-level service, not bound to this widget: everything - // it needs is captured up front, so an unmount mid-await must not abort a - // start the user asked for. Hence no `context.mounted` guards below. - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - final home = identity == null ? null : await plexHome.materializePlexHomeForConnection(identity.account.id); - final ok = await companionRemote.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); - if (!ok) return false; + if (!await ensureCompanionRemoteCryptoFromContext(context)) return false; await companionRemote.startHostServer(); return companionRemote.isHostServerRunning; diff --git a/lib/services/device_performance.dart b/lib/services/device_performance.dart index 7b50dabc..3c5a4304 100644 --- a/lib/services/device_performance.dart +++ b/lib/services/device_performance.dart @@ -4,6 +4,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/painting.dart'; import 'package:flutter/services.dart'; +import '../utils/async_singleton.dart'; +import '../utils/device_channel.dart'; import '../utils/platform_detector.dart'; /// User override for the visual-effects tier (stored by SettingsService). @@ -19,11 +21,9 @@ enum VisualEffectsSetting { auto, full, reduced } class DevicePerformance { DevicePerformance._(); - static DevicePerformance? _instance; - static Future? _initialization; + static final AsyncSingleton _singleton = AsyncSingleton(); @visibleForTesting - static Future? debugDetectionGate; - static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device'); + static set debugDetectionGate(Future? value) => _singleton.debugGate = value; /// ~2.2 GiB: above what 2 GB boxes report (≤ ~1.95 GiB after kernel /// reservations), below 3 GB Shield-class devices (~2.8 GiB). @@ -39,35 +39,13 @@ class DevicePerformance { /// Get the singleton, detecting hardware signals on first call. /// [override] is the persisted SettingsService.visualEffects value. - static Future getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) async { - final existing = _instance; - if (existing != null) { - final initialization = _initialization; - if (initialization != null) await initialization; - return existing; - } - - final instance = DevicePerformance._().._override = override; - _instance = instance; - final initialization = instance._detect(); - _initialization = initialization; - try { - await initialization; - } catch (_) { - if (identical(_instance, instance)) _instance = null; - rethrow; - } finally { - if (identical(_initialization, initialization)) _initialization = null; - } - return instance; - } + static Future getInstance({VisualEffectsSetting override = VisualEffectsSetting.auto}) => + _singleton.getInstance(() => DevicePerformance._().._override = override, (instance) => instance._detect()); Future _detect() async { - final gate = debugDetectionGate; - if (gate != null) await gate; if (!Platform.isAndroid) return; // tvOS/iOS/desktop: always full tier try { - final result = await _deviceChannel.invokeMapMethod('getPerformanceSignals'); + final result = await deviceChannel.invokeMapMethod('getPerformanceSignals'); if (result == null) return; _is64Bit = result['is64Bit'] == true; _isLowRam = result['isLowRamDevice'] == true; @@ -85,7 +63,7 @@ class DevicePerformance { /// Total device RAM as reported by the platform, or null off-Android / /// before init. Used to scale memory-watchdog thresholds to the device. - static int? get totalMemBytes => _instance?._totalMemBytes; + static int? get totalMemBytes => _singleton.instance?._totalMemBytes; /// Auto-detected low-end hardware (32-bit process / low-RAM / ≤2.2 GiB), /// independent of the visual-effects override. Use this for decisions tied to @@ -93,11 +71,11 @@ class DevicePerformance { /// boxes lagging a GL subtitle overlay — where a user's effects preference is /// irrelevant. Safe before init (returns false). See [isReduced] for the /// effects-tier gate that the override can force. - static bool get isLowEndHardware => _instance?._autoReduced ?? false; + static bool get isLowEndHardware => _singleton.instance?._autoReduced ?? false; /// Primary gate for effect chokepoints. Safe before init (full tier). static bool get isReduced { - final instance = _instance; + final instance = _singleton.instance; if (instance == null) return false; return switch (instance._override) { VisualEffectsSetting.auto => instance._autoReduced, @@ -112,7 +90,7 @@ class DevicePerformance { /// Update the user override from the settings screen and re-apply the /// budgets that were computed at boot. static void setOverrideSync(VisualEffectsSetting value) { - _instance?._override = value; + _singleton.instance?._override = value; applyImageCacheBudget(); } @@ -142,7 +120,7 @@ class DevicePerformance { /// Raw signals are always included (even when the tier is forced) so an /// uploaded log answers "did the reduced tier engage, and why / why not". static String describeSync() { - final instance = _instance; + final instance = _singleton.instance; if (instance == null) return 'unknown'; final tier = isReduced ? 'reduced' : 'full'; final signals = [ @@ -158,14 +136,13 @@ class DevicePerformance { @visibleForTesting static void debugReset({bool? autoReduced, VisualEffectsSetting? override}) { - _initialization = null; - debugDetectionGate = null; if (autoReduced == null && override == null) { - _instance = null; + _singleton.debugReset(); return; } - _instance ??= DevicePerformance._(); - if (autoReduced != null) _instance!._autoReduced = autoReduced; - if (override != null) _instance!._override = override; + final instance = _singleton.instance ?? DevicePerformance._(); + _singleton.debugReset(instance: instance); + if (autoReduced != null) instance._autoReduced = autoReduced; + if (override != null) instance._override = override; } } diff --git a/lib/services/download_manager_service.dart b/lib/services/download_manager_service.dart index e9d04e40..f3103e66 100644 --- a/lib/services/download_manager_service.dart +++ b/lib/services/download_manager_service.dart @@ -1323,46 +1323,23 @@ class DownloadManagerService { ); } - var artworkSettled = !queueItem.downloadArtwork; - if (queueItem.downloadArtwork) { - final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); - final chapterArtworkSettled = metadata.serverId == null - ? false - : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); - artworkSettled = itemArtworkSettled && chapterArtworkSettled; - } - - var subtitlesSettled = !queueItem.downloadSubtitles; - if (queueItem.downloadSubtitles) { - try { - final resolution = await client.resolveDownload( - metadata, - mediaIndex: record?.mediaIndex ?? 0, - mediaSourceId: record?.mediaSourceId, - ); - if (resolution.externalSubtitlesResolved) { - subtitlesSettled = await _downloadSubtitles( - globalKey, - metadata, - resolution.externalSubtitles, - client, - showYear: showYear, - ); - } else { - appLogger.d('Subtitle enrichment remains deferred for $globalKey'); - } - } catch (e, st) { - appLogger.w('Could not resolve subtitles for deferred download: $globalKey', error: e, stackTrace: st); - } - } - if (artworkSettled && subtitlesSettled) { + final settled = await _runSupplementaryDownloads( + globalKey, + metadata, + client, + downloadArtwork: queueItem.downloadArtwork, + downloadSubtitles: queueItem.downloadSubtitles, + record: record, + showYear: showYear, + ); + if (settled.artwork && settled.subtitles) { await _database.removeFromQueue(globalKey); appLogger.i('Deferred supplementary downloads completed for $globalKey'); } else { await _database.updateSupplementaryQueueIntent( globalKey, - downloadSubtitles: !subtitlesSettled, - downloadArtwork: !artworkSettled, + downloadSubtitles: !settled.subtitles, + downloadArtwork: !settled.artwork, ); } } catch (e, st) { @@ -1591,17 +1568,8 @@ class DownloadManagerService { if (client != null) unawaited(_processQueue(client)); } - Future _cancelNativeTask(String globalKey, String taskId, {required String reason}) async { - if (!downloadsSupported || taskId.isEmpty) return; - try { - final cancelled = await FileDownloader().cancelTaskWithId(taskId); - if (cancelled) { - appLogger.d('Cancelled native task $taskId for $globalKey ($reason)'); - } - } catch (e) { - appLogger.w('Failed to cancel native task $taskId for $globalKey ($reason)', error: e); - } - } + Future _cancelNativeTask(String globalKey, String taskId, {required String reason}) => + _cancelNativeTaskIds(globalKey, [taskId], reason: reason); Future _cancelNativeTasksForGlobalKey( String globalKey, { @@ -1624,16 +1592,7 @@ class DownloadManagerService { appLogger.w('Failed to enumerate native tasks for $globalKey ($reason)', error: e); } - if (taskIds.isEmpty) return; - - try { - final cancelled = await FileDownloader().cancelTasksWithIds(taskIds); - if (cancelled) { - appLogger.d('Cancelled ${taskIds.length} native task(s) for $globalKey ($reason): ${taskIds.join(', ')}'); - } - } catch (e) { - appLogger.w('Failed to cancel native tasks for $globalKey ($reason): ${taskIds.join(', ')}', error: e); - } + await _cancelNativeTaskIds(globalKey, taskIds, reason: reason); } Future _downloadForCurrentTaskSession( @@ -2390,36 +2349,20 @@ class DownloadManagerService { try { final metadata = ctx?.metadata ?? await _resolveMetadata(globalKey); final client = ctx?.client ?? await _getClientForDownloadKey(globalKey); - final showYear = ctx?.showYear; if (metadata != null && client != null) { - if (downloadArtwork) { - final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); - final chapterArtworkSettled = metadata.serverId == null - ? false - : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); - artworkSettled = itemArtworkSettled && chapterArtworkSettled; - } - if (downloadSubtitles) { - var subtitles = ctx?.subtitles; - if (subtitles == null) { - try { - final resolution = await client.resolveDownload( - metadata, - mediaIndex: existingCheck.mediaIndex, - mediaSourceId: existingCheck.mediaSourceId, - ); - if (resolution.externalSubtitlesResolved) { - subtitles = resolution.externalSubtitles; - } - } catch (e, st) { - appLogger.w('Could not re-resolve subtitles for $globalKey', error: e, stackTrace: st); - } - } - if (subtitles != null) { - subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); - } - } + final settled = await _runSupplementaryDownloads( + globalKey, + metadata, + client, + downloadArtwork: downloadArtwork, + downloadSubtitles: downloadSubtitles, + record: existingCheck, + showYear: ctx?.showYear, + preresolvedSubtitles: ctx?.subtitles, + ); + artworkSettled = settled.artwork; + subtitlesSettled = settled.subtitles; } } catch (e, st) { appLogger.w('Supplementary downloads failed for $globalKey (video is saved)', error: e, stackTrace: st); @@ -2516,6 +2459,58 @@ class DownloadManagerService { return _fetchShowYear(ServerId(serverId), metadata.grandparentId, clientScopeId: clientScopeId); } + /// Best-effort supplementary work for an already-stored video (artwork, + /// chapter thumbnails, external subtitles); reports which half settled so the + /// caller can do its own queue-row bookkeeping. Shared by the completion path + /// and the deferred-repair path: [record] carries the media-source + /// coordinates for re-resolving subtitles, [preresolvedSubtitles] skips that + /// re-resolve, and [showYear] is caller-supplied because the paths differ. + Future<({bool artwork, bool subtitles})> _runSupplementaryDownloads( + String globalKey, + MediaItem metadata, + MediaServerClient client, { + required bool downloadArtwork, + required bool downloadSubtitles, + required DownloadedMediaItem? record, + required int? showYear, + List? preresolvedSubtitles, + }) async { + var artworkSettled = !downloadArtwork; + if (downloadArtwork) { + final itemArtworkSettled = await _downloadArtwork(globalKey, metadata, client); + final chapterArtworkSettled = metadata.serverId == null + ? false + : await _downloadChapterThumbnails(ServerId(metadata.serverId!), metadata.id, client); + artworkSettled = itemArtworkSettled && chapterArtworkSettled; + } + + var subtitlesSettled = !downloadSubtitles; + if (downloadSubtitles) { + try { + var subtitles = preresolvedSubtitles; + if (subtitles == null) { + final resolution = await client.resolveDownload( + metadata, + mediaIndex: record?.mediaIndex ?? 0, + mediaSourceId: record?.mediaSourceId, + ); + if (resolution.externalSubtitlesResolved) { + subtitles = resolution.externalSubtitles; + } else { + appLogger.d('Subtitle enrichment remains deferred for $globalKey'); + } + } + if (subtitles != null) { + subtitlesSettled = await _downloadSubtitles(globalKey, metadata, subtitles, client, showYear: showYear); + } + } catch (e, st) { + appLogger.w('Could not resolve subtitles for $globalKey', error: e, stackTrace: st); + } + } + + return (artwork: artworkSettled, subtitles: subtitlesSettled); + } + Future _downloadArtwork(String globalKey, MediaItem metadata, MediaServerClient client) async { if (metadata.serverId == null) return false; @@ -3266,24 +3261,39 @@ class DownloadManagerService { } } - Future _deleteMovieStorageDirectory(MediaItem movie) async { + /// Delete one media directory and everything under it, on either storage backend. + /// [safComponents] and [fileDirectory] are thunks so only the branch that runs + /// resolves its path — the file-mode getters create the directory as a side effect. + Future _deleteStorageDirectory({ + required List Function() safComponents, + required Future Function() fileDirectory, + required String label, + }) async { if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; - final movieDir = await _safStorage.getChild(safBaseUri, _storageService.getMovieSafPathComponents(movie)); - if (movieDir != null) { - await _deleteSafDirRecursive(movieDir.uri, description: 'movie directory'); + final dir = await _safStorage.getChild(safBaseUri, safComponents()); + if (dir != null) { + await _deleteSafDirRecursive(dir.uri, description: '$label directory'); } return; } - final movieDir = await _storageService.getMovieDirectory(movie); - if (await movieDir.exists()) { - await movieDir.delete(recursive: true); - appLogger.i('Deleted movie directory: ${movieDir.path}'); + final dir = await fileDirectory(); + if (await dir.exists()) { + await dir.delete(recursive: true); + appLogger.i('Deleted $label directory: ${dir.path}'); } } + Future _deleteMovieStorageDirectory(MediaItem movie) { + return _deleteStorageDirectory( + safComponents: () => _storageService.getMovieSafPathComponents(movie), + fileDirectory: () => _storageService.getMovieDirectory(movie), + label: 'movie', + ); + } + Future<_EpisodeStorageDeletion> _deleteEpisodeStorageVideo( MediaItem episode, { required int? showYear, @@ -3330,50 +3340,32 @@ class DownloadManagerService { } Future _deleteSeasonStorageDirectory(MediaItem season, int? showYear) async { + await _deleteStorageDirectory( + safComponents: () => _storageService.getSeasonSafPathComponents(season, showYear: showYear), + fileDirectory: () => _storageService.getSeasonDirectory(season, showYear: showYear), + label: 'season', + ); + + // Drop the parent show directory too if the deleted season left it empty. if (_storageService.isUsingSaf) { final safBaseUri = _storageService.safBaseUri; if (safBaseUri == null) return; - final seasonDir = await _safStorage.getChild( - safBaseUri, - _storageService.getSeasonSafPathComponents(season, showYear: showYear), - ); - if (seasonDir != null) { - await _deleteSafDirRecursive(seasonDir.uri, description: 'season directory'); - } final showDir = await _safStorage.getChild( safBaseUri, _storageService.getShowSafPathComponents(season, showYear: showYear), ); - if (showDir != null) { - await _deleteEmptySafDirsInOrder([showDir.uri]); - } + await _deleteEmptySafDirsInOrder([showDir?.uri]); return; } - - final seasonDir = await _storageService.getSeasonDirectory(season, showYear: showYear); - if (await seasonDir.exists()) { - await seasonDir.delete(recursive: true); - appLogger.i('Deleted season directory: ${seasonDir.path}'); - } await _cleanupShowDirectory(season, showYear); } - Future _deleteShowStorageDirectory(MediaItem show) async { - if (_storageService.isUsingSaf) { - final safBaseUri = _storageService.safBaseUri; - if (safBaseUri == null) return; - final showDir = await _safStorage.getChild(safBaseUri, _storageService.getShowSafPathComponents(show)); - if (showDir != null) { - await _deleteSafDirRecursive(showDir.uri, description: 'show directory'); - } - return; - } - - final showDir = await _storageService.getShowDirectory(show); - if (await showDir.exists()) { - await showDir.delete(recursive: true); - appLogger.i('Deleted show directory: ${showDir.path}'); - } + Future _deleteShowStorageDirectory(MediaItem show) { + return _deleteStorageDirectory( + safComponents: () => _storageService.getShowSafPathComponents(show), + fileDirectory: () => _storageService.getShowDirectory(show), + label: 'show', + ); } /// Safety net: after metadata-based deletion, verify the actual DB-recorded diff --git a/lib/services/downloaded_video_source.dart b/lib/services/downloaded_video_source.dart new file mode 100644 index 00000000..c343e2e3 --- /dev/null +++ b/lib/services/downloaded_video_source.dart @@ -0,0 +1,77 @@ +import 'dart:io'; + +import '../database/app_database.dart'; +import '../models/download_models.dart'; +import '../utils/app_logger.dart'; +import '../utils/downloaded_version_match.dart'; +import 'download_storage_service.dart'; + +/// A downloaded copy resolved to a playable location, plus the version that is +/// actually on disk — which can differ from the requested one when +/// [resolveDownloadedVideoSource] was allowed to fall back. +typedef DownloadedVideoSource = ({String path, int mediaIndex, String? mediaSourceId}); + +/// Single source of truth for "where is the playable copy of this downloaded +/// row, and is it the version that was asked for". +/// +/// Returns null when the row cannot back playback: the download is not +/// complete, it holds a different version than requested (unless +/// [allowAnyDownloadedVersion]), it has no stored video path, or the stored +/// file is gone from disk. +/// +/// Version matching is strict by default so online flows keep streaming an +/// explicitly requested non-downloaded version (issue #1440). With +/// [allowAnyDownloadedVersion] the downloaded version is returned on mismatch +/// instead — for offline flows where the alternative is failing outright. +/// +/// Callers own their own preconditions (profile ownership, how the row was +/// looked up); this only judges the row itself. +Future resolveDownloadedVideoSource( + DownloadedMediaItem row, { + int? requestedMediaIndex, + String? requestedMediaSourceId, + bool allowAnyDownloadedVersion = false, +}) async { + if (row.status != DownloadStatus.completed.index) { + appLogger.d('Download not complete for ${row.globalKey}. Status: ${row.status}'); + return null; + } + + if (!downloadedVersionMatches( + row, + requestedMediaIndex: requestedMediaIndex, + requestedMediaSourceId: requestedMediaSourceId, + )) { + if (!allowAnyDownloadedVersion) { + appLogger.d( + '[VersionTrace] Downloaded copy of ${row.globalKey} is version ${row.mediaIndex} ' + '(source ${row.mediaSourceId}), but requested version $requestedMediaIndex ' + '(source ${requestedMediaSourceId?.trim()}) — skipping offline', + ); + return null; + } + appLogger.d( + '[VersionTrace] Requested version $requestedMediaIndex (source ${requestedMediaSourceId?.trim()}) ' + 'is not downloaded — falling back to downloaded version ${row.mediaIndex} ' + '(source ${row.mediaSourceId})', + ); + } + + final storedPath = row.videoFilePath; + if (storedPath == null) { + appLogger.d('Video file path is null for ${row.globalKey}'); + return null; + } + + final storageService = DownloadStorageService.instance; + // SAF URIs (content://) are already playable and come back untouched; file + // paths may be stored relative, so resolve them and confirm they still exist. + final readablePath = await storageService.getReadablePath(storedPath); + if (!storageService.isSafUri(storedPath) && !await File(readablePath).exists()) { + appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)'); + return null; + } + + appLogger.d('Found offline video: $readablePath'); + return (path: readablePath, mediaIndex: row.mediaIndex, mediaSourceId: row.mediaSourceId); +} diff --git a/lib/services/fullscreen_state_manager.dart b/lib/services/fullscreen_state_manager.dart index 3eec20be..60b044e3 100644 --- a/lib/services/fullscreen_state_manager.dart +++ b/lib/services/fullscreen_state_manager.dart @@ -30,70 +30,22 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { Future toggleFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - final isCurrentlyFullscreen = await MacOSWindowService.isFullscreen(); - if (isCurrentlyFullscreen) { - await MacOSWindowService.exitFullscreen(); - } else { - await MacOSWindowService.enterFullscreen(); - } - } else if (Platform.isWindows) { - // Route through the native Win32 runner, which restores to the monitor - // the window is currently on (window_manager 0.5.1 picks the wrong one - // on multi-monitor setups — see issue #880). The native code also - // preserves maximized state internally, so no unmaximize dance here. - final isCurrentlyFullscreen = await NativeWindowService.isFullScreen(); - await NativeWindowService.setFullScreen(!isCurrentlyFullscreen); - } else { - final isCurrentlyFullscreen = await windowManager.isFullScreen(); - if (isCurrentlyFullscreen) { - await windowManager.setFullScreen(false); - if (_wasMaximized) { - await windowManager.maximize(); - _wasMaximized = false; - } - } else { - _wasMaximized = await windowManager.isMaximized(); - if (_wasMaximized) { - await windowManager.unmaximize(); - } - await windowManager.setFullScreen(true); - } - } + final isCurrentlyFullscreen = await _platformIsFullscreen(); + await _platformSetFullscreen(!isCurrentlyFullscreen); } /// Enter fullscreen, preserving maximized state on Windows/Linux for restoration on exit. Future enterFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - await MacOSWindowService.enterFullscreen(); - } else if (Platform.isWindows) { - await NativeWindowService.setFullScreen(true); - } else { - _wasMaximized = await windowManager.isMaximized(); - if (_wasMaximized) { - await windowManager.unmaximize(); - } - await windowManager.setFullScreen(true); - } + await _platformSetFullscreen(true); } /// Exit fullscreen, restoring maximized state if needed Future exitFullscreen() async { if (!PlatformDetector.isDesktopOS()) return; - if (Platform.isMacOS) { - await MacOSWindowService.exitFullscreen(); - } else if (Platform.isWindows) { - await NativeWindowService.setFullScreen(false); - } else { - await windowManager.setFullScreen(false); - if (_wasMaximized) { - await windowManager.maximize(); - _wasMaximized = false; - } - } + await _platformSetFullscreen(false); } /// Exits fullscreen when the platform window is currently fullscreen. @@ -103,17 +55,47 @@ class FullscreenStateManager extends ChangeNotifier with WindowListener { Future exitFullscreenIfActive() async { if (!PlatformDetector.isDesktopOS()) return false; - final isActive = Platform.isMacOS - ? await MacOSWindowService.isFullscreen() - : Platform.isWindows - ? await NativeWindowService.isFullScreen() - : await windowManager.isFullScreen(); + final isActive = await _platformIsFullscreen(); if (!isActive) return false; - await exitFullscreen(); + await _platformSetFullscreen(false); return true; } + Future _platformIsFullscreen() { + if (Platform.isMacOS) return MacOSWindowService.isFullscreen(); + if (Platform.isWindows) return NativeWindowService.isFullScreen(); + return windowManager.isFullScreen(); + } + + Future _platformSetFullscreen(bool value) async { + if (Platform.isMacOS) { + if (value) { + await MacOSWindowService.enterFullscreen(); + } else { + await MacOSWindowService.exitFullscreen(); + } + } else if (Platform.isWindows) { + // Route through the native Win32 runner, which restores to the monitor + // the window is currently on (window_manager 0.5.1 picks the wrong one + // on multi-monitor setups — see issue #880). The native code also + // preserves maximized state internally, so no unmaximize dance here. + await NativeWindowService.setFullScreen(value); + } else if (value) { + _wasMaximized = await windowManager.isMaximized(); + if (_wasMaximized) { + await windowManager.unmaximize(); + } + await windowManager.setFullScreen(true); + } else { + await windowManager.setFullScreen(false); + if (_wasMaximized) { + await windowManager.maximize(); + _wasMaximized = false; + } + } + } + void startMonitoring() { if (!_shouldMonitor() || _isListening) return; diff --git a/lib/services/jellyfin_client.dart b/lib/services/jellyfin_client.dart index 9353ad80..a34205cf 100644 --- a/lib/services/jellyfin_client.dart +++ b/lib/services/jellyfin_client.dart @@ -76,6 +76,20 @@ part 'jellyfin_client/parts/live_tv.dart'; part 'jellyfin_client/parts/images_downloads.dart'; part 'jellyfin_client/parts/metadata_edit.dart'; +/// Canonical declarations of the [JellyfinClient] internals that the `part` +/// mixins call into. +/// +/// Every part mixin is `on _JellyfinClientInternals`, so each shared member is +/// declared exactly once here instead of being re-declared per file. Members +/// used by a single part stay declared in that part. +mixin _JellyfinClientInternals on MediaServerCacheMixin { + JellyfinConnection get connection; + FailoverHttpClient get _http; + MediaItem? _mapItem(Map json); + List _mapItems(Iterable> items); + String? _absolutizeImagePath(String? path); +} + /// [MediaServerClient] over a Jellyfin server. /// /// Constructs from a [JellyfinConnection] and a [MediaServerHttpClient] (the @@ -85,6 +99,7 @@ part 'jellyfin_client/parts/metadata_edit.dart'; class JellyfinClient with MediaServerCacheMixin, + _JellyfinClientInternals, _JellyfinBrowseMethods, _JellyfinMusicMethods, _JellyfinPlaybackMethods, diff --git a/lib/services/jellyfin_client/parts/browse.dart b/lib/services/jellyfin_client/parts/browse.dart index f53ddc61..86d7c143 100644 --- a/lib/services/jellyfin_client/parts/browse.dart +++ b/lib/services/jellyfin_client/parts/browse.dart @@ -30,6 +30,27 @@ List> _itemsArray(Object? data) { return const []; } +/// Builds a [LibraryPage] from an `/Items`-shaped response: the `Items` array +/// run through [map], plus the server's `TotalRecordCount` when it reports one. +/// Responses that omit it (or return a non-int) fall back to +/// [fallbackPageTotal], whose full-page sentinel keeps pagination enabled; +/// [singlePage] endpoints return everything at once, so a full page there means +/// the end of the list, not "there may be more". +LibraryPage _pagedItems( + Object? data, { + required int offset, + required List Function(List>) map, + int? requestedSize, + bool singlePage = false, +}) { + final rawItems = _itemsArray(data); + final rawTotal = data is Map ? data['TotalRecordCount'] : null; + final fallbackTotal = singlePage + ? offset + rawItems.length + : fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); + return LibraryPage(items: map(rawItems), totalCount: rawTotal is int ? rawTotal : fallbackTotal, offset: offset); +} + /// Slim field set for grid/list browsing — what the card UI actually /// renders (title, year, watched badge, episode count for series). /// @@ -145,12 +166,7 @@ const _detailFields = // any extra round-trip. 'ProviderIds'; -mixin _JellyfinBrowseMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - MediaItem? _mapItem(Map json); - List _mapItems(Iterable> items); - +mixin _JellyfinBrowseMethods on _JellyfinClientInternals { // Endpoint conventions follow what the official Jellyfin Kotlin SDK // generates (cross-checked against the Findroid client). The SDK mixes // `/Users/{userId}/...` for "user library" / "views" / "latest" / "single @@ -700,7 +716,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { final items = _itemsArray(data); final rawTotal = data is Map ? data['TotalRecordCount'] : null; if (items.isNotEmpty || (rawTotal is int && rawTotal > 0)) { - return _pagedMediaItems(data, offset: offset, requestedSize: pageSize); + return _pagedItems(data, offset: offset, requestedSize: pageSize, map: _mapItems); } } } on MediaServerHttpException { @@ -722,7 +738,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } Future> fetchSeasonEpisodesPage( @@ -754,7 +770,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } /// Jellyfin folder browsing mirrors Jellyfin Web/Findroid/Swiftfin: query @@ -925,27 +941,19 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { required String includeItemTypes, bool byAlbumArtist = false, AbortController? abort, - }) async { - final all = []; - var start = 0; - while (true) { - abort?.throwIfAborted(); - final page = await _fetchPlayableDescendantsPage( + }) { + return drainPages( + (start, size) => _fetchPlayableDescendantsPage( parentId, start: start, - size: _pagedListPageSize, + size: size, abort: abort, includeItemTypes: includeItemTypes, byAlbumArtist: byAlbumArtist, - ); - abort?.throwIfAborted(); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - abort?.throwIfAborted(); - return all; + ), + pageSize: _pagedListPageSize, + abort: abort, + ); } @override @@ -991,7 +999,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } /// All episodes of a series in the app's **aired watch order** — primarily by @@ -1146,18 +1154,10 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } @override - Future> fetchPersonMedia(String personId) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPersonMediaPage(personId, start: start, size: _pagedListPageSize); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchPersonMedia(String personId) => drainPages( + (start, size) => fetchPersonMediaPage(personId, start: start, size: size), + pageSize: _pagedListPageSize, + ); @override Future> fetchPersonMediaPage( @@ -1186,7 +1186,7 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _pagedMediaItems(response.data, offset: offset, requestedSize: pageSize); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } @override @@ -1637,16 +1637,12 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { try { final response = await _http.get(path, queryParameters: queryParameters, abort: abort); throwIfHttpError(response); - final data = response.data; - final rawItems = data is List ? data.whereType>().toList() : _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = singlePage - ? offset + rawItems.length - : fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - return LibraryPage( - items: _mapItems(rawItems), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, + return _pagedItems( + response.data, offset: offset, + requestedSize: requestedSize, + singlePage: singlePage, + map: _mapItems, ); } catch (e, st) { appLogger.w('JellyfinClient: $path failed', error: e, stackTrace: st); @@ -1654,17 +1650,6 @@ mixin _JellyfinBrowseMethods on MediaServerCacheMixin { } } - LibraryPage _pagedMediaItems(Object? data, {required int offset, required int requestedSize}) { - final rawItems = _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - return LibraryPage( - items: _mapItems(rawItems), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, - offset: offset, - ); - } - @override Future> fetchRelatedHubs(String id, {int count = 10}) async { final response = await _http.get( diff --git a/lib/services/jellyfin_client/parts/collections.dart b/lib/services/jellyfin_client/parts/collections.dart index bdfa44c2..8e3f8f29 100644 --- a/lib/services/jellyfin_client/parts/collections.dart +++ b/lib/services/jellyfin_client/parts/collections.dart @@ -1,27 +1,15 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinCollectionMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - List _mapItems(Iterable> items); - +mixin _JellyfinCollectionMethods on _JellyfinClientInternals { static const int _collectionsPageSize = 36; String? _boxSetsViewId; @override - Future> fetchCollections(String libraryId) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchCollectionsPage(libraryId, start: start, size: _collectionsPageSize); - all.addAll(page.items); - if (page.items.isEmpty) break; - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchCollections(String libraryId) => drainPages( + (start, size) => fetchCollectionsPage(libraryId, start: start, size: size), + pageSize: _collectionsPageSize, + ); @override Future> fetchCollectionsPage( @@ -54,7 +42,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _itemsPage(response.data, offset: s, requestedSize: pageSize); + return _pagedItems(response.data, offset: s, requestedSize: pageSize, map: _mapItems); } Future _fetchBoxSetsViewId({AbortController? abort}) async { @@ -73,14 +61,6 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { return null; } - LibraryPage _itemsPage(Object? data, {required int offset, int? requestedSize}) { - final rawItems = _itemsArray(data); - final rawTotal = data is Map ? data['TotalRecordCount'] : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: rawItems.length, requestedSize: requestedSize); - final total = rawTotal is int ? rawTotal : fallbackTotal; - return LibraryPage(items: _mapItems(rawItems), totalCount: total, offset: offset); - } - @override Future> fetchCollectionPage( String collectionId, { @@ -104,7 +84,7 @@ mixin _JellyfinCollectionMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - return _itemsPage(response.data, offset: s, requestedSize: size); + return _pagedItems(response.data, offset: s, requestedSize: size, map: _mapItems); } @override diff --git a/lib/services/jellyfin_client/parts/file_info.dart b/lib/services/jellyfin_client/parts/file_info.dart index 0360cc64..77d738b5 100644 --- a/lib/services/jellyfin_client/parts/file_info.dart +++ b/lib/services/jellyfin_client/parts/file_info.dart @@ -1,6 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinFileInfoMethods on MediaServerCacheMixin { +mixin _JellyfinFileInfoMethods on _JellyfinClientInternals { @override Future getFileInfo(MediaItem item) async { // Lightweight browse responses omit `MediaSources`; detail and some cached diff --git a/lib/services/jellyfin_client/parts/images_downloads.dart b/lib/services/jellyfin_client/parts/images_downloads.dart index 5d8ea676..9e26c7ab 100644 --- a/lib/services/jellyfin_client/parts/images_downloads.dart +++ b/lib/services/jellyfin_client/parts/images_downloads.dart @@ -1,7 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinImageDownloadMethods on MediaServerCacheMixin { - JellyfinConnection get connection; +mixin _JellyfinImageDownloadMethods on _JellyfinClientInternals { Future fetchPlaybackBundle( String itemId, { int sourceIndex = 0, diff --git a/lib/services/jellyfin_client/parts/live_tv.dart b/lib/services/jellyfin_client/parts/live_tv.dart index 471f9353..c828e97a 100644 --- a/lib/services/jellyfin_client/parts/live_tv.dart +++ b/lib/services/jellyfin_client/parts/live_tv.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinLiveTvMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - String? _absolutizeImagePath(String? path); +mixin _JellyfinLiveTvMethods on _JellyfinClientInternals { Future>> _safeFetchItemsArray( String path, Map queryParameters, { diff --git a/lib/services/jellyfin_client/parts/metadata_edit.dart b/lib/services/jellyfin_client/parts/metadata_edit.dart index 926cf395..5e8a29d2 100644 --- a/lib/services/jellyfin_client/parts/metadata_edit.dart +++ b/lib/services/jellyfin_client/parts/metadata_edit.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinMetadataEditMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinMetadataEditMethods on _JellyfinClientInternals { Future?> fetchEditableMetadataItem(String itemId) async { if (isOfflineMode) return null; final response = await _http.get('/Users/${_segment(connection.userId)}/Items/${_segment(itemId)}'); diff --git a/lib/services/jellyfin_client/parts/music.dart b/lib/services/jellyfin_client/parts/music.dart index ec556d94..81f293ac 100644 --- a/lib/services/jellyfin_client/parts/music.dart +++ b/lib/services/jellyfin_client/parts/music.dart @@ -4,11 +4,7 @@ part of '../../jellyfin_client.dart'; /// listings, instant mix, and lyrics. Endpoint conventions follow the /// Jellyfin web client's music surface (cross-checked against the Kotlin /// SDK), mirroring the style notes at the top of `browse.dart`. -mixin _JellyfinMusicMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - List _mapItems(Iterable> items); - +mixin _JellyfinMusicMethods on _JellyfinClientInternals { /// Albums credited to [artist], newest first. Queries `AlbumArtistIds` /// rather than `ParentId` because Jellyfin links albums to artists via /// tags — an artist's albums are usually not its folder children. diff --git a/lib/services/jellyfin_client/parts/playback.dart b/lib/services/jellyfin_client/parts/playback.dart index 861588bc..060ae2b9 100644 --- a/lib/services/jellyfin_client/parts/playback.dart +++ b/lib/services/jellyfin_client/parts/playback.dart @@ -9,36 +9,7 @@ bool _canUseJellyfinStaticStreamFallback(Object error) { return true; } -PlaybackException _classifyJellyfinPlaybackFailure(Object error) { - if (error is MediaServerAuthException || - error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { - return PlaybackException( - t.messages.playbackAuthenticationRequired, - reason: PlaybackFailureReason.authenticationRequired, - ); - } - if (error is MediaServerHttpException) { - if (error.isCancellation) { - return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); - } - final status = error.statusCode; - if (error.isTransient || status != null && status >= 500) { - return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); - } - if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - } - if (error is FormatException || error is TypeError) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - return PlaybackException(t.messages.playbackFailed); -} - -mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinPlaybackMethods on _JellyfinClientInternals { /// Backend-neutral [PlaybackExtras] for [itemId]. Jellyfin exposes chapters /// at the item level (`raw['Chapters']`) and native skip segments through a /// separate `/MediaSegments/{itemId}` endpoint. Segment loading is best-effort @@ -230,7 +201,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { chosenSource = _selectNegotiatedMediaSource(negotiation['MediaSources'], bundle.selectedSourceId); } catch (error, stackTrace) { if (!_canUseJellyfinStaticStreamFallback(error)) { - Error.throwWithStackTrace(_classifyJellyfinPlaybackFailure(error), stackTrace); + Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace); } appLogger.w( 'Jellyfin playback negotiation unavailable; using the static stream', @@ -750,20 +721,16 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { @override Map get streamHeaders => const {}; - /// Tell the server the user has started playing [itemId]. Body shape - /// mirrors the Jellyfin SDK's [PlaybackStartInfo] — Findroid sends the - /// same fields, and Jellyfin's session tracker drops events that omit - /// `PlayMethod` because it has no way to associate progress with an - /// active session row. - /// - /// [duration] is accepted for interface symmetry with Plex but ignored — - /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes - /// are still sent so the active session reflects the chosen tracks. - @override - Future reportPlaybackStarted({ + /// Shared body for the `/Sessions/Playing[/Progress]` pair — only [path] and + /// [isPaused] differ between start and progress. Shape mirrors the Jellyfin + /// SDK's `PlaybackStartInfo`/`PlaybackProgressInfo`: Findroid sends the same + /// fields, and Jellyfin's session tracker drops events that omit `PlayMethod` + /// because it has no way to associate progress with an active session row. + Future _postPlayingState( + String path, { required String itemId, required Duration position, - Duration? duration, + required bool isPaused, String? playSessionId, String? playMethod, String? liveStreamId, @@ -772,44 +739,7 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { int? subtitleStreamIndex, }) async { final response = await _http.post( - '/Sessions/Playing', - body: { - 'ItemId': itemId, - 'MediaSourceId': ?mediaSourceId, - 'AudioStreamIndex': ?audioStreamIndex, - 'SubtitleStreamIndex': ?subtitleStreamIndex, - 'PositionTicks': msToJellyfinTicks(position.inMilliseconds), - 'CanSeek': true, - 'IsPaused': false, - 'IsMuted': false, - 'PlayMethod': playMethod ?? 'DirectPlay', - 'RepeatMode': 'RepeatNone', - 'PlaybackOrder': 'Default', - 'PlaySessionId': ?playSessionId, - 'LiveStreamId': ?liveStreamId, - }, - ); - throwIfHttpError(response); - } - - /// Periodic progress ping (5–10s cadence is typical). Server uses this to - /// drive the resume position, detect idle sessions, and save remembered - /// audio/subtitle stream indexes when enabled in Jellyfin user settings. - @override - Future reportPlaybackProgress({ - required String itemId, - required Duration position, - required Duration duration, - bool isPaused = false, - String? playSessionId, - String? playMethod, - String? liveStreamId, - String? mediaSourceId, - int? audioStreamIndex, - int? subtitleStreamIndex, - }) async { - final response = await _http.post( - '/Sessions/Playing/Progress', + path, body: { 'ItemId': itemId, 'MediaSourceId': ?mediaSourceId, @@ -829,6 +759,63 @@ mixin _JellyfinPlaybackMethods on MediaServerCacheMixin { throwIfHttpError(response); } + /// Tell the server the user has started playing [itemId]. + /// + /// [duration] is accepted for interface symmetry with Plex but ignored — + /// Jellyfin's `/Sessions/Playing` body has no slot for it. Stream indexes + /// are still sent so the active session reflects the chosen tracks. + @override + Future reportPlaybackStarted({ + required String itemId, + required Duration position, + Duration? duration, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => _postPlayingState( + '/Sessions/Playing', + itemId: itemId, + position: position, + isPaused: false, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + + /// Periodic progress ping (5–10s cadence is typical). Server uses this to + /// drive the resume position, detect idle sessions, and save remembered + /// audio/subtitle stream indexes when enabled in Jellyfin user settings. + @override + Future reportPlaybackProgress({ + required String itemId, + required Duration position, + required Duration duration, + bool isPaused = false, + String? playSessionId, + String? playMethod, + String? liveStreamId, + String? mediaSourceId, + int? audioStreamIndex, + int? subtitleStreamIndex, + }) => _postPlayingState( + '/Sessions/Playing/Progress', + itemId: itemId, + position: position, + isPaused: isPaused, + playSessionId: playSessionId, + playMethod: playMethod, + liveStreamId: liveStreamId, + mediaSourceId: mediaSourceId, + audioStreamIndex: audioStreamIndex, + subtitleStreamIndex: subtitleStreamIndex, + ); + /// End-of-playback signal. Final position becomes the resume bookmark. /// [duration] is accepted for interface symmetry with Plex but ignored. @override diff --git a/lib/services/jellyfin_client/parts/playlists.dart b/lib/services/jellyfin_client/parts/playlists.dart index 614cd82f..0e82d83d 100644 --- a/lib/services/jellyfin_client/parts/playlists.dart +++ b/lib/services/jellyfin_client/parts/playlists.dart @@ -1,31 +1,13 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - String? _absolutizeImagePath(String? path); - List _mapItems(Iterable> items); - +mixin _JellyfinPlaylistMethods on _JellyfinClientInternals { static const int _playlistsPageSize = 200; @override - Future> fetchPlaylists({String playlistType = 'video', bool? smart}) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPlaylistsPage( - playlistType: playlistType, - smart: smart, - start: start, - size: _playlistsPageSize, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalCount) break; - } - return all; - } + Future> fetchPlaylists({String playlistType = 'video', bool? smart}) => drainPages( + (start, size) => fetchPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size), + pageSize: _playlistsPageSize, + ); @override Future> fetchPlaylistsPage({ @@ -70,15 +52,11 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - final items = _itemsArray(response.data).map(_playlistFromJson).toList(); - final rawTotal = response.data is Map - ? (response.data as Map)['TotalRecordCount'] - : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); - return LibraryPage( - items: items, - totalCount: rawTotal is int ? rawTotal : fallbackTotal, + return _pagedItems( + response.data, offset: offset, + requestedSize: pageSize, + map: (raw) => raw.map(_playlistFromJson).toList(), ); } @@ -125,16 +103,7 @@ mixin _JellyfinPlaylistMethods on MediaServerCacheMixin { abort: abort, ); throwIfHttpError(response); - final items = _itemsArray(response.data); - final rawTotal = response.data is Map - ? (response.data as Map)['TotalRecordCount'] - : null; - final fallbackTotal = fallbackPageTotal(offset: offset, itemCount: items.length, requestedSize: pageSize); - return LibraryPage( - items: _mapItems(items), - totalCount: rawTotal is int ? rawTotal : fallbackTotal, - offset: offset, - ); + return _pagedItems(response.data, offset: offset, requestedSize: pageSize, map: _mapItems); } @override diff --git a/lib/services/jellyfin_client/parts/watch_state.dart b/lib/services/jellyfin_client/parts/watch_state.dart index 2c1e4daa..eb362295 100644 --- a/lib/services/jellyfin_client/parts/watch_state.dart +++ b/lib/services/jellyfin_client/parts/watch_state.dart @@ -1,9 +1,6 @@ part of '../../jellyfin_client.dart'; -mixin _JellyfinWatchStateMethods on MediaServerCacheMixin { - JellyfinConnection get connection; - FailoverHttpClient get _http; - +mixin _JellyfinWatchStateMethods on _JellyfinClientInternals { @override Future markWatched(MediaItem item) async { final response = await _http.post( diff --git a/lib/services/jellyfin_endpoint_discovery.dart b/lib/services/jellyfin_endpoint_discovery.dart index 89469811..f0960a59 100644 --- a/lib/services/jellyfin_endpoint_discovery.dart +++ b/lib/services/jellyfin_endpoint_discovery.dart @@ -455,6 +455,16 @@ class JellyfinEndpointDiscovery { return List.unmodifiable(result); } + /// Splits a raw add/edit form field into the individual URLs the user typed. + /// Entries are separated by newlines and/or commas; blanks are dropped. + static List parseUserEnteredUrls(String raw) { + return raw + .split(RegExp(r'[\n,]+')) + .map((url) => url.trim()) + .where((url) => url.isNotEmpty) + .toList(growable: false); + } + static JellyfinEndpointUserInputCandidates buildUserInputCandidates(Iterable input) { final probeBaseUrls = []; final explicitBaseUrls = []; diff --git a/lib/services/jellyfin_sequential_launcher.dart b/lib/services/jellyfin_sequential_launcher.dart index 250d9326..b6a4c4d7 100644 --- a/lib/services/jellyfin_sequential_launcher.dart +++ b/lib/services/jellyfin_sequential_launcher.dart @@ -137,6 +137,7 @@ class JellyfinSequentialLauncher extends MediaListPlaybackLauncher { /// Launch playback from a Jellyfin folder row. Jellyfin has no server-side /// queue resource, so folders use the same local queue path as collections. /// The client query is video-only; music-only folders return [PlayQueueEmpty]. + @override Future launchFromFolder({ required MediaItem folder, required bool shuffle, diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index 06ad7c30..52d79410 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -8,12 +8,11 @@ import '../i18n/strings.g.dart'; import '../mpv/mpv.dart'; import 'settings_binding_owner.dart'; import 'settings_service.dart'; +import 'shortcut_action.dart'; import '../utils/platform_detector.dart'; import '../utils/player_utils.dart'; class KeyboardShortcutsService extends ChangeNotifier { - static const Set _repeatableVideoActions = {'zoom_in', 'zoom_out'}; - static KeyboardShortcutsService? _instance; static Future? _initialization; late final SettingsBindingOwner _settingsBinding; @@ -218,12 +217,15 @@ class KeyboardShortcutsService extends ChangeNotifier { final isMetaPressed = HardwareKeyboard.instance.isMetaPressed; for (final entry in _hotkeys.entries) { - final action = entry.key; final hotkey = entry.value; if (hotkey == null) continue; if (physicalKey != hotkey.key) continue; + // Null for an id this build does not know: the event is still consumed so + // a stale binding never leaks through to another handler. + final action = ShortcutAction.fromId(entry.key); + final requiredModifiers = hotkey.modifiers ?? []; bool modifiersMatch = true; @@ -265,30 +267,13 @@ class KeyboardShortcutsService extends ChangeNotifier { continue; } - if (isRepeat && !_repeatableVideoActions.contains(action)) { + if (isRepeat && !(action?.repeatable ?? false)) { return KeyEventResult.handled; } - const playbackControlledActions = { - 'play_pause', - 'seek_forward', - 'seek_backward', - 'seek_forward_large', - 'seek_backward_large', - 'audio_track_next', - 'subtitle_track_next', - 'chapter_next', - 'chapter_previous', - 'speed_increase', - 'speed_decrease', - 'speed_reset', - 'sub_seek_next', - 'sub_seek_prev', - 'skip_marker', - }; - const mediaItemActions = {'episode_next', 'episode_previous'}; - if ((playbackControlledActions.contains(action) && !canControlPlayback) || - (mediaItemActions.contains(action) && !canNavigateMediaItems)) { + if (action == null || + (action.requiresPlayback && !canControlPlayback) || + (action.requiresMediaNavigation && !canNavigateMediaItems)) { return KeyEventResult.handled; } @@ -326,7 +311,7 @@ class KeyboardShortcutsService extends ChangeNotifier { } void _executeAction( - String action, + ShortcutAction action, Player player, VoidCallback? onToggleFullscreen, VoidCallback? onToggleSubtitles, @@ -363,154 +348,72 @@ class KeyboardShortcutsService extends ChangeNotifier { } switch (action) { - case 'play_pause': + case ShortcutAction.playPause: (onPlayPause ?? player.playOrPause).call(); - break; - case 'volume_up': + case ShortcutAction.volumeUp: onVolumeUp?.call(); - break; - case 'volume_down': + case ShortcutAction.volumeDown: onVolumeDown?.call(); - break; - case 'seek_forward': + case ShortcutAction.seekForward: performSeek(_seekTimeSmall); - break; - case 'seek_backward': + case ShortcutAction.seekBackward: performSeek(-_seekTimeSmall); - break; - case 'seek_forward_large': + case ShortcutAction.seekForwardLarge: performSeek(_seekTimeLarge); - break; - case 'seek_backward_large': + case ShortcutAction.seekBackwardLarge: performSeek(-_seekTimeLarge); - break; - case 'fullscreen_toggle': + case ShortcutAction.fullscreenToggle: onToggleFullscreen?.call(); - break; - case 'mute_toggle': + case ShortcutAction.muteToggle: onToggleMute?.call(); - break; - case 'subtitle_toggle': + case ShortcutAction.subtitleToggle: onToggleSubtitles?.call(); - break; - case 'audio_track_next': + case ShortcutAction.audioTrackNext: onNextAudioTrack?.call(); - break; - case 'subtitle_track_next': + case ShortcutAction.subtitleTrackNext: onNextSubtitleTrack?.call(); - break; - case 'chapter_next': + case ShortcutAction.chapterNext: onNextChapter?.call(); - break; - case 'chapter_previous': + case ShortcutAction.chapterPrevious: onPreviousChapter?.call(); - break; - case 'episode_next': + case ShortcutAction.episodeNext: onNextEpisode?.call(); - break; - case 'episode_previous': + case ShortcutAction.episodePrevious: onPreviousEpisode?.call(); - break; - case 'speed_increase': + case ShortcutAction.speedIncrease: final newRateUp = (player.state.rate + 0.25).clamp(0.25, 3.0); player.setRate(newRateUp); _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateUp); - break; - case 'speed_decrease': + case ShortcutAction.speedDecrease: final newRateDown = (player.state.rate - 0.25).clamp(0.25, 3.0); player.setRate(newRateDown); _settingsService.write(SettingsService.defaultPlaybackSpeed, newRateDown); - break; - case 'speed_reset': + case ShortcutAction.speedReset: player.setRate(1.0); _settingsService.write(SettingsService.defaultPlaybackSpeed, 1.0); - break; - case 'sub_seek_next': + case ShortcutAction.subSeekNext: player.command(['sub-seek', '1']); - break; - case 'sub_seek_prev': + case ShortcutAction.subSeekPrev: player.command(['sub-seek', '-1']); - break; - case 'shader_toggle': + case ShortcutAction.shaderToggle: onToggleShader?.call(); - break; - case 'skip_marker': + case ShortcutAction.skipMarker: onSkipMarker?.call(); - break; - case 'screenshot': + case ShortcutAction.screenshot: unawaited(player.command(['screenshot', 'subtitles']).then((_) => onScreenshot?.call())); - break; - case 'zoom_in': + case ShortcutAction.zoomIn: onZoomIn?.call(); - break; - case 'zoom_out': + case ShortcutAction.zoomOut: onZoomOut?.call(); - break; - case 'zoom_reset': + case ShortcutAction.zoomReset: onZoomReset?.call(); - break; } } String getActionDisplayName(String action) { - switch (action) { - case 'play_pause': - return t.hotkeys.actions.playPause; - case 'volume_up': - return t.hotkeys.actions.volumeUp; - case 'volume_down': - return t.hotkeys.actions.volumeDown; - case 'seek_forward': - return t.hotkeys.actions.seekForward(seconds: _seekTimeSmall); - case 'seek_backward': - return t.hotkeys.actions.seekBackward(seconds: _seekTimeSmall); - case 'seek_forward_large': - return t.hotkeys.actions.seekForward(seconds: _seekTimeLarge); - case 'seek_backward_large': - return t.hotkeys.actions.seekBackward(seconds: _seekTimeLarge); - case 'fullscreen_toggle': - return t.hotkeys.actions.fullscreenToggle; - case 'mute_toggle': - return t.hotkeys.actions.muteToggle; - case 'subtitle_toggle': - return t.hotkeys.actions.subtitleToggle; - case 'audio_track_next': - return t.hotkeys.actions.audioTrackNext; - case 'subtitle_track_next': - return t.hotkeys.actions.subtitleTrackNext; - case 'chapter_next': - return t.hotkeys.actions.chapterNext; - case 'chapter_previous': - return t.hotkeys.actions.chapterPrevious; - case 'episode_next': - return t.hotkeys.actions.episodeNext; - case 'episode_previous': - return t.hotkeys.actions.episodePrevious; - case 'speed_increase': - return t.hotkeys.actions.speedIncrease; - case 'speed_decrease': - return t.hotkeys.actions.speedDecrease; - case 'speed_reset': - return t.hotkeys.actions.speedReset; - case 'sub_seek_next': - return t.hotkeys.actions.subSeekNext; - case 'sub_seek_prev': - return t.hotkeys.actions.subSeekPrev; - case 'shader_toggle': - return t.hotkeys.actions.shaderToggle; - case 'skip_marker': - return t.hotkeys.actions.skipMarker; - case 'screenshot': - return t.hotkeys.actions.screenshot; - case 'zoom_in': - return t.hotkeys.actions.zoomIn; - case 'zoom_out': - return t.hotkeys.actions.zoomOut; - case 'zoom_reset': - return t.hotkeys.actions.zoomReset; - default: - return action; - } + final shortcut = ShortcutAction.fromId(action); + if (shortcut == null) return action; + return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge); } // Check if a hotkey is already assigned to another action diff --git a/lib/services/macos_window_service.dart b/lib/services/macos_window_service.dart index 9f7f413a..bc338289 100644 --- a/lib/services/macos_window_service.dart +++ b/lib/services/macos_window_service.dart @@ -2,27 +2,6 @@ import 'dart:io' show Platform; import 'package:flutter/services.dart'; import 'fullscreen_state_manager.dart'; -/// Abstract class for receiving macOS window delegate callbacks. -/// Extend this class and register with [MacOSWindowService] to receive -/// fullscreen transition events. -abstract class MacOSWindowDelegate { - /// Called when the window is about to enter fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillEnterFullScreen() {} - - /// Called when the window has entered fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidEnterFullScreen() {} - - /// Called when the window is about to exit fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowWillExitFullScreen() {} - - /// Called when the window has exited fullscreen mode. - // ignore: no-empty-block - default no-op, subclasses override as needed - void windowDidExitFullScreen() {} -} - /// Service for manipulating macOS window properties. /// This is a native implementation replacing the macos_window_utils package. /// @@ -31,35 +10,25 @@ abstract class MacOSWindowDelegate { /// This service only exposes what's needed externally: /// - Traffic light visibility (for video controls) /// - Fullscreen enter/exit (for video controls) -/// - Delegate registration (for FullscreenStateManager updates) +/// - Fullscreen state tracking (for FullscreenStateManager updates) class MacOSWindowService { static const _channel = MethodChannel('com.plezy/window_utils'); static bool _initialized = false; static bool _delegateEnabled = false; - static final List _delegates = []; - static final MacOSWindowDelegate _fullscreenDelegate = _FullscreenWindowDelegate(); static Future _invoke(String method, [Map? args]) async { if (!Platform.isMacOS) return; await _channel.invokeMethod(method, args); } - static void _notifyDelegates(void Function(MacOSWindowDelegate) callback) { - for (final delegate in _delegates) { - callback(delegate); - } - } - + /// Window manipulation (toolbar, titlebar, traffic lights) is handled directly + /// in Swift's WindowDelegate; this only mirrors the transition into Dart state. static Future _handleMethodCall(MethodCall call) async { switch (call.method) { case 'windowWillEnterFullScreen': - _notifyDelegates((d) => d.windowWillEnterFullScreen()); - case 'windowDidEnterFullScreen': - _notifyDelegates((d) => d.windowDidEnterFullScreen()); - case 'windowWillExitFullScreen': - _notifyDelegates((d) => d.windowWillExitFullScreen()); + FullscreenStateManager().setFullscreen(true); case 'windowDidExitFullScreen': - _notifyDelegates((d) => d.windowDidExitFullScreen()); + FullscreenStateManager().setFullscreen(false); } } @@ -81,7 +50,6 @@ class MacOSWindowService { } await initialize(enableWindowDelegate: true); - addWindowDelegate(_fullscreenDelegate); await syncWindowChrome(); FullscreenStateManager().setFullscreen(await isFullscreen()); } @@ -104,12 +72,6 @@ class MacOSWindowService { } } - static void addWindowDelegate(MacOSWindowDelegate delegate) { - if (!_delegates.contains(delegate)) { - _delegates.add(delegate); - } - } - static Future setTrafficLightsVisible(bool visible) => _invoke('setTrafficLightsVisible', {'visible': visible}); static Future syncWindowChrome() => _invoke('syncWindowChrome'); @@ -123,18 +85,3 @@ class MacOSWindowService { return await _channel.invokeMethod('isFullscreen') ?? false; } } - -/// Internal window delegate that manages fullscreen state. -/// Note: Window manipulation (toolbar, titlebar, traffic lights) is now handled -/// directly in Swift's WindowDelegate. This class only updates Dart-side state. -class _FullscreenWindowDelegate extends MacOSWindowDelegate { - @override - void windowWillEnterFullScreen() { - FullscreenStateManager().setFullscreen(true); - } - - @override - void windowDidExitFullScreen() { - FullscreenStateManager().setFullscreen(false); - } -} diff --git a/lib/screens/video_player/media_control_router.dart b/lib/services/media_control_router.dart similarity index 80% rename from lib/screens/video_player/media_control_router.dart rename to lib/services/media_control_router.dart index 9e657f35..100eb3b8 100644 --- a/lib/screens/video_player/media_control_router.dart +++ b/lib/services/media_control_router.dart @@ -1,12 +1,14 @@ import 'package:os_media_controls/os_media_controls.dart'; -/// Screen-owned authorization boundary for user-originated OS media commands. +/// Authorization boundary for user-originated OS media commands, owned by +/// whoever holds the transport (the video screen, the music session). /// -/// Lifecycle/audio-route events are handled before this router. Recognized -/// commands are consumed even when denied so they cannot reach a background -/// route or stale player owner. -final class VideoPlayerMediaControlRouter { - const VideoPlayerMediaControlRouter({ +/// Lifecycle/audio-route events are handled before this router: [route] +/// reports `false` for what it does not recognize. Recognized commands are +/// consumed even when denied so they cannot reach a background route or stale +/// player owner. Both gates stay required — every owner states its policy. +final class MediaControlRouter { + const MediaControlRouter({ required this.canControlPlayback, required this.canNavigateMediaItems, required this.onPlay, diff --git a/lib/services/media_list_playback_launcher.dart b/lib/services/media_list_playback_launcher.dart index effba026..09e3b069 100644 --- a/lib/services/media_list_playback_launcher.dart +++ b/lib/services/media_list_playback_launcher.dart @@ -73,6 +73,17 @@ abstract class MediaListPlaybackLauncher { /// queue from `EpisodeNavigationService`). Future launchShuffledShow({required MediaItem metadata, bool showLoadingIndicator = true}); + /// Launch playback from a folder row of the library tree. Everything each + /// backend needs is stamped onto [folder]: Plex builds a server-side + /// `/playQueues` from [MediaItem.backendFolderKey] (returning a + /// [PlayQueueError] when the row carries none), Jellyfin fetches the + /// folder's playable descendants and publishes a local queue. + Future launchFromFolder({ + required MediaItem folder, + required bool shuffle, + bool showLoadingIndicator = true, + }); + /// Pick the right implementation for [item]. Reads /// [MediaItem.backend] / [MediaPlaylist.backend]. static MediaListPlaybackLauncher forItem(BuildContext context, Object item) { diff --git a/lib/services/multi_server_manager.dart b/lib/services/multi_server_manager.dart index 38b065f5..50ddf00b 100644 --- a/lib/services/multi_server_manager.dart +++ b/lib/services/multi_server_manager.dart @@ -77,6 +77,10 @@ class MultiServerManager { Stream> get statusStream => _statusController.stream; + /// Publish a snapshot of the per-server online map — subscribers must never + /// receive the live [_serverStatus] instance. + void _emitStatus() => _statusController.add(Map.from(_serverStatus)); + /// Per-server connect progress during a bind. Unlike [statusStream] — whose /// first emission means "the binder's first connect pass finished" and which /// triggers libraries/live-tv work per emission — this fires as each @@ -106,6 +110,26 @@ class MultiServerManager { String? _resolveClientIdentifier(ServerId serverId) => _clientIdByServer[serverId]; + /// Record the Plex identity a server is bound under — the single writer for + /// all three per-server Plex registrations. A null [scope] (only + /// [markPlexConnectionAuthError], which has no profile yet) leaves any + /// previously recorded scope in place. + void _registerPlexServer( + String serverId, + PlexServer server, { + required String clientIdentifier, + PlexProfileScopeId? scope, + }) { + _clientIdByServer[serverId] = clientIdentifier; + _plexServers[serverId] = server; + if (scope != null) _plexScopeByServer[serverId] = scope; + } + + /// Whether [compoundId] is still the client bound as the active user for + /// [machineId]. Async Jellyfin work must re-check this before publishing a + /// result — a profile switch can rebind the machine mid-probe. + bool _isActiveJellyfin(String machineId, String compoundId) => _activeJellyfinMachine[machineId] == compoundId; + /// All Jellyfin clients ever added, keyed by the compound connection id /// (`{serverMachineId}/{userId}`). Lets two users on the same Jellyfin /// server coexist — adding the second user's client won't tear down the @@ -231,7 +255,7 @@ class MultiServerManager { void debugMarkAuthErrorForTesting(ServerId serverId) { _serverStatus[serverId] = false; _authErrorServers.add(serverId); - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } /// Mark every cached Plex server on [connection] as auth-rejected without @@ -240,12 +264,11 @@ class MultiServerManager { void markPlexConnectionAuthError(PlexAccountConnection connection) { for (final server in connection.servers) { final id = server.clientIdentifier; - _clientIdByServer[id] = connection.clientIdentifier; - _plexServers[id] = server; + _registerPlexServer(id, server, clientIdentifier: connection.clientIdentifier); _serverStatus[id] = false; _authErrorServers.add(id); } - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } /// Plex-specific server config (name, machineId, connection candidates, @@ -457,37 +480,43 @@ class MultiServerManager { .where((entry) => entry.value.connection.serverMachineId == serverId) .map((entry) => entry.key) .toList(); + final activeClient = _forgetServer(serverId); if (jellyfinCompoundIds.isNotEmpty) { final closed = {}; - _clients.remove(serverId); - _activeJellyfinMachine.remove(serverId); for (final compoundId in jellyfinCompoundIds) { final client = _jellyfinByCompoundId.remove(compoundId); _jellyfinHealthByCompoundId.remove(compoundId); if (client != null && closed.add(client)) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); } } - } else { - final client = _clients.remove(serverId); - if (client != null) _closeClient(client); + } else if (activeClient != null) { + // Jellyfin's clients were all closed above. + unawaited(_closeClientGracefully(activeClient)); } - _plexServers.remove(serverId); - _plexScopeByServer.remove(serverId); - _serverStatus.remove(serverId); - _authErrorServers.remove(serverId); - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); appLogger.i('Removed server: $serverId'); } - void _closeClient(MediaServerClient client) { - if (client case final GracefullyCloseable graceful) { - unawaited(graceful.closeGracefully()); - } else { - client.close(); - } + /// Drop every registration keyed by [serverId], cancel its pending exhaustion + /// retry, and return the client that was bound (the caller closes it). The + /// single teardown for both removal paths, so they cannot drift apart again. + /// The in-flight guards ([_activeOptimizations], [_endpointHealthChecks]) are + /// deliberately left alone — they are owned by the futures that set them. + MediaServerClient? _forgetServer(String serverId) { + _reconnectDebounce.remove(serverId)?.cancel(); + final client = _clients.remove(serverId); + _activeJellyfinMachine.remove(serverId); + _plexServers.remove(serverId); + _clientIdByServer.remove(serverId); + _plexScopeByServer.remove(serverId); + _serverStatus.remove(serverId); + _authErrorServers.remove(serverId); + return client; } + /// Close [client], draining in-flight requests when it supports it. Callers + /// that do not need to wait wrap the call in `unawaited(...)`. Future _closeClientGracefully( MediaServerClient client, { Duration drainTimeout = const Duration(seconds: 2), @@ -539,9 +568,7 @@ class MultiServerManager { ); if (!applied || isStale() || !identical(_clients[serverId], existing)) return; - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; + _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId); _authErrorServers.remove(serverId); _serverStatus[serverId] = true; bound.add(serverId); @@ -556,9 +583,7 @@ class MultiServerManager { return; } - _clientIdByServer[serverId] = connection.clientIdentifier; - _plexServers[serverId] = server; - _plexScopeByServer[serverId] = profileScopeId; + _registerPlexServer(serverId, server, clientIdentifier: connection.clientIdentifier, scope: profileScopeId); try { final client = await _createClientForServer( server: server, @@ -566,11 +591,11 @@ class MultiServerManager { profileScopeId: profileScopeId, ).namedTimeout(timeout, operation: 'connect to ${server.name}'); if (isStale() || !identical(_plexServers[serverId], server)) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); return; } final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _clients[serverId] = client; _serverStatus[serverId] = true; _authErrorServers.remove(serverId); @@ -586,7 +611,7 @@ class MultiServerManager { }); await Future.wait(futures); if (isStale()) return const {}; - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); if (bound.isNotEmpty && _connectivitySubscription == null) { _startNetworkMonitoring(); } @@ -682,7 +707,7 @@ class MultiServerManager { // the connection materially changed (token refresh, URL-set edit); an // unchanged re-add was already handled by the reuse branch above. final oldClient = _jellyfinByCompoundId[compoundId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _jellyfinByCompoundId[compoundId] = client; // Bind this user as the active client for its machine. A previously @@ -739,13 +764,13 @@ class MultiServerManager { Future _reuseJellyfinClient(JellyfinClient client) async { final compoundId = client.connection.id; final machineId = client.connection.serverMachineId; - final rebound = _activeJellyfinMachine[machineId] != compoundId; + final rebound = !_isActiveJellyfin(machineId, compoundId); _clients[machineId] = client; _activeJellyfinMachine[machineId] = compoundId; final health = await client.checkHealth(); _jellyfinHealthByCompoundId[compoundId] = health; - if (_activeJellyfinMachine[machineId] != compoundId) { + if (!_isActiveJellyfin(machineId, compoundId)) { // A concurrent remove/re-add won while the probe was in flight. appLogger.d('Ignoring stale Jellyfin reuse result for ${client.connection.serverName}'); return health == HealthStatus.online; @@ -754,7 +779,7 @@ class MultiServerManager { if (rebound) { // The machine's active user changed even if its online status didn't; // client-map consumers need to observe the swap. - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); } final healthy = health == HealthStatus.online; appLogger.i( @@ -784,7 +809,7 @@ class MultiServerManager { appLogger.w('Failed to persist Jellyfin connection update', error: e, stackTrace: st); } } - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); }; } @@ -802,13 +827,10 @@ class MultiServerManager { final machineId = connection.serverMachineId; final client = _jellyfinByCompoundId.remove(compoundId); _jellyfinHealthByCompoundId.remove(compoundId); - if (client != null) _closeClient(client); - if (_activeJellyfinMachine[machineId] == compoundId) { - _activeJellyfinMachine.remove(machineId); - _clients.remove(machineId); - _serverStatus.remove(machineId); - _authErrorServers.remove(machineId); - _statusController.add(Map.from(_serverStatus)); + if (client != null) unawaited(_closeClientGracefully(client)); + if (_isActiveJellyfin(machineId, compoundId)) { + _forgetServer(machineId); + _emitStatus(); } } @@ -816,15 +838,8 @@ class MultiServerManager { /// /// Clears the auth-error flag — callers that observed an auth failure /// should use [_applyHealth] instead. - void updateServerStatus(ServerId serverId, bool isOnline) { - final prevOnline = _serverStatus[serverId]; - final hadAuthError = _authErrorServers.remove(serverId); - if (prevOnline != isOnline || hadAuthError) { - _serverStatus[serverId] = isOnline; - _statusController.add(Map.from(_serverStatus)); - appLogger.d('Server $serverId status changed to: $isOnline'); - } - } + void updateServerStatus(ServerId serverId, bool isOnline) => + _applyHealth(serverId, isOnline ? HealthStatus.online : HealthStatus.offline); /// Apply a health-probe outcome to both online state and auth-error /// tracking. Used by the manager's own health checks; external callers @@ -844,7 +859,7 @@ class MultiServerManager { final changed = prevOnline != isOnline || hadAuthError != isAuthError; if (changed) { - _statusController.add(Map.from(_serverStatus)); + _emitStatus(); if (isAuthError) { appLogger.w('Server $serverId auth rejected — token expired or revoked'); } else { @@ -880,7 +895,7 @@ class MultiServerManager { if (client is JellyfinClient) { final compoundId = expectedJellyfinCompoundId ?? client.connection.id; _jellyfinHealthByCompoundId[compoundId] = status; - if (_activeJellyfinMachine[serverId] != compoundId) { + if (!_isActiveJellyfin(serverId, compoundId)) { appLogger.d('Ignoring stale Jellyfin health result for ${client.connection.serverName}'); return; } @@ -949,6 +964,33 @@ class MultiServerManager { appLogger.i('Stopped network monitoring'); } + /// Run [taskBuilder] as the single in-flight optimize/reconnect task for + /// [serverId] — the sole owner of the [_activeOptimizations] invariant. + /// + /// While an entry exists the builder is never invoked and a completed future + /// is returned, so a caller awaiting a batch never waits on work it did not + /// start. The registered future always clears its own entry. [timeout] bounds + /// the task, logging ` timed out for ` when it fires. + Future _runServerTask( + String serverId, + Future Function() taskBuilder, { + Duration? timeout, + String? timeoutLabel, + }) { + if (_activeOptimizations.containsKey(serverId)) return Future.value(); + + var task = taskBuilder(); + if (timeout != null) { + task = task.timeout(timeout, onTimeout: () => appLogger.d('$timeoutLabel timed out for $serverId')); + } + // Must not *return* the removed entry — whenComplete would then await this very future. + final registered = task.whenComplete(() { + _activeOptimizations.remove(serverId); + }); + _activeOptimizations[serverId] = registered; + return registered; + } + /// Re-optimize all connected servers and attempt reconnection for offline ones void _reoptimizeAllServers({required String reason}) { for (final entry in _plexServers.entries) { @@ -961,33 +1003,27 @@ class MultiServerManager { continue; } - if (!isServerOnline(ServerId(serverId))) { - // Attempt reconnection for offline servers - _activeOptimizations[serverId] = _reconnectServer(ServerId(serverId), server).whenComplete(() { - _activeOptimizations.remove(serverId); - }); - } else { - // Re-optimize online servers - _activeOptimizations[serverId] = _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason) - .whenComplete(() { - _activeOptimizations.remove(serverId); - }); - } + // Online servers get their endpoints re-raced; offline ones a full reconnect. + unawaited( + _runServerTask( + serverId, + () => isServerOnline(ServerId(serverId)) + ? _reoptimizeServer(serverId: ServerId(serverId), server: server, reason: reason) + : _reconnectServer(ServerId(serverId), server), + ), + ); } // Jellyfin re-probes offline servers here. Online clients keep their current // endpoint and can still fail over per request through JellyfinClient. for (final entry in _activeJellyfinMachine.entries) { final serverId = entry.key; - if (_activeOptimizations.containsKey(serverId)) continue; if (isServerOnline(ServerId(serverId))) continue; final client = _jellyfinByCompoundId[entry.value]; if (client == null) continue; - _activeOptimizations[serverId] = _reconnectJellyfinServer(serverId, client).whenComplete(() { - _activeOptimizations.remove(serverId); - }); + unawaited(_runServerTask(serverId, () => _reconnectJellyfinServer(serverId, client))); } } @@ -1064,13 +1100,13 @@ class MultiServerManager { if (!identical(_plexServers[serverId], server) || _resolveClientIdentifier(serverId) != clientId || _plexScopeByServer[serverId] != profileScopeId) { - _closeClient(client); + unawaited(_closeClientGracefully(client)); appLogger.d('Ignoring stale reconnection result for ${server.name}'); return; } final oldClient = _clients[serverId]; - if (oldClient != null) _closeClient(oldClient); + if (oldClient != null) unawaited(_closeClientGracefully(oldClient)); _clients[serverId] = client; updateServerStatus(serverId, true); appLogger.i('Successfully reconnected to ${server.name}'); @@ -1093,7 +1129,7 @@ class MultiServerManager { appLogger.d('Attempting reconnection for Jellyfin server ${client.connection.serverName}'); final status = await client.checkHealth(); _jellyfinHealthByCompoundId[expectedCompoundId] = status; - if (_activeJellyfinMachine[machineId] != expectedCompoundId) { + if (!_isActiveJellyfin(machineId, expectedCompoundId)) { appLogger.d('Ignoring stale Jellyfin reconnection result for ${client.connection.serverName}'); return; } @@ -1144,22 +1180,14 @@ class MultiServerManager { } final futures = offline.map((serverId) { - // Skip if already running - if (_activeOptimizations.containsKey(serverId)) return Future.value(); - final server = _plexServers[serverId]; if (server != null) { - final future = _reconnectServer(ServerId(serverId), server) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - appLogger.d('Reconnection timed out for $serverId'); - }, - ) - .whenComplete(() => _activeOptimizations.remove(serverId)); - - _activeOptimizations[serverId] = future; - return future; + return _runServerTask( + serverId, + () => _reconnectServer(ServerId(serverId), server), + timeout: const Duration(seconds: 15), + timeoutLabel: 'Reconnection', + ); } // Jellyfin offline path — no `_plexServers` entry, but the active @@ -1167,21 +1195,14 @@ class MultiServerManager { // `_activeJellyfinMachine`. Run the same auth probe used at add time. final activeCompoundId = _activeJellyfinMachine[serverId]; final jellyfinClient = activeCompoundId != null ? _jellyfinByCompoundId[activeCompoundId] : null; - if (jellyfinClient != null) { - final future = _reconnectJellyfinServer(serverId, jellyfinClient) - .timeout( - const Duration(seconds: 15), - onTimeout: () { - appLogger.d('Jellyfin reconnection timed out for $serverId'); - }, - ) - .whenComplete(() => _activeOptimizations.remove(serverId)); + if (jellyfinClient == null) return Future.value(); - _activeOptimizations[serverId] = future; - return future; - } - - return Future.value(); + return _runServerTask( + serverId, + () => _reconnectJellyfinServer(serverId, jellyfinClient), + timeout: const Duration(seconds: 15), + timeoutLabel: 'Jellyfin reconnection', + ); }); await Future.wait(futures); @@ -1232,13 +1253,14 @@ class MultiServerManager { appLogger.i('Health probe confirmed $serverId offline, triggering reconnection'); - if (_activeOptimizations.containsKey(serverId)) return; - final reconnect = plexServer != null - ? _reconnectServer(serverId, plexServer) - : _reconnectJellyfinServer(serverId, jellyfinClient!); - _activeOptimizations[serverId] = reconnect.whenComplete(() { - _activeOptimizations.remove(serverId); - }); + unawaited( + _runServerTask( + serverId, + () => plexServer != null + ? _reconnectServer(serverId, plexServer) + : _reconnectJellyfinServer(serverId, jellyfinClient!), + ), + ); } finally { _endpointHealthChecks.remove(serverId); } @@ -1248,7 +1270,7 @@ class MultiServerManager { /// client stays in [_jellyfinByCompoundId]); only the currently bound /// client's exhaustion may verify and flip the machine's status. void _onJellyfinEndpointsExhausted(String machineId, String compoundId) { - if (_activeJellyfinMachine[machineId] != compoundId) { + if (!_isActiveJellyfin(machineId, compoundId)) { appLogger.d('Ignoring endpoint exhaustion from inactive Jellyfin client', error: compoundId); return; } @@ -1264,13 +1286,12 @@ class MultiServerManager { @visibleForTesting void debugTriggerEndpointsExhaustedForTesting(ServerId serverId) => _onServerEndpointsExhausted(serverId); - /// Disconnect all servers + /// Disconnect all servers, fire-and-forget. + /// + /// Registrations are dropped synchronously ([_detachAllClients] runs before + /// the first await); only the socket drain is left running in the background. void disconnectAll() { - appLogger.i('Disconnecting all servers'); - final clients = _detachAllClients(); - for (final client in clients) { - _closeClient(client); - } + unawaited(disconnectAllGracefully(drainTimeout: const Duration(seconds: 2))); } Future disconnectAllGracefully({Duration drainTimeout = const Duration(seconds: 5)}) async { diff --git a/lib/services/music/music_playback_service_impl.dart b/lib/services/music/music_playback_service_impl.dart index 0293c5b2..d8c81eef 100644 --- a/lib/services/music/music_playback_service_impl.dart +++ b/lib/services/music/music_playback_service_impl.dart @@ -14,6 +14,7 @@ import '../../mpv/player/player.dart'; import '../../utils/app_logger.dart'; import '../../utils/notification_permission.dart'; import '../../utils/platform_detector.dart'; +import '../media_control_router.dart'; import '../media_controls_manager.dart'; import '../multi_server_manager.dart'; import '../offline_watch_sync_service.dart'; @@ -783,27 +784,32 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO ); } + /// OS transport commands. Music has no authorization gate: the session only + /// exists while a track is loaded, and that is checked in [_onControlEvent]. + late final _mediaControlRouter = MediaControlRouter( + canControlPlayback: () => true, + canNavigateMediaItems: () => true, + onPlay: () => unawaited(play()), + onPause: () => unawaited(pause()), + onTogglePlayPause: () => unawaited(togglePlayPause()), + onSeek: (position) => unawaited(seek(position)), + onNext: () => unawaited(next()), + onPrevious: () => unawaited(previous()), + onStop: () => unawaited(stop()), + onSkipForward: (interval) => unawaited(_seekRelative(interval ?? _defaultSkipInterval)), + onSkipBackward: (interval) => unawaited(_seekRelative(-(interval ?? _defaultSkipInterval))), + // Speed is deliberately ignored: music always plays at 1.0 and the control + // is not advertised — but Linux MPRIS exposes an always-writable Rate + // property, so the event can still arrive. The periodic playback-state + // update reasserts speed 1.0. + onSetSpeed: (_) {}, + ); + void _onControlEvent(MediaControlEvent event) { if (_disposed || _currentTrack == null) return; - if (event is PlayEvent) { - unawaited(play()); - } else if (event is PauseEvent) { - unawaited(pause()); - } else if (event is TogglePlayPauseEvent) { - unawaited(togglePlayPause()); - } else if (event is NextTrackEvent) { - unawaited(next()); - } else if (event is PreviousTrackEvent) { - unawaited(previous()); - } else if (event is SeekEvent) { - unawaited(seek(event.position)); - } else if (event is StopEvent) { - unawaited(stop()); - } else if (event is SkipForwardEvent) { - unawaited(_seekRelative(event.interval ?? _defaultSkipInterval)); - } else if (event is SkipBackwardEvent) { - unawaited(_seekRelative(-(event.interval ?? _defaultSkipInterval))); - } else if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) { + if (_mediaControlRouter.route(event)) return; + + if (event is AudioInterruptionBeganEvent || event is AudioRouteOldDeviceUnavailableEvent) { // Remember whether we were playing so interruption-end/route-return // can resume. Unlike video, music resumes even while backgrounded — // background audio is the product. @@ -822,10 +828,6 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO unawaited(play()); } } - // SetSpeedEvent is deliberately unhandled: music always plays at 1.0 and - // the control is not advertised — but Linux MPRIS exposes an always- - // writable Rate property, so the event can still arrive. The periodic - // playback-state update reasserts speed 1.0. } static const _defaultSkipInterval = Duration(seconds: 15); @@ -1148,10 +1150,7 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _queueSessionRevision++; _generation++; _invalidateArmRequests(); - _completedConfirmTimer?.cancel(); - _completedConfirmTimer = null; - _cancelSleepTimer(); - _finalizeCurrentTrack(); + _cancelTimersAndFinalizeTrack(); _queue.clear(); _currentTrack = null; _currentSource = null; @@ -1161,27 +1160,56 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _resumeAfterInterruption = false; _setStatus(endStatus, forceNotify: true); - final player = _player; - _player = null; + await _teardownPlayerAndControls(awaitStop: true); + } + + /// Kills the completion/sleep timers and flushes the track's final progress + /// report — done before [_setStatus] so listeners never see a live timer. + void _cancelTimersAndFinalizeTrack() { + _completedConfirmTimer?.cancel(); + _completedConfirmTimer = null; + _cancelSleepTimer(); + _finalizeCurrentTrack(); + } + + /// Detaches the player streams, shuts the player down and drops the OS media + /// session — the teardown shared by [_stopSession] and [dispose]. + /// + /// [awaitStop] stops the player and awaits every step, so callers know the + /// audio core is gone once the future resolves. The `false` path must never + /// suspend: [dispose] is a synchronous override and needs the whole teardown + /// to run in the caller's turn, before `super.dispose()`. + Future _teardownPlayerAndControls({required bool awaitStop}) async { for (final sub in _playerSubs) { unawaited(sub.cancel()); } _playerSubs.clear(); + final player = _player; + _player = null; if (player != null && !player.disposed) { - try { - await player.stop(); - } catch (e) { - appLogger.d('Audio player stop failed during session teardown', error: e); - } - try { - await player.abandonAudioFocus(); - } catch (e) { - appLogger.d('Audio focus abandon failed during session teardown', error: e); - } - try { - await player.dispose(); - } catch (e) { - appLogger.w('Audio player dispose failed during session teardown', error: e); + if (awaitStop) { + try { + await player.stop(); + } catch (e) { + appLogger.d('Audio player stop failed during session teardown', error: e); + } + try { + await player.abandonAudioFocus(); + } catch (e) { + appLogger.d('Audio focus abandon failed during session teardown', error: e); + } + try { + await player.dispose(); + } catch (e) { + appLogger.w('Audio player dispose failed during session teardown', error: e); + } + } else { + unawaited( + player.abandonAudioFocus().catchError((Object e) { + appLogger.d('Audio focus abandon failed during dispose', error: e); + }), + ); + unawaited(player.dispose()); } } @@ -1229,33 +1257,9 @@ class MusicPlaybackServiceImpl extends MusicPlaybackService with WidgetsBindingO _observesLifecycle = false; } _coordinator.unregisterMusicSession(_stopForVideoClaim); - _completedConfirmTimer?.cancel(); - _completedConfirmTimer = null; - _cancelSleepTimer(); - _finalizeCurrentTrack(); - for (final sub in _playerSubs) { - unawaited(sub.cancel()); - } - _playerSubs.clear(); - unawaited(_controlEventsSub?.cancel()); - _controlEventsSub = null; - final player = _player; - _player = null; - if (player != null && !player.disposed) { - unawaited( - player.abandonAudioFocus().catchError((Object e) { - appLogger.d('Audio focus abandon failed during dispose', error: e); - }), - ); - unawaited(player.dispose()); - } - final controls = _mediaControls; - _mediaControls = null; - if (controls != null) { - unawaited(controls.setBackgroundMode(false)); - unawaited(controls.clear()); - controls.dispose(); - } + _cancelTimersAndFinalizeTrack(); + // Runs to completion synchronously — see the awaitStop: false contract. + unawaited(_teardownPlayerAndControls(awaitStop: false)); unawaited(_positionController.close()); unawaited(_errorsController.close()); _volumeNotifier.dispose(); diff --git a/lib/services/play_queue_launcher.dart b/lib/services/play_queue_launcher.dart index 21fe222c..911ea261 100644 --- a/lib/services/play_queue_launcher.dart +++ b/lib/services/play_queue_launcher.dart @@ -27,9 +27,8 @@ export 'media_list_playback_launcher.dart' /// 3. Navigating to the video player /// 4. Handling errors with appropriate feedback /// -/// Implements [MediaListPlaybackLauncher.launchFromCollectionOrPlaylist] for -/// the backend-neutral entry point. Flows outside that abstraction, such as -/// [launchFromFolder], live directly on this class. +/// Implements the backend-neutral [MediaListPlaybackLauncher] entry points +/// on top of that resource. class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { final BuildContext context; final PlexClient client; @@ -219,14 +218,22 @@ class PlexPlayQueueLauncher extends MediaListPlaybackLauncher { ); } - /// Launch playback from a folder's contents. + /// Launch playback from a folder's contents. The `/folder` key and the + /// owning library are both stamped onto the folder row by the listing + /// fetch, so the neutral [MediaItem] carries everything Plex needs. + @override Future launchFromFolder({ - required String folderKey, + required MediaItem folder, required bool shuffle, - String? libraryId, - String? libraryTitle, bool showLoadingIndicator = true, }) async { + final folderKey = folder.backendFolderKey; + if (folderKey == null) { + return PlayQueueError(Exception('Folder is missing its backend folder key')); + } + final libraryId = folder.libraryId; + final libraryTitle = folder.libraryTitle; + return executeWithLoading( context: context, showLoading: showLoadingIndicator, diff --git a/lib/services/playback_initialization_service.dart b/lib/services/playback_initialization_service.dart index 79850eb1..9a600db9 100644 --- a/lib/services/playback_initialization_service.dart +++ b/lib/services/playback_initialization_service.dart @@ -9,14 +9,13 @@ import '../media/media_item_types.dart'; import '../media/media_server_client.dart'; import '../media/media_source_info.dart'; import '../models/audio_quality_preset.dart'; -import '../models/download_models.dart'; import '../models/transcode_quality_preset.dart'; import '../mpv/models.dart'; import '../utils/app_logger.dart'; -import '../utils/downloaded_version_match.dart'; import '../utils/global_key_utils.dart'; import 'cached_playback_metadata_service.dart'; import 'download_storage_service.dart'; +import 'downloaded_video_source.dart'; import 'playback_initialization_types.dart'; // Re-export so existing callers (video_player_screen) can keep importing @@ -70,7 +69,7 @@ class PlaybackInitializationService { /// streaming an explicitly requested non-downloaded version. With /// [allowAnyDownloadedVersion] the single downloaded version is returned on /// mismatch instead — for offline flows where the alternative is failing. - Future<({String path, int mediaIndex, String? mediaSourceId})?> _resolveOfflineVideoSource( + Future _resolveOfflineVideoSource( ServerId serverId, String ratingKey, { required int mediaIndex, @@ -89,55 +88,16 @@ class PlaybackInitializationService { ..where((tbl) => tbl.globalKey.equals(buildGlobalKey(ServerId(serverId), ratingKey))); final downloadedItem = await query.getSingleOrNull(); - - // Return null if not found or not completed - if (downloadedItem == null || downloadedItem.status != DownloadStatus.completed.index) { + if (downloadedItem == null) { return null; } - final matches = downloadedVersionMatches( + return await resolveDownloadedVideoSource( downloadedItem, requestedMediaIndex: mediaIndex, requestedMediaSourceId: selectedMediaSourceId, + allowAnyDownloadedVersion: allowAnyDownloadedVersion, ); - if (!matches) { - if (!allowAnyDownloadedVersion) { - appLogger.d( - '[VersionTrace] Offline video is version ${downloadedItem.mediaIndex} ' - '(source ${downloadedItem.mediaSourceId}), but requested version ' - '$mediaIndex (source ${selectedMediaSourceId?.trim()}) — skipping offline', - ); - return null; - } - appLogger.d( - '[VersionTrace] Requested version $mediaIndex (source ${selectedMediaSourceId?.trim()}) ' - 'is not downloaded — falling back to downloaded version ' - '${downloadedItem.mediaIndex} (source ${downloadedItem.mediaSourceId})', - ); - } - - // Return null if no video file path - if (downloadedItem.videoFilePath == null) { - return null; - } - - final storageService = DownloadStorageService.instance; - final storedPath = downloadedItem.videoFilePath!; - - // Get readable path (handles both SAF URIs and file paths) - final readablePath = await storageService.getReadablePath(storedPath); - - // For file paths (not SAF), verify the file exists - if (!storageService.isSafUri(storedPath)) { - final file = File(readablePath); - if (!await file.exists()) { - appLogger.w('Offline video file not found: $readablePath (stored as: $storedPath)'); - return null; - } - } - - appLogger.d('Found offline video: $readablePath'); - return (path: readablePath, mediaIndex: downloadedItem.mediaIndex, mediaSourceId: downloadedItem.mediaSourceId); } catch (e) { appLogger.w('Error checking offline video path', error: e); return null; @@ -165,7 +125,7 @@ class PlaybackInitializationService { }) async { final serverId = metadata.serverId ?? client?.serverId; - ({String path, int mediaIndex, String? mediaSourceId})? offlineSource; + DownloadedVideoSource? offlineSource; if (serverId != null && (preferOffline || client == null) && database != null) { offlineSource = await _resolveOfflineVideoSource( ServerId(serverId), diff --git a/lib/services/playback_initialization_types.dart b/lib/services/playback_initialization_types.dart index c6a07eff..1ca83374 100644 --- a/lib/services/playback_initialization_types.dart +++ b/lib/services/playback_initialization_types.dart @@ -1,3 +1,5 @@ +import '../exceptions/media_server_exceptions.dart'; +import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_source_info.dart'; import '../media/media_version.dart'; @@ -180,3 +182,34 @@ class PlaybackException implements Exception { @override String toString() => message; } + +/// Maps a transport-level failure raised while initializing playback onto a +/// display-safe [PlaybackException]. +/// +/// Backend-neutral on purpose: Plex and Jellyfin both throw the same +/// [MediaServerException] hierarchy, so both clients classify identically. +PlaybackException classifyPlaybackFailure(Object error) { + if (error is MediaServerAuthException || + error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { + return PlaybackException( + t.messages.playbackAuthenticationRequired, + reason: PlaybackFailureReason.authenticationRequired, + ); + } + if (error is MediaServerHttpException) { + if (error.isCancellation) { + return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); + } + final status = error.statusCode; + if (error.isTransient || status != null && status >= 500) { + return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); + } + if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + } + if (error is FormatException || error is TypeError) { + return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); + } + return PlaybackException(t.messages.playbackFailed); +} diff --git a/lib/services/playlist_items_loader.dart b/lib/services/playlist_items_loader.dart index fb1b709b..e9f38316 100644 --- a/lib/services/playlist_items_loader.dart +++ b/lib/services/playlist_items_loader.dart @@ -1,3 +1,4 @@ +import '../media/library_query.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; import '../utils/media_server_http_client.dart'; @@ -10,20 +11,11 @@ Future> fetchAllPlaylistItems( String playlistId, { int pageSize = playlistItemsPageSize, AbortController? abort, -}) async { - final all = []; - var offset = 0; - while (true) { - abort?.throwIfAborted(); - final page = await client.fetchPlaylistPage(playlistId, start: offset, size: pageSize, abort: abort); - abort?.throwIfAborted(); - if (page.items.isEmpty) break; - all.addAll(page.items); - if (all.length >= page.totalCount) break; - offset += page.items.length; - } - return all; -} +}) => drainPages( + (start, size) => client.fetchPlaylistPage(playlistId, start: start, size: size, abort: abort), + pageSize: pageSize, + abort: abort, +); /// Page through every item in a collection via the backend-neutral client API. Future> fetchAllCollectionItemsPaged( @@ -32,21 +24,14 @@ Future> fetchAllCollectionItemsPaged( int pageSize = 100, String? libraryId, String? libraryTitle, -}) async { - final all = []; - var offset = 0; - while (true) { - final page = await client.fetchCollectionPage( - collectionId, - start: offset, - size: pageSize, - libraryId: libraryId, - libraryTitle: libraryTitle, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - if (all.length >= page.totalCount || page.items.length < pageSize) break; - offset += page.items.length; - } - return all; -} +}) => drainPages( + (start, size) => client.fetchCollectionPage( + collectionId, + start: start, + size: size, + libraryId: libraryId, + libraryTitle: libraryTitle, + ), + pageSize: pageSize, + stopOnShortPage: true, +); diff --git a/lib/services/plex_auth_service.dart b/lib/services/plex_auth_service.dart index a6a46fa1..4db143bc 100644 --- a/lib/services/plex_auth_service.dart +++ b/lib/services/plex_auth_service.dart @@ -7,6 +7,7 @@ import 'plex_client.dart'; import '../exceptions/media_server_exceptions.dart'; import '../models/plex/plex_user_profile.dart'; import '../models/plex/plex_home.dart'; +import '../models/plex/plex_home_user.dart'; import '../models/user_switch_response.dart'; import '../utils/app_logger.dart'; import '../utils/device_identity.dart'; @@ -15,6 +16,7 @@ import '../utils/json_utils.dart'; import '../utils/media_server_timeouts.dart'; import '../utils/media_server_http_client.dart'; import '../utils/poll_with_backoff.dart'; +import '../utils/url_utils.dart'; /// Redacts the middle of an IP address or hostname for safe logging. /// E.g. `192.168.1.50` → `192.***.***.50`, `my.server.example.com` → `my.***.***. com`. @@ -162,11 +164,7 @@ class PlexAuthService { String getAuthUrl(String pinCode) { final params = {'clientID': _clientIdentifier, 'code': pinCode, 'context[device][product]': _appName}; - final queryString = params.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); - - return 'https://app.plex.tv/auth#?$queryString'; + return 'https://app.plex.tv/auth#?${encodeQueryParameters(params)}'; } /// Poll the PIN to check if it has been claimed @@ -1016,6 +1014,19 @@ class PlexConnection { } } +/// Default implementation of the `Future> Function(String)` +/// fetcher seam injected into `PlexHomeService` and `ConnectionBootstrap`: +/// spins up a throwaway [PlexAuthService] for a single `/home/users` call. +Future> fetchPlexHomeUsers(String accountToken) async { + final auth = await PlexAuthService.create(); + try { + final home = await auth.getHomeUsers(accountToken); + return home.users; + } finally { + auth.dispose(); + } +} + String? _optionalScalarString(Object? value) => switch (value) { null => null, final String value => value, diff --git a/lib/services/plex_client.dart b/lib/services/plex_client.dart index 207e2319..0de09dd3 100644 --- a/lib/services/plex_client.dart +++ b/lib/services/plex_client.dart @@ -29,6 +29,7 @@ import 'settings_service.dart'; import 'library_query_translator.dart'; import 'scrub_preview_source.dart'; import '../utils/media_server_http_client.dart'; +import '../utils/url_utils.dart'; import '../exceptions/media_server_exceptions.dart'; import '../models/livetv_capture_buffer.dart'; import '../models/livetv_channel.dart'; @@ -164,6 +165,20 @@ List _processHubResponse( return hubs; } +/// Library-hub item filter. Music-section hubs carry artist/album/track items — +/// the default video-only filter would empty them out. +bool _videoOrMusicHubItem(PlexMetadataDto item) { + final type = item.type?.toLowerCase(); + return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type); +} + +/// Related-hub item filter: related rows include collection entries alongside +/// the usual video items. +bool _videoOrCollectionHubItem(PlexMetadataDto item) { + final type = item.type?.toLowerCase(); + return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection; +} + int? _librarySectionIdFromJson(Map? json) => plexLibrarySectionIdFromJson(json); int? _librarySectionIdFromString(String? sectionId) => plexLibrarySectionIdFromString(sectionId); @@ -252,9 +267,54 @@ class _PlexMediaProviderState { final String? continueWatchingHubKey; } +/// Canonical declarations of the [PlexClient] internals that the `part` +/// mixins below call into. +/// +/// Every part mixin is `on _PlexClientInternals`, so each shared member is +/// declared exactly once here instead of being re-declared (and drifting) +/// per file. Members used by a single part stay declared in that part. +mixin _PlexClientInternals on MediaServerCacheMixin { + FailoverHttpClient get _http; + + Future _getWithFailover( + String path, { + Map? queryParameters, + // ignore: unused_element_parameter + Map? headers, + // ignore: unused_element_parameter + Duration? timeout, + AbortController? abort, + bool allowEndpointFailover = true, + }); + + Map? _getMediaContainer(MediaServerResponse response); + + Map _buildPaginationParams(int? start, int? size); + + Future<_LibraryContentResult> _fetchPaginatedList( + String path, { + int? start, + int? size, + AbortController? abort, + int? librarySectionID, + String? librarySectionTitle, + }); + + Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); + + Future> _wrapListApiCall( + Future Function() apiCall, + List Function(MediaServerResponse response) parseResponse, + String errorMessage, + ); + + Future buildMetadataUri(String ratingKey); +} + class PlexClient with MediaServerCacheMixin, + _PlexClientInternals, _PlexLiveTvClientMethods, _PlexPlaylistMethods, _PlexCollectionMethods, @@ -1220,23 +1280,18 @@ class PlexClient static const int _fetchAllPageSize = 200; /// Iterate every page of a paginated endpoint and concatenate the results. - /// Stops as soon as [_LibraryContentResult.totalSize] is reached or a page - /// returns no items. Errors propagate. + /// Adapts Plex's [_LibraryContentResult] onto the shared [drainPages] drain, + /// so it stops as soon as [_LibraryContentResult.totalSize] is reached or a + /// page returns no items. Errors propagate. @override Future> _fetchAllPages( Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { AbortController? abort, - }) async { - final all = []; - var start = 0; - while (true) { - final page = await fetchPage(start, _fetchAllPageSize, abort); - all.addAll(page.items); - start += page.items.length; - if (page.items.isEmpty) break; - if (start >= page.totalSize) break; - } - return all; + }) { + return drainPages((start, size) async { + final page = await fetchPage(start, size, abort); + return LibraryPage(items: page.items, totalCount: page.totalSize, offset: start); + }, pageSize: _fetchAllPageSize); } /// Walk every page of [path] and return a single synthesized response whose @@ -2027,109 +2082,90 @@ class PlexClient return fallbackSorts; } + /// Shared transport for the hub endpoints: bounded transient retry with no + /// endpoint failover (a hub row is not worth flipping the active endpoint), + /// isolate-offloaded parsing, and log-and-empty on failure so one dead hub + /// row never takes down the screen around it. + /// + /// [failureLabel] names the hub set in the failure log line. + Future> _fetchHubs({ + required String path, + required Map queryParameters, + required String operation, + required List attemptTimeouts, + required String failureLabel, + int? librarySectionID, + String? librarySectionTitle, + bool Function(PlexMetadataDto)? filter, + }) async { + try { + final response = await retryTransientMediaServerCall( + operation: operation, + attemptTimeouts: attemptTimeouts, + call: (timeout, abort) => _getWithFailover( + path, + queryParameters: queryParameters, + timeout: timeout, + abort: abort, + allowEndpointFailover: false, + ), + ); + final sid = serverId; + final sname = serverName; + final data = response.data as Map; + return await tryIsolateRun( + () => _processHubResponse( + data, + sid, + sname, + librarySectionID: librarySectionID, + librarySectionTitle: librarySectionTitle, + filter: filter, + ), + ); + } catch (e) { + appLogger.e('Failed to get $failureLabel: $e'); + } + return []; + } + /// Get library hubs (recommendations for a specific library section) /// Returns a list of recommendation hubs like "Trending Movies", "Top in Genre", etc. Future> _getLibraryHubs( String sectionId, { int limit = defaultHubPreviewLimit, String? libraryName, - }) async { - try { - final response = await retryTransientMediaServerCall( - operation: 'Plex library hubs', - attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - '/hubs/sections/$sectionId', - queryParameters: {'count': limit, 'includeGuids': 1}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun( - () => _processHubResponse( - data, - sid, - sname, - librarySectionID: _librarySectionIdFromString(sectionId), - librarySectionTitle: libraryName, - // Music-section hubs carry artist/album/track items — the default - // video-only filter would empty them out. - filter: (item) { - final type = item.type?.toLowerCase(); - return ContentTypes.videoTypes.contains(type) || ContentTypes.musicTypes.contains(type); - }, - ), - ); - } catch (e) { - appLogger.e('Failed to get library hubs: $e'); - } - return []; - } + }) => _fetchHubs( + path: '/hubs/sections/$sectionId', + queryParameters: {'count': limit, 'includeGuids': 1}, + operation: 'Plex library hubs', + attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, + failureLabel: 'library hubs', + librarySectionID: _librarySectionIdFromString(sectionId), + librarySectionTitle: libraryName, + filter: _videoOrMusicHubItem, + ); /// Get global hubs (home page recommendations) /// Returns actual home page hubs like "Recently Added Movies", "Recently Added TV", etc. /// This matches the official Plex client's home page layout. - Future> _getGlobalHubs({int limit = defaultHubPreviewLimit}) async { - try { - final hubKey = _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs'; - final response = await retryTransientMediaServerCall( - operation: 'Plex global hubs', - attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - hubKey, - queryParameters: {'count': limit, 'includeGuids': 1}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun(() => _processHubResponse(data, sid, sname)); - } catch (e) { - appLogger.e('Failed to get global hubs: $e'); - } - return []; - } + Future> _getGlobalHubs({int limit = defaultHubPreviewLimit}) => _fetchHubs( + path: _providerPromotedHubKey ?? _providerHomeHubKey ?? '/hubs', + queryParameters: {'count': limit, 'includeGuids': 1}, + operation: 'Plex global hubs', + attemptTimeouts: MediaServerTimeouts.homeHubAttemptTimeouts, + failureLabel: 'global hubs', + ); /// Get related hubs for a specific metadata item (collections, similar, "more from" director/actor) - Future> _getRelatedHubs(String ratingKey, {int count = 10}) async { - try { - final response = await retryTransientMediaServerCall( - operation: 'Plex related hubs', - attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, - call: (timeout, abort) => _getWithFailover( - '/hubs/metadata/$ratingKey/related', - queryParameters: {'count': count}, - timeout: timeout, - abort: abort, - allowEndpointFailover: false, - ), - ); - final sid = serverId; - final sname = serverName; - final data = response.data as Map; - return await tryIsolateRun( - () => _processHubResponse( - data, - sid, - sname, - filter: (item) { - final type = item.type?.toLowerCase(); - return ContentTypes.videoTypes.contains(type) || type == ContentTypes.collection; - }, - ), - ); - } catch (e) { - appLogger.e('Failed to get related hubs: $e'); - } - return []; - } + Future> _getRelatedHubs(String ratingKey, {int count = 10}) => _fetchHubs( + path: '/hubs/metadata/$ratingKey/related', + queryParameters: {'count': count}, + operation: 'Plex related hubs', + attemptTimeouts: MediaServerTimeouts.libraryHubAttemptTimeouts, + failureLabel: 'related hubs', + filter: _videoOrCollectionHubItem, + ); /// Get full content from a hub using its hub key /// Returns the complete list of metadata items in the hub @@ -3136,36 +3172,10 @@ class PlexClient ); } catch (error, stackTrace) { if (error is PlaybackException) rethrow; - Error.throwWithStackTrace(_classifyPlaybackFailure(error), stackTrace); + Error.throwWithStackTrace(classifyPlaybackFailure(error), stackTrace); } } - PlaybackException _classifyPlaybackFailure(Object error) { - if (error is MediaServerAuthException || - error is MediaServerHttpException && (error.statusCode == 401 || error.statusCode == 403)) { - return PlaybackException( - t.messages.playbackAuthenticationRequired, - reason: PlaybackFailureReason.authenticationRequired, - ); - } - if (error is MediaServerHttpException) { - if (error.isCancellation) { - return PlaybackException(t.messages.playbackCancelled, reason: PlaybackFailureReason.cancelled); - } - final status = error.statusCode; - if (error.isTransient || status != null && status >= 500) { - return PlaybackException(t.messages.playbackServerUnavailable, reason: PlaybackFailureReason.serverUnavailable); - } - if (error.type == MediaServerHttpErrorType.unknown && status != null && status < 400) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - } - if (error is FormatException || error is TypeError) { - return PlaybackException(t.messages.playbackDataInvalid, reason: PlaybackFailureReason.invalidPlaybackData); - } - return PlaybackException(t.messages.playbackFailed); - } - /// Direct-play result for a transcode decision that fell back (failed or /// said direct-play only), surfacing the reason so the UI can notify the /// user. Shared by the video and music branches of diff --git a/lib/services/plex_client/parts/collections.dart b/lib/services/plex_client/parts/collections.dart index bc0d0e7c..3318a240 100644 --- a/lib/services/plex_client/parts/collections.dart +++ b/lib/services/plex_client/parts/collections.dart @@ -1,23 +1,6 @@ part of '../../plex_client.dart'; -mixin _PlexCollectionMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - Map _buildPaginationParams(int? start, int? size); - +mixin _PlexCollectionMethods on _PlexClientInternals { _LibraryContentResult _extractLibraryContentResult( MediaServerResponse response, { int? librarySectionID, @@ -27,25 +10,12 @@ mixin _PlexCollectionMethods on MediaServerCacheMixin { int? requestedSize, }); - Future<_LibraryContentResult> _fetchPaginatedList( - String path, { - int? start, - int? size, - AbortController? abort, - int? librarySectionID, - String? librarySectionTitle, - }); - Future> _fetchAllPages( Future<_LibraryContentResult> Function(int start, int size, AbortController? abort) fetchPage, { // ignore: unused_element_parameter AbortController? abort, }); - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future buildMetadataUri(String ratingKey); - Future<_LibraryContentResult> _getLibraryCollectionsPage( String sectionId, { int? start, diff --git a/lib/services/plex_client/parts/live_tv.dart b/lib/services/plex_client/parts/live_tv.dart index d3aa59d2..33eadb1e 100644 --- a/lib/services/plex_client/parts/live_tv.dart +++ b/lib/services/plex_client/parts/live_tv.dart @@ -3,39 +3,13 @@ part of '../../plex_client.dart'; const _favoriteChannelsUrl = 'https://epg.provider.plex.tv/settings/favoriteChannels'; const _providerVersionHeader = {'X-Plex-Provider-Version': '5.1'}; -mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport, LiveTvDvrSupport { +mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport, LiveTvDvrSupport { PlexConfig get config; - MediaServerHttpClient get _http; - - @override - ServerId get serverId; - - @override - String? get serverName; List<({String identifier, String gridEndpoint})> get _providerEpg; - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); PlexMetadataDto _createTaggedMetadata(Map json); - Future> _wrapListApiCall( - Future Function() apiCall, - List Function(MediaServerResponse response) parseResponse, - String errorMessage, - ); - /// POST the tune endpoint with one retry on transient HTTP failure. Future _postTuneWithRetry(String path, String sessionIdentifier) async { final query = {'X-Plex-Session-Identifier': sessionIdentifier}; @@ -97,7 +71,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport for (final entry in request.prefs.entries) 'prefs[${entry.key}]': entry.value, for (final entry in request.params.entries) 'params[${entry.key}]': entry.value, }; - final encoded = MediaServerHttpClient.encodeQueryParameters(flat); + final encoded = encodeQueryParameters(flat); if (encoded.isNotEmpty) parts.add(encoded); return parts.join('&'); } @@ -750,10 +724,9 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport if (config.token != null) 'X-Plex-Token': config.token!, }; - // Manual query encoding — use '%20' for spaces as Plex requires. - final queryString = allParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); + // '%20' for spaces as Plex requires — not `Uri.queryParameters`, which + // emits `+`. + final queryString = encodeQueryParameters(allParams); // Decision — wrapper around the same transport so no default X-Plex-* // HTTP headers leak through (everything travels in the query string). @@ -783,9 +756,7 @@ mixin _PlexLiveTvClientMethods on MediaServerCacheMixin implements LiveTvSupport // Token is added by the caller via .withPlexToken() final startParams = Map.from(allParams)..remove('X-Plex-Token'); - final startQuery = startParams.entries - .map((e) => '${Uri.encodeComponent(e.key)}=${Uri.encodeComponent(e.value)}') - .join('&'); + final startQuery = encodeQueryParameters(startParams); return '$_plexVideoHlsStartEndpoint?$startQuery'; } catch (e, st) { diff --git a/lib/services/plex_client/parts/metadata_edit.dart b/lib/services/plex_client/parts/metadata_edit.dart index 472dc273..f5e00d81 100644 --- a/lib/services/plex_client/parts/metadata_edit.dart +++ b/lib/services/plex_client/parts/metadata_edit.dart @@ -1,33 +1,7 @@ part of '../../plex_client.dart'; -mixin _PlexMetadataEditMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; +mixin _PlexMetadataEditMethods on _PlexClientInternals { PlexApiCache get _cache; - @override - ServerId get serverId; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future> _wrapListApiCall( - Future Function() apiCall, - List Function(MediaServerResponse response) parseResponse, - String errorMessage, - ); Future updateMetadata({ required int sectionId, diff --git a/lib/services/plex_client/parts/play_queues.dart b/lib/services/plex_client/parts/play_queues.dart index ee501e70..d6800ecc 100644 --- a/lib/services/plex_client/parts/play_queues.dart +++ b/lib/services/plex_client/parts/play_queues.dart @@ -1,29 +1,12 @@ part of '../../plex_client.dart'; -mixin _PlexPlayQueueMethods on MediaServerCacheMixin { - FailoverHttpClient get _http; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - // ignore: unused_element_parameter - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - +mixin _PlexPlayQueueMethods on _PlexClientInternals { PlexMetadataDto _createTaggedMetadataWithLibrary( Map json, { int? librarySectionID, String? librarySectionTitle, }); - Future buildMetadataUri(String ratingKey); - PlayQueueResponse _parsePlayQueueResponse(dynamic data, {int? librarySectionID, String? librarySectionTitle}) { final container = data is Map && data['MediaContainer'] is Map ? data['MediaContainer'] as Map diff --git a/lib/services/plex_client/parts/playlists.dart b/lib/services/plex_client/parts/playlists.dart index 566fe0fe..6123ee9b 100644 --- a/lib/services/plex_client/parts/playlists.dart +++ b/lib/services/plex_client/parts/playlists.dart @@ -1,62 +1,24 @@ part of '../../plex_client.dart'; -mixin _PlexPlaylistMethods on MediaServerCacheMixin { +mixin _PlexPlaylistMethods on _PlexClientInternals { static const int _playlistPageSize = 200; static const int _defaultPlaylistContainerSize = 100; - FailoverHttpClient get _http; - @override - ServerId get serverId; - @override - String? get serverName; - - Future _getWithFailover( - String path, { - Map? queryParameters, - // ignore: unused_element_parameter - Map? headers, - // ignore: unused_element_parameter - Duration? timeout, - AbortController? abort, - // ignore: unused_element_parameter - bool allowEndpointFailover = true, - }); - - Map? _getMediaContainer(MediaServerResponse response); - Map _buildPaginationParams(int? start, int? size); - - Future<_LibraryContentResult> _fetchPaginatedList(String path, {int? start, int? size, AbortController? abort}); - ({List items, int totalSize}) _extractPlaylistListResult( MediaServerResponse response, { int? start, int? size, }); - Future _wrapBoolApiCall(Future Function() apiCall, String errorMessage); - - Future buildMetadataUri(String ratingKey); - Future<_LibraryContentResult> _getPlaylist(String playlistId, {int? start, int? size, AbortController? abort}) => _fetchPaginatedList('/playlists/$playlistId/items', start: start, size: size, abort: abort); Future> _getPlaylists({String playlistType = 'video', bool? smart}) async { try { - final all = []; - var start = 0; - while (true) { - final page = await _getPlaylistsPage( - playlistType: playlistType, - smart: smart, - start: start, - size: _playlistPageSize, - ); - if (page.items.isEmpty) break; - all.addAll(page.items); - start += page.items.length; - if (start >= page.totalSize) break; - } - return all; + return await drainPages((start, size) async { + final page = await _getPlaylistsPage(playlistType: playlistType, smart: smart, start: start, size: size); + return LibraryPage(items: page.items, totalCount: page.totalSize, offset: start); + }, pageSize: _playlistPageSize); } catch (e, st) { appLogger.e('Failed to get playlists', error: e, stackTrace: st); return []; diff --git a/lib/services/seerr/seerr_http_client.dart b/lib/services/seerr/seerr_http_client.dart index ea70f065..a06e3052 100644 --- a/lib/services/seerr/seerr_http_client.dart +++ b/lib/services/seerr/seerr_http_client.dart @@ -7,6 +7,7 @@ import '../../utils/app_logger.dart'; import '../../utils/platform_http_client_stub.dart' if (dart.library.io) '../../utils/platform_http_client_io.dart' as platform; +import '../../utils/url_utils.dart'; import '../trackers/tracker_http_client.dart'; import 'seerr_constants.dart'; import 'seerr_exceptions.dart'; @@ -26,9 +27,8 @@ class SeerrResponse { /// Adds the two things the tracker HTTP layer doesn't cover: /// 1. `connect.sid` cookie capture from `Set-Cookie` on login, replayed as /// `Cookie:` on every subsequent request — Express session auth. -/// 2. Query encoding with `%20` for spaces: Seerr proxies `/search` to -/// TMDB, which rejects `+` in the query value, so `Uri.queryParameters` -/// (which emits `+`) cannot be used. +/// 2. Query encoding via [encodeQueryParameters] (`%20` for spaces): Seerr +/// proxies `/search` to TMDB, which rejects `+` in the query value. class SeerrHttpClient { final String baseUrl; final http.Client _http; @@ -110,12 +110,8 @@ class SeerrHttpClient { Uri _uri(String path, Map? query) { final base = Uri.parse('$baseUrl${SeerrConstants.apiPath}$path'); - if (query == null || query.isEmpty) return base; - final parts = [ - for (final entry in query.entries) - if (entry.value != null) '${Uri.encodeComponent(entry.key)}=${Uri.encodeComponent(entry.value.toString())}', - ]; - return parts.isEmpty ? base : base.replace(query: parts.join('&')); + final encoded = encodeQueryParameters(query); + return encoded.isEmpty ? base : base.replace(query: encoded); } /// Throw the mapped exception for a 4xx/5xx response; no-op on success. diff --git a/lib/services/settings_export_service.dart b/lib/services/settings_export_service.dart index 13f3987b..531f38aa 100644 --- a/lib/services/settings_export_service.dart +++ b/lib/services/settings_export_service.dart @@ -17,7 +17,6 @@ import '../utils/platform_detector.dart'; import 'file_picker_service.dart'; import 'settings_service.dart'; import 'storage_service.dart'; -import 'trackers/tracker_constants.dart'; class ImportResult { final int keysImported; @@ -87,120 +86,13 @@ class SettingsExportService { static const Set _nonPortableDeviceStorageKeys = {'custom_download_path', 'custom_download_path_type'}; static const String _tvosDatabaseRecoveryPrefix = 'tvos_db_recovery_'; - /// Closed registry of portable, user-facing settings. The [Pref] declarations - /// are the source of truth for both keys and stored types; credentials, - /// runtime state, device paths, history, endpoints, and user-authored player + /// Closed registry of portable, user-facing settings, keyed by preference + /// key. [SettingsService.portablePrefs] is the source of truth for membership + /// and the [Pref] declarations for the stored types; credentials, runtime + /// state, device paths, history, endpoints, and user-authored player /// configuration are intentionally absent. static final Map _portablePreferences = { - for (final pref in >[ - SettingsService.enableDebugLogging, - SettingsService.enableHardwareDecoding, - SettingsService.enableHDR, - SettingsService.preferredVideoCodec, - SettingsService.preferredAudioCodec, - SettingsService.viewMode, - SettingsService.seekTimeSmall, - SettingsService.seekTimeLarge, - SettingsService.rewindOnResume, - SettingsService.showHeroSection, - SettingsService.tvFullCardLayout, - SettingsService.focusGlow, - SettingsService.useGlobalHubs, - SettingsService.showServerNameOnHubs, - SettingsService.groupLibrariesByServer, - SettingsService.sleepTimerDuration, - SettingsService.audioSyncOffset, - SettingsService.subtitleSyncOffset, - SettingsService.subtitleSearchLanguage, - SettingsService.volume, - SettingsService.rotationLocked, - SettingsService.subtitleFontSize, - SettingsService.subtitleTextColor, - SettingsService.subtitleBorderSize, - SettingsService.subtitleBorderColor, - SettingsService.subtitleBackgroundColor, - SettingsService.subtitleBackgroundOpacity, - SettingsService.subAssOverride, - SettingsService.subtitleRenderResolution, - SettingsService.subtitleBold, - SettingsService.subtitleItalic, - SettingsService.rememberTrackSelections, - SettingsService.showChapterMarkersOnTimeline, - SettingsService.clickVideoTogglesPlayback, - SettingsService.autoSkipIntro, - SettingsService.autoSkipCredits, - SettingsService.forceSkipMarkerFallback, - SettingsService.autoSkipDelay, - SettingsService.introPattern, - SettingsService.creditsPattern, - SettingsService.downloadOnWifiOnly, - SettingsService.autoRemoveWatchedDownloads, - SettingsService.downloadIncludeSpecials, - SettingsService.autoCheckUpdatesOnStartup, - SettingsService.showPerformanceOverlay, - SettingsService.autoHidePerformanceOverlay, - SettingsService.enableDiscordRPC, - SettingsService.enableTraktScrobble, - SettingsService.enableTraktWatchedSync, - SettingsService.enableMalScrobble, - SettingsService.enableAnilistScrobble, - SettingsService.enableSimklScrobble, - SettingsService.matchContentFrameRate, - SettingsService.tunneledPlayback, - SettingsService.dvConversionMode, - SettingsService.defaultQualityPreset, - SettingsService.musicQualityPreset, - SettingsService.musicVolume, - SettingsService.autoPlayNextEpisode, - SettingsService.useExoPlayer, - SettingsService.startupSection, - SettingsService.alwaysKeepSidebarOpen, - SettingsService.showUnwatchedCount, - SettingsService.showEpisodeNumberOnCards, - SettingsService.showSeasonPostersOnTabs, - SettingsService.hideSpoilers, - SettingsService.showNavBarLabels, - SettingsService.globalShaderPreset, - SettingsService.requireProfileSelectionOnOpen, - SettingsService.useExternalPlayer, - SettingsService.forceTvMode, - SettingsService.visualEffects, - SettingsService.ambientLighting, - SettingsService.audioPassthrough, - SettingsService.audioNormalization, - SettingsService.audioDownmix, - SettingsService.audioDownmixNormalize, - SettingsService.liveTvDefaultFavorites, - SettingsService.matchRefreshRate, - SettingsService.matchDynamicRange, - SettingsService.appLocale, - SettingsService.autoPip, - SettingsService.maxVolume, - SettingsService.downmixCenterBoost, - SettingsService.subtitlePosition, - SettingsService.defaultPlaybackSpeed, - SettingsService.defaultBoxFitMode, - SettingsService.displaySwitchDelay, - SettingsService.themeMode, - SettingsService.videoPlayerNavigationEnabled, - SettingsService.enableCompanionRemoteServer, - SettingsService.startInFullscreen, - SettingsService.exitFullscreenOnPlayerClose, - SettingsService.bufferSize, - SettingsService.libraryDensity, - SettingsService.tvCornerSpotlightBackdrop, - SettingsService.episodePosterMode, - SettingsService.continueWatchingAction, - SettingsService.episodeAction, - SettingsService.keyboardHotkeys, - ]) - pref.key: _PreferencePolicy(_storageTypeFor(pref)), - for (final service in TrackerService.values) - for (final pref in >[ - SettingsService.trackerFilterModePref(service), - SettingsService.trackerFilterIdsPref(service), - ]) - pref.key: _PreferencePolicy(_storageTypeFor(pref)), + for (final pref in SettingsService.portablePrefs) pref.key: _PreferencePolicy(_storageTypeFor(pref)), }; static const Set _jsonStringListPreferenceKeys = {'hidden_libraries', 'library_order'}; diff --git a/lib/services/settings_service.dart b/lib/services/settings_service.dart index ed651dd2..a9a345de 100644 --- a/lib/services/settings_service.dart +++ b/lib/services/settings_service.dart @@ -14,6 +14,7 @@ import '../models/mpv_config_models.dart'; import '../models/external_player_models.dart'; import 'base_shared_preferences_service.dart'; import 'device_performance.dart'; +import 'shortcut_action.dart'; export 'base_shared_preferences_service.dart' show Pref, BoolPref, IntPref, DoublePref, StringPref, NullableStringPref, StringListPref, EnumPref, JsonPref; import '../models/audio_quality_preset.dart'; @@ -278,33 +279,7 @@ List _decodeMpvPresets(dynamic raw) { } Map _defaultKeyboardHotkeys() => { - 'play_pause': const HotKey(key: PhysicalKeyboardKey.space), - 'volume_up': const HotKey(key: PhysicalKeyboardKey.arrowUp), - 'volume_down': const HotKey(key: PhysicalKeyboardKey.arrowDown), - 'seek_forward': const HotKey(key: PhysicalKeyboardKey.arrowRight), - 'seek_backward': const HotKey(key: PhysicalKeyboardKey.arrowLeft), - 'seek_forward_large': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), - 'seek_backward_large': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), - 'fullscreen_toggle': const HotKey(key: PhysicalKeyboardKey.keyF), - 'mute_toggle': const HotKey(key: PhysicalKeyboardKey.keyM), - 'subtitle_toggle': const HotKey(key: PhysicalKeyboardKey.keyS), - 'audio_track_next': const HotKey(key: PhysicalKeyboardKey.keyA), - 'subtitle_track_next': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), - 'chapter_next': const HotKey(key: PhysicalKeyboardKey.keyN), - 'chapter_previous': const HotKey(key: PhysicalKeyboardKey.keyP), - 'episode_next': const HotKey(key: PhysicalKeyboardKey.keyN, modifiers: [HotKeyModifier.shift]), - 'episode_previous': const HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.shift]), - 'speed_increase': const HotKey(key: PhysicalKeyboardKey.equal), - 'speed_decrease': const HotKey(key: PhysicalKeyboardKey.minus), - 'speed_reset': const HotKey(key: PhysicalKeyboardKey.keyR), - 'zoom_in': const HotKey(key: PhysicalKeyboardKey.equal, modifiers: [HotKeyModifier.alt]), - 'zoom_out': const HotKey(key: PhysicalKeyboardKey.minus, modifiers: [HotKeyModifier.alt]), - 'zoom_reset': const HotKey(key: PhysicalKeyboardKey.backspace, modifiers: [HotKeyModifier.alt]), - 'sub_seek_next': const HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), - 'sub_seek_prev': const HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), - 'shader_toggle': const HotKey(key: PhysicalKeyboardKey.keyG), - 'skip_marker': const HotKey(key: PhysicalKeyboardKey.enter), - 'screenshot': const HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.control]), + for (final action in ShortcutAction.values) action.id: action.defaultHotKey, }; Map _decodeKeyboardHotkeys(dynamic raw) { @@ -391,11 +366,7 @@ class SettingsService extends BaseSharedPreferencesService { static const showPerformanceOverlay = BoolPref('show_performance_overlay'); static const autoHidePerformanceOverlay = BoolPref('auto_hide_performance_overlay', defaultValue: true); static const enableDiscordRPC = BoolPref('enable_discord_rpc'); - static const enableTraktScrobble = BoolPref('enable_trakt_scrobble', defaultValue: true); static const enableTraktWatchedSync = BoolPref('enable_trakt_watched_sync', defaultValue: true); - static const enableMalScrobble = BoolPref('enable_mal_scrobble', defaultValue: true); - static const enableAnilistScrobble = BoolPref('enable_anilist_scrobble', defaultValue: true); - static const enableSimklScrobble = BoolPref('enable_simkl_scrobble', defaultValue: true); static const matchContentFrameRate = BoolPref('match_content_frame_rate'); static const tunneledPlayback = BoolPref('tunneled_playback', defaultValue: true); static const dvConversionMode = EnumPref( @@ -567,6 +538,11 @@ class SettingsService extends BaseSharedPreferencesService { /// keeps applying until the user chooses). static IntPref dvrTargetSectionPref(ServerId serverId, int type) => IntPref('dvr_target_section_${type}_$serverId'); + /// Per-service "scrobble to this tracker" toggle. Trakt's second toggle + /// ([enableTraktWatchedSync]) has no counterpart on the other services and + /// stays a standalone constant. + static BoolPref scrobblePref(TrackerService s) => BoolPref('enable_${s.name}_scrobble', defaultValue: true); + static EnumPref trackerFilterModePref(TrackerService s) => EnumPref( 'tracker_library_filter_mode_${s.name}', values: TrackerLibraryFilterMode.values, @@ -825,55 +801,47 @@ class SettingsService extends BaseSharedPreferencesService { return null; } - /// Settings that "Reset All Settings" actually resets. Mirrors the original - /// reset surface — notably excludes user-customized data (intro/credits regex - /// patterns) and opt-in toggles prior versions didn't reset, so behavior - /// stays identical for users. - static List> _resettablePrefs() => [ + /// Preference registry behind "Reset All Settings" and settings export. + /// Every participating preference is named exactly once, in the group that + /// states its policy; anything absent from all three groups takes part in + /// neither surface (credentials, runtime state, migration sentinels). + /// + /// Group one: reset *and* exported — the ordinary case. + static final List> _resetAndPortablePrefs = [ enableDebugLogging, - bufferSize, enableHardwareDecoding, enableHDR, preferredVideoCodec, preferredAudioCodec, viewMode, - showHeroSection, - continueWatchingAction, - episodeAction, seekTimeSmall, seekTimeLarge, + showHeroSection, sleepTimerDuration, audioSyncOffset, subtitleSyncOffset, subtitleSearchLanguage, volume, - maxVolume, subtitleFontSize, subtitleTextColor, subtitleBorderSize, subtitleBorderColor, subtitleBackgroundColor, subtitleBackgroundOpacity, - subtitlePosition, rememberTrackSelections, - customDownloadPathType, downloadOnWifiOnly, downloadIncludeSpecials, autoCheckUpdatesOnStartup, showPerformanceOverlay, autoHidePerformanceOverlay, enableDiscordRPC, - enableTraktScrobble, enableTraktWatchedSync, - enableMalScrobble, - enableAnilistScrobble, - enableSimklScrobble, + // Scrobble toggle, one per tracker service. + for (final s in TrackerService.values) scrobblePref(s), matchContentFrameRate, tunneledPlayback, dvConversionMode, musicVolume, - defaultPlaybackSpeed, - defaultBoxFitMode, autoPlayNextEpisode, useExoPlayer, startupSection, @@ -893,20 +861,71 @@ class SettingsService extends BaseSharedPreferencesService { audioNormalization, audioDownmix, audioDownmixNormalize, + appLocale, + autoPip, + maxVolume, downmixCenterBoost, + subtitlePosition, + defaultPlaybackSpeed, + defaultBoxFitMode, themeMode, - keyboardHotkeys, + videoPlayerNavigationEnabled, + bufferSize, libraryDensity, tvCornerSpotlightBackdrop, episodePosterMode, + continueWatchingAction, + episodeAction, + keyboardHotkeys, + // Library filters, one pair per tracker service. + for (final s in TrackerService.values) ...[trackerFilterModePref(s), trackerFilterIdsPref(s)], + ]; + + /// Group two: exported but *not* reset. Mirrors the original reset surface — + /// user-customized data (intro/credits regex patterns) and opt-in toggles + /// prior versions didn't reset, so behavior stays identical for users. + static final List> _portableOnlyPrefs = [ + rewindOnResume, + tvFullCardLayout, + focusGlow, + useGlobalHubs, + showServerNameOnHubs, + groupLibrariesByServer, + rotationLocked, + subAssOverride, + subtitleRenderResolution, + subtitleBold, + subtitleItalic, + showChapterMarkersOnTimeline, + clickVideoTogglesPlayback, + autoSkipIntro, + autoSkipCredits, + forceSkipMarkerFallback, + autoSkipDelay, + introPattern, + creditsPattern, + autoRemoveWatchedDownloads, + defaultQualityPreset, + musicQualityPreset, + liveTvDefaultFavorites, + matchRefreshRate, + matchDynamicRange, + displaySwitchDelay, + enableCompanionRemoteServer, + startInFullscreen, + exitFullscreenOnPlayerClose, + ]; + + /// Group three: reset but *not* exported — device-local paths, endpoints and + /// per-device state plus user-authored player configuration, none of which + /// should travel between installations. + static final List> _resetOnlyPrefs = [ + customDownloadPathType, mediaVersionPreferences, localLastPlayedAt, - appLocale, customDownloadPath, - videoPlayerNavigationEnabled, mpvConfigText, mpvPresets, - autoPip, customShaderPresets, selectedExternalPlayer, customExternalPlayers, @@ -914,17 +933,19 @@ class SettingsService extends BaseSharedPreferencesService { companionRemoteLastHostAddress, ]; + /// Settings that "Reset All Settings" actually resets. + static List> get _resettablePrefs => [..._resetAndPortablePrefs, ..._resetOnlyPrefs]; + + /// Settings carried by settings export/import files. + static List> get portablePrefs => [..._resetAndPortablePrefs, ..._portableOnlyPrefs]; + Future resetAllSettings() async { - final resettable = _resettablePrefs(); await Future.wait([ - ...resettable.map((p) => prefs.remove(p.key)), + ..._resettablePrefs.map((p) => prefs.remove(p.key)), // Legacy migration sentinels — removed alongside the keys they guarded. prefs.remove(_legacyUseSeasonPosterKey), prefs.remove(_legacyMpvConfigEntriesKey), prefs.remove(_bufferSizeMigratedKey), - ...TrackerService.values.expand( - (s) => [prefs.remove(trackerFilterModePref(s).key), prefs.remove(trackerFilterIdsPref(s).key)], - ), ]); refreshListenables(); } diff --git a/lib/services/shader_asset_loader.dart b/lib/services/shader_asset_loader.dart index 56a27252..b9ce944a 100644 --- a/lib/services/shader_asset_loader.dart +++ b/lib/services/shader_asset_loader.dart @@ -174,12 +174,7 @@ class ShaderAssetLoader { /// Get the shader file path for an ArtCNN preset. /// Returns a list containing exactly one ArtCNN shader path. static Future> getArtCNNShaders(ArtCNNConfig config) async { - final variantId = switch (config.variant) { - ArtCNNVariant.neutral => 'neutral', - ArtCNNVariant.denoise => 'dn', - ArtCNNVariant.denoiseSharpen => 'ds', - }; - final shaderPath = await _extractShader(_artcnnShaders['${config.model.name}_$variantId']!); + final shaderPath = await _extractShader(_artcnnShaders['${config.model.name}_${config.variant.slug}']!); if (shaderPath == null) return []; return [shaderPath]; } diff --git a/lib/services/shortcut_action.dart b/lib/services/shortcut_action.dart new file mode 100644 index 00000000..34f9364e --- /dev/null +++ b/lib/services/shortcut_action.dart @@ -0,0 +1,139 @@ +import 'package:flutter/services.dart'; + +import '../i18n/strings.g.dart'; +import '../models/hotkey_model.dart'; +import 'shader_service.dart'; + +/// Every keyboard shortcut the video player understands. +/// +/// One row per action carries everything about it except the behaviour: the +/// persisted [id], the [defaultHotKey] shipped with the app, the localized +/// [label], and the capability flags that gate dispatch. Adding a shortcut is +/// one entry here plus a case in `KeyboardShortcutsService._executeAction`, +/// which the analyzer demands because that switch is exhaustive over this enum. +/// +/// Declaration order is the order shortcuts are listed in settings, and [id] is +/// persisted in preferences — do not reorder or rename existing entries. +enum ShortcutAction { + playPause('play_pause', HotKey(key: PhysicalKeyboardKey.space), requiresPlayback: true), + volumeUp('volume_up', HotKey(key: PhysicalKeyboardKey.arrowUp)), + volumeDown('volume_down', HotKey(key: PhysicalKeyboardKey.arrowDown)), + seekForward('seek_forward', HotKey(key: PhysicalKeyboardKey.arrowRight), requiresPlayback: true), + seekBackward('seek_backward', HotKey(key: PhysicalKeyboardKey.arrowLeft), requiresPlayback: true), + seekForwardLarge( + 'seek_forward_large', + HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + seekBackwardLarge( + 'seek_backward_large', + HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + fullscreenToggle('fullscreen_toggle', HotKey(key: PhysicalKeyboardKey.keyF)), + muteToggle('mute_toggle', HotKey(key: PhysicalKeyboardKey.keyM)), + subtitleToggle('subtitle_toggle', HotKey(key: PhysicalKeyboardKey.keyS)), + audioTrackNext('audio_track_next', HotKey(key: PhysicalKeyboardKey.keyA), requiresPlayback: true), + subtitleTrackNext( + 'subtitle_track_next', + HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.shift]), + requiresPlayback: true, + ), + chapterNext('chapter_next', HotKey(key: PhysicalKeyboardKey.keyN), requiresPlayback: true), + chapterPrevious('chapter_previous', HotKey(key: PhysicalKeyboardKey.keyP), requiresPlayback: true), + episodeNext( + 'episode_next', + HotKey(key: PhysicalKeyboardKey.keyN, modifiers: [HotKeyModifier.shift]), + requiresMediaNavigation: true, + ), + episodePrevious( + 'episode_previous', + HotKey(key: PhysicalKeyboardKey.keyP, modifiers: [HotKeyModifier.shift]), + requiresMediaNavigation: true, + ), + speedIncrease('speed_increase', HotKey(key: PhysicalKeyboardKey.equal), requiresPlayback: true), + speedDecrease('speed_decrease', HotKey(key: PhysicalKeyboardKey.minus), requiresPlayback: true), + speedReset('speed_reset', HotKey(key: PhysicalKeyboardKey.keyR), requiresPlayback: true), + zoomIn('zoom_in', HotKey(key: PhysicalKeyboardKey.equal, modifiers: [HotKeyModifier.alt]), repeatable: true), + zoomOut('zoom_out', HotKey(key: PhysicalKeyboardKey.minus, modifiers: [HotKeyModifier.alt]), repeatable: true), + zoomReset('zoom_reset', HotKey(key: PhysicalKeyboardKey.backspace, modifiers: [HotKeyModifier.alt])), + subSeekNext( + 'sub_seek_next', + HotKey(key: PhysicalKeyboardKey.arrowRight, modifiers: [HotKeyModifier.control]), + requiresPlayback: true, + ), + subSeekPrev( + 'sub_seek_prev', + HotKey(key: PhysicalKeyboardKey.arrowLeft, modifiers: [HotKeyModifier.control]), + requiresPlayback: true, + ), + shaderToggle('shader_toggle', HotKey(key: PhysicalKeyboardKey.keyG), requiresShaderSupport: true), + skipMarker('skip_marker', HotKey(key: PhysicalKeyboardKey.enter), requiresPlayback: true), + screenshot('screenshot', HotKey(key: PhysicalKeyboardKey.keyS, modifiers: [HotKeyModifier.control])); + + const ShortcutAction( + this.id, + this.defaultHotKey, { + this.repeatable = false, + this.requiresPlayback = false, + this.requiresMediaNavigation = false, + this.requiresShaderSupport = false, + }); + + /// Stable key this action is stored under in preferences. + final String id; + + /// Shortcut used until the user assigns their own. + final HotKey defaultHotKey; + + /// Whether holding the key repeats the action instead of swallowing repeats. + final bool repeatable; + + /// Whether the action drives playback and needs playback authority. + final bool requiresPlayback; + + /// Whether the action switches media item and needs navigation authority. + final bool requiresMediaNavigation; + + /// Whether the action is only meaningful where shaders are available. + final bool requiresShaderSupport; + + static final Map _byId = {for (final action in values) action.id: action}; + + /// The action stored under [id], or null for an id this build does not know. + static ShortcutAction? fromId(String id) => _byId[id]; + + /// Whether this action can be used on the current platform. + bool get isSupported => !requiresShaderSupport || ShaderService.isPlatformSupported; + + /// Localized name shown in settings; seek labels embed the configured steps. + String label({required int seekTimeSmall, required int seekTimeLarge}) => switch (this) { + ShortcutAction.playPause => t.hotkeys.actions.playPause, + ShortcutAction.volumeUp => t.hotkeys.actions.volumeUp, + ShortcutAction.volumeDown => t.hotkeys.actions.volumeDown, + ShortcutAction.seekForward => t.hotkeys.actions.seekForward(seconds: seekTimeSmall), + ShortcutAction.seekBackward => t.hotkeys.actions.seekBackward(seconds: seekTimeSmall), + ShortcutAction.seekForwardLarge => t.hotkeys.actions.seekForward(seconds: seekTimeLarge), + ShortcutAction.seekBackwardLarge => t.hotkeys.actions.seekBackward(seconds: seekTimeLarge), + ShortcutAction.fullscreenToggle => t.hotkeys.actions.fullscreenToggle, + ShortcutAction.muteToggle => t.hotkeys.actions.muteToggle, + ShortcutAction.subtitleToggle => t.hotkeys.actions.subtitleToggle, + ShortcutAction.audioTrackNext => t.hotkeys.actions.audioTrackNext, + ShortcutAction.subtitleTrackNext => t.hotkeys.actions.subtitleTrackNext, + ShortcutAction.chapterNext => t.hotkeys.actions.chapterNext, + ShortcutAction.chapterPrevious => t.hotkeys.actions.chapterPrevious, + ShortcutAction.episodeNext => t.hotkeys.actions.episodeNext, + ShortcutAction.episodePrevious => t.hotkeys.actions.episodePrevious, + ShortcutAction.speedIncrease => t.hotkeys.actions.speedIncrease, + ShortcutAction.speedDecrease => t.hotkeys.actions.speedDecrease, + ShortcutAction.speedReset => t.hotkeys.actions.speedReset, + ShortcutAction.zoomIn => t.hotkeys.actions.zoomIn, + ShortcutAction.zoomOut => t.hotkeys.actions.zoomOut, + ShortcutAction.zoomReset => t.hotkeys.actions.zoomReset, + ShortcutAction.subSeekNext => t.hotkeys.actions.subSeekNext, + ShortcutAction.subSeekPrev => t.hotkeys.actions.subSeekPrev, + ShortcutAction.shaderToggle => t.hotkeys.actions.shaderToggle, + ShortcutAction.skipMarker => t.hotkeys.actions.skipMarker, + ShortcutAction.screenshot => t.hotkeys.actions.screenshot, + }; +} diff --git a/lib/services/storage_service.dart b/lib/services/storage_service.dart index ebf309e3..34f4cccf 100644 --- a/lib/services/storage_service.dart +++ b/lib/services/storage_service.dart @@ -113,20 +113,38 @@ class StorageService extends BaseSharedPreferencesService { String _userPrefixForProfileId(String profileId) => 'user_${userScopeForProfileId(profileId)}_'; - /// Read a string with user-scoped key, migrating from legacy key if needed. - String? _getScopedString(String baseKey) { - final scopedKey = '$_userPrefix$baseKey'; - final value = prefs.getString(scopedKey); - if (value != null || _userPrefix.isEmpty) return value; - // One-time migration from legacy global key - final legacy = prefs.getString(baseKey); + /// Read [baseKey] from the [prefix]-scoped slot, adopting the legacy + /// unscoped value once: the scoped key wins; otherwise the unscoped value is + /// copied into it and the unscoped key removed. [read]/[write] carry the + /// per-type codec. Adoption is skipped when [prefix] is empty (no scope to + /// migrate into) or [allowLegacyAdoption] is false (a scope other than the + /// active profile's, which must not steal legacy prefs). + T? _readScopedWithLegacyMigration( + String baseKey, { + required String prefix, + required T? Function(String key) read, + required void Function(String key, T value) write, + bool allowLegacyAdoption = true, + }) { + final scopedKey = '$prefix$baseKey'; + final value = read(scopedKey); + if (value != null || prefix.isEmpty || !allowLegacyAdoption) return value; + final legacy = read(baseKey); if (legacy != null) { - prefs.setString(scopedKey, legacy); + write(scopedKey, legacy); prefs.remove(baseKey); } return legacy; } + /// Read a string with user-scoped key, migrating from legacy key if needed. + String? _getScopedString(String baseKey) => _readScopedWithLegacyMigration( + baseKey, + prefix: _userPrefix, + read: prefs.getString, + write: prefs.setString, + ); + // Per-Server Endpoint URL (for multi-server connection caching) Future saveServerEndpoint(ServerId serverId, String url) async { await prefs.setString('$_prefixServerEndpoint$serverId', url); @@ -217,19 +235,12 @@ class StorageService extends BaseSharedPreferencesService { await _setJsonMap('$_userPrefix$_prefixLibrarySort$sectionId', sortData); } - Map? getLibrarySort(String sectionId) { - final baseKey = '$_prefixLibrarySort$sectionId'; - final scopedKey = '$_userPrefix$baseKey'; - var result = _readJsonMap(scopedKey, legacyStringOk: true); - if (result != null || _userPrefix.isEmpty) return result; - // One-time migration from legacy key - result = _readJsonMap(baseKey, legacyStringOk: true); - if (result != null) { - _setJsonMap(scopedKey, result); - prefs.remove(baseKey); - } - return result; - } + Map? getLibrarySort(String sectionId) => _readScopedWithLegacyMigration>( + '$_prefixLibrarySort$sectionId', + prefix: _userPrefix, + read: (key) => _readJsonMap(key, legacyStringOk: true), + write: _setJsonMap, + ); // Library Grouping (per-library, e.g., 'movies', 'shows', 'seasons', 'episodes') Future saveLibraryGrouping(String sectionId, String grouping) async { @@ -270,22 +281,18 @@ class StorageService extends BaseSharedPreferencesService { return _decodeStringSet(jsonString); } - Set getHiddenLibrariesForProfile(String profileId) { - final scopedKey = '${_userPrefixForProfileId(profileId)}$_keyHiddenLibraries'; - var jsonString = prefs.getString(scopedKey); - if (jsonString == null && getActiveProfileId() == profileId) { - // One-time migration from the legacy unscoped key, but only for the - // currently active profile. Otherwise merely opening another profile's - // scoped provider could steal legacy preferences into the wrong scope. - final legacy = prefs.getString(_keyHiddenLibraries); - if (legacy != null) { - prefs.setString(scopedKey, legacy); - prefs.remove(_keyHiddenLibraries); - jsonString = legacy; - } - } - return _decodeStringSet(jsonString); - } + Set getHiddenLibrariesForProfile(String profileId) => _decodeStringSet( + _readScopedWithLegacyMigration( + _keyHiddenLibraries, + prefix: _userPrefixForProfileId(profileId), + read: prefs.getString, + write: prefs.setString, + // Only the active profile may adopt the legacy unscoped value. Otherwise + // merely opening another profile's scoped provider could steal legacy + // preferences into the wrong scope. + allowLegacyAdoption: getActiveProfileId() == profileId, + ), + ); Set _decodeStringSet(String? jsonString) { if (jsonString == null) return {}; @@ -366,19 +373,12 @@ class StorageService extends BaseSharedPreferencesService { await _setStringList('$_userPrefix$_keyLibraryOrder', libraryKeys); } - List? getLibraryOrder() { - final baseKey = _keyLibraryOrder; - final scopedKey = '$_userPrefix$baseKey'; - final value = _getStringList(scopedKey); - if (value != null || _userPrefix.isEmpty) return value; - // One-time migration from legacy key - final legacy = _getStringList(baseKey); - if (legacy != null) { - _setStringList(scopedKey, legacy); - prefs.remove(baseKey); - } - return legacy; - } + List? getLibraryOrder() => _readScopedWithLegacyMigration>( + _keyLibraryOrder, + prefix: _userPrefix, + read: _getStringList, + write: _setStringList, + ); // Current User UUID — read once by [ConnectionBootstrap._promoteActiveProfileFromLegacy] // on the upgrade run, then cleared. Replaced by @@ -546,13 +546,18 @@ class StorageService extends BaseSharedPreferencesService { } } - Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) async { + /// Run [op] over every slot holding [baseKey]: the legacy unscoped key plus + /// each `user_{scope}_{baseKey}` variant. + Future _forEachScopedKey(String baseKey, Future Function(String key) op) async { final keys = prefs.keys .where((key) => key == baseKey || (key.startsWith('user_') && key.endsWith('_$baseKey'))) .toList(growable: false); - await Future.wait(keys.map((key) => _filterServerEntriesFromStringList(key, serverId))); + await Future.wait(keys.map(op)); } + Future _filterServerEntriesFromAllStringListKeys(String baseKey, ServerId serverId) => + _forEachScopedKey(baseKey, (key) => _filterServerEntriesFromStringList(key, serverId)); + Future _clearSelectedLibraryForServer(String key, ServerId serverId) async { final selected = prefs.getString(key); if (selected != null && _belongsToServer(selected, serverId)) { @@ -560,15 +565,8 @@ class StorageService extends BaseSharedPreferencesService { } } - Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) async { - final keys = prefs.keys - .where( - (key) => - key == _keySelectedLibraryKey || (key.startsWith('user_') && key.endsWith('_$_keySelectedLibraryKey')), - ) - .toList(growable: false); - await Future.wait(keys.map((key) => _clearSelectedLibraryForServer(key, serverId))); - } + Future _clearServerSelectedLibraryKeysEverywhere(ServerId serverId) => + _forEachScopedKey(_keySelectedLibraryKey, (key) => _clearSelectedLibraryForServer(key, serverId)); Future _clearKeysWithPrefixForServer(String keyPrefix, ServerId serverId) async { final serverPrefix = '$serverId:'; diff --git a/lib/services/system_shelf_service.dart b/lib/services/system_shelf_service.dart index e21a7a6a..31c63ae1 100644 --- a/lib/services/system_shelf_service.dart +++ b/lib/services/system_shelf_service.dart @@ -110,19 +110,13 @@ class SystemShelfService { await _enqueueMutation(() async { final channel = _channel; if (channel == null) return; - try { - await channel.invokeMethod('clear', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - }); - } on MissingPluginException catch (e) { - appLogger.e('Failed to clear system shelf: native channel missing', error: e); - } on PlatformException catch (e) { - appLogger.e('Failed to clear system shelf: native platform error', error: e); - } catch (e) { - appLogger.e('Failed to clear system shelf', error: e); - } + await _invokeGuarded( + channel, + 'clear', + arguments: _envelope(profileId, generation), + label: 'Failed to clear system shelf', + severe: true, + ); }); } @@ -146,19 +140,45 @@ class SystemShelfService { return completer.future; } + /// Owner-scoped envelope every mutating native call carries. + static Map _envelope(String profileId, int generation, [Map? extra]) { + return {'schemaVersion': schemaVersion, 'ownerId': profileId, 'generation': generation, ...?extra}; + } + + /// Invokes [method] on [channel], logging channel failures under [label] (at + /// error level when [severe]) and returning null instead of throwing. + /// Errors that are not channel failures log [failureLabel] when given. + Future _invokeGuarded( + MethodChannel channel, + String method, { + Map? arguments, + required String label, + String? failureLabel, + bool severe = false, + }) async { + final log = severe ? appLogger.e : appLogger.w; + try { + return await channel.invokeMethod(method, arguments); + } on MissingPluginException catch (e) { + log('$label: native channel missing', error: e); + } on PlatformException catch (e) { + log('$label: native platform error', error: e); + } catch (e) { + log(failureLabel ?? label, error: e); + } + return null; + } + /// Get a pending deep link from cold start (consumed on first call). Future getInitialDeepLink() async { final channel = _channel; if (channel == null) return null; - try { - return await channel.invokeMethod('getInitialDeepLink'); - } on MissingPluginException catch (e) { - appLogger.w('System shelf initial deep link failed: native channel missing', error: e); - return null; - } catch (e) { - appLogger.w('Failed to get system shelf initial deep link', error: e); - return null; - } + return _invokeGuarded( + channel, + 'getInitialDeepLink', + label: 'System shelf initial deep link failed', + failureLabel: 'Failed to get system shelf initial deep link', + ); } /// Check whether the current platform has a launcher shelf integration. @@ -167,18 +187,13 @@ class SystemShelfService { if (override != null) return override(); final channel = _channel; if (channel == null) return false; - try { - return await channel.invokeMethod('isSupported') ?? false; - } on MissingPluginException catch (e) { - appLogger.w('System shelf unsupported: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.w('System shelf unsupported: native platform error', error: e); - return false; - } catch (e) { - appLogger.w('System shelf unsupported: native support check failed', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'isSupported', + label: 'System shelf unsupported', + failureLabel: 'System shelf unsupported: native support check failed', + ) ?? + false; } /// Sync Continue Watching items for the currently active [profileId]. @@ -204,24 +219,14 @@ class SystemShelfService { final result = await _enqueueMutation(() async { if (!_owns(profileId, generation)) return false; - try { - return await channel.invokeMethod('sync', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - 'items': items, - }) ?? - false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to sync system shelf: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to sync system shelf: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to sync system shelf', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'sync', + arguments: _envelope(profileId, generation, {'items': items}), + label: 'Failed to sync system shelf', + severe: true, + ) ?? + false; }); return result ?? false; } @@ -233,24 +238,14 @@ class SystemShelfService { final generation = _generation; final result = await _enqueueMutation(() async { if (!_owns(profileId, generation)) return false; - try { - return await channel.invokeMethod('remove', { - 'schemaVersion': schemaVersion, - 'ownerId': profileId, - 'generation': generation, - 'contentId': _buildContentId(serverId, ratingKey), - }) ?? - false; - } on MissingPluginException catch (e) { - appLogger.e('Failed to remove system shelf item: native channel missing', error: e); - return false; - } on PlatformException catch (e) { - appLogger.e('Failed to remove system shelf item: native platform error', error: e); - return false; - } catch (e) { - appLogger.e('Failed to remove system shelf item', error: e); - return false; - } + return await _invokeGuarded( + channel, + 'remove', + arguments: _envelope(profileId, generation, {'contentId': _buildContentId(serverId, ratingKey)}), + label: 'Failed to remove system shelf item', + severe: true, + ) ?? + false; }); return result ?? false; } diff --git a/lib/services/trackers/anilist/anilist_tracker.dart b/lib/services/trackers/anilist/anilist_tracker.dart index d916fd16..eb4a2b52 100644 --- a/lib/services/trackers/anilist/anilist_tracker.dart +++ b/lib/services/trackers/anilist/anilist_tracker.dart @@ -3,7 +3,6 @@ import 'package:http/http.dart' as http; import '../../../models/trackers/anime_ids.dart'; import '../../../models/trackers/tracker_context.dart'; import '../../../utils/app_logger.dart'; -import '../../settings_service.dart'; import '../anime_list_tracker_base.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -25,18 +24,9 @@ class AnilistTracker extends TrackerBase with ClientBackedTracker @override TrackerService get service => TrackerService.anilist; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableAnilistScrobble); - @override String get logLabel => 'AniList'; - @override - String get idLogName => 'anilist'; - - @override - String get ratingUnavailableName => 'AniList'; - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/anime_list_tracker_base.dart b/lib/services/trackers/anime_list_tracker_base.dart index 55a60d50..0cacb5e8 100644 --- a/lib/services/trackers/anime_list_tracker_base.dart +++ b/lib/services/trackers/anime_list_tracker_base.dart @@ -12,8 +12,6 @@ mixin AnimeListTrackerBase on TrackerBa bool get needsFribb => true; String get logLabel; - String get idLogName; - String get ratingUnavailableName; int? animeId(AnimeIds? anime); Future loadAnimeEpisodeCount(TClient client, int animeId); @@ -81,7 +79,7 @@ mixin AnimeListTrackerBase on TrackerBa (TClient, int) _ratingTarget(TrackerRatingContext ctx) { final activeClient = client; final id = animeId(ctx.ids.anime); - if (activeClient == null || id == null) throw TrackerRatingUnavailableException(ratingUnavailableName); + if (activeClient == null || id == null) throw TrackerRatingUnavailableException(logLabel); return (activeClient, id); } @@ -94,7 +92,7 @@ mixin AnimeListTrackerBase on TrackerBa if (identical(_episodeCountLoads[id], loading)) { final _ = _episodeCountLoads.remove(id); } - appLogger.d('$logLabel: failed to fetch anime episode count ($idLogName=$id)', error: e); + appLogger.d('$logLabel: failed to fetch anime episode count ($name=$id)', error: e); return null; }); _episodeCountLoads[id] = loading; diff --git a/lib/services/trackers/mal/mal_tracker.dart b/lib/services/trackers/mal/mal_tracker.dart index 5eb4b128..9dd35f9e 100644 --- a/lib/services/trackers/mal/mal_tracker.dart +++ b/lib/services/trackers/mal/mal_tracker.dart @@ -2,7 +2,6 @@ import 'package:http/http.dart' as http; import '../../../models/trackers/anime_ids.dart'; import '../../../utils/app_logger.dart'; -import '../../settings_service.dart'; import '../anime_list_tracker_base.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; @@ -28,18 +27,9 @@ class MalTracker extends TrackerBase with ClientBackedTracker, AnimeL @override TrackerService get service => TrackerService.mal; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableMalScrobble); - @override String get logLabel => 'MAL'; - @override - String get idLogName => 'mal'; - - @override - String get ratingUnavailableName => 'MAL'; - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/simkl/simkl_tracker.dart b/lib/services/trackers/simkl/simkl_tracker.dart index deec235f..44f13b28 100644 --- a/lib/services/trackers/simkl/simkl_tracker.dart +++ b/lib/services/trackers/simkl/simkl_tracker.dart @@ -5,7 +5,6 @@ import '../../../models/trackers/tracker_context.dart'; import '../../../utils/app_logger.dart'; import '../../../utils/external_ids.dart'; import '../../../utils/json_utils.dart'; -import '../../settings_service.dart'; import '../tracker.dart'; import '../tracker_constants.dart'; import '../tracker_id_resolver.dart'; @@ -34,9 +33,6 @@ class SimklTracker extends TrackerBase with ClientBackedTracker imp @override bool get needsFribb => false; - @override - bool readEnabledSetting(SettingsService settings) => settings.read(SettingsService.enableSimklScrobble); - void rebindSession( TrackerSession? session, { required void Function() onSessionInvalidated, diff --git a/lib/services/trackers/tracker.dart b/lib/services/trackers/tracker.dart index 4e4665a7..44416b92 100644 --- a/lib/services/trackers/tracker.dart +++ b/lib/services/trackers/tracker.dart @@ -54,16 +54,14 @@ class TrackerRatingUnavailableException implements Exception { String toString() => 'TrackerRatingUnavailableException($trackerName)'; } -/// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient], -/// [readEnabledSetting], and [markWatched]. +/// Shared enabled-state bookkeeping. Subclasses override [hasActiveClient] +/// and [markWatched]. abstract class TrackerBase implements Tracker { bool _isInitialized = false; bool _isEnabled = false; bool get hasActiveClient; - bool readEnabledSetting(SettingsService settings); - @override bool get canScrobble => _isEnabled && hasActiveClient; @@ -71,7 +69,8 @@ abstract class TrackerBase implements Tracker { Future initialize() async { if (_isInitialized) return; _isInitialized = true; - _isEnabled = readEnabledSetting(await SettingsService.getInstance()); + final settings = await SettingsService.getInstance(); + _isEnabled = settings.read(SettingsService.scrobblePref(service)); } @override diff --git a/lib/services/trackers/tracker_coordinator.dart b/lib/services/trackers/tracker_coordinator.dart index a3ba1ef0..d7530ac0 100644 --- a/lib/services/trackers/tracker_coordinator.dart +++ b/lib/services/trackers/tracker_coordinator.dart @@ -118,23 +118,19 @@ class TrackerCoordinator { animeProgress: _debugAnimeProgress, ); - Future markWatched(MediaItem item, MediaServerClient client) async { + Future markWatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: true); + + Future markUnwatched(MediaItem item, MediaServerClient client) => _markManual(item, client, watched: false); + + Future _markManual(MediaItem item, MediaServerClient client, {required bool watched}) async { try { - await _markWatched(item, client); + await _applyManualMark(item, client, watched: watched); } catch (e) { - appLogger.d('Trackers: manual markWatched failed for ${item.id}', error: e); + appLogger.d('Trackers: manual ${watched ? 'markWatched' : 'markUnwatched'} failed for ${item.id}', error: e); } } - Future markUnwatched(MediaItem item, MediaServerClient client) async { - try { - await _markUnwatched(item, client); - } catch (e) { - appLogger.d('Trackers: manual markUnwatched failed for ${item.id}', error: e); - } - } - - Future _markWatched(MediaItem item, MediaServerClient client) async { + Future _applyManualMark(MediaItem item, MediaServerClient client, {required bool watched}) async { final kind = item.kind; if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { return; @@ -146,38 +142,18 @@ class TrackerCoordinator { final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); if (kind == MediaKind.movie || kind == MediaKind.episode) { - await _markSingleWatched(item, resolver); + await (watched ? _markSingleWatched(item, resolver) : _markSingleUnwatched(item, resolver)); return; } final episodes = []; await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - appLogger.d('Trackers: manual ${kind.name} ${item.id} expanded to ${episodes.length} episodes'); + final expansion = watched ? 'expanded' : 'unwatched expanded'; + appLogger.d('Trackers: manual ${kind.name} ${item.id} $expansion to ${episodes.length} episodes'); - await _markContainerEpisodesWatched(episodes, resolver); - } - - Future _markUnwatched(MediaItem item, MediaServerClient client) async { - final kind = item.kind; - if (kind != MediaKind.movie && kind != MediaKind.episode && kind != MediaKind.season && kind != MediaKind.show) { - return; - } - - final libraryGlobalKey = item.libraryGlobalKey; - if (!_hasActiveTrackerForLibrary(libraryGlobalKey)) return; - - final resolver = _newResolver(client, needsFribb: () => _anyTrackerNeedsFribbForLibrary(libraryGlobalKey)); - - if (kind == MediaKind.movie || kind == MediaKind.episode) { - await _markSingleUnwatched(item, resolver); - return; - } - - final episodes = []; - await collectEpisodes(client, item.id, unwatchedOnly: false, out: episodes, fallback: item); - appLogger.d('Trackers: manual ${kind.name} ${item.id} unwatched expanded to ${episodes.length} episodes'); - - await _markContainerEpisodesUnwatched(episodes, resolver); + await (watched + ? _markContainerEpisodesWatched(episodes, resolver) + : _markContainerEpisodesUnwatched(episodes, resolver)); } Future _markContainerEpisodesWatched(List episodes, TrackerIdResolver resolver) async { @@ -189,7 +165,7 @@ class TrackerCoordinator { if (ctx == null) continue; resolved++; - await _dispatchToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: true); final key = _animeGroupKey(ctx); if (key == null) continue; @@ -200,7 +176,7 @@ class TrackerCoordinator { for (final group in animeGroups.values) { final ctx = group.context; - if (ctx != null) await _dispatchToTrackers([MalTracker.instance, AnilistTracker.instance], ctx); + if (ctx != null) await _dispatch([MalTracker.instance, AnilistTracker.instance], ctx, watched: true); } appLogger.d('Trackers: manual container resolved ${animeGroups.length} anime entries'); } @@ -220,7 +196,7 @@ class TrackerCoordinator { if (ctx == null) continue; resolved++; - await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: false); final anime = ctx.anime; if (anime == null) continue; @@ -279,7 +255,7 @@ class TrackerCoordinator { appLogger.d('Trackers: no external IDs for manually watched ${item.id}'); return; } - await _dispatchMarkWatched(ctx); + await _dispatch(_trackers, ctx, watched: true); } Future _markSingleUnwatched(MediaItem item, TrackerIdResolver resolver) async { @@ -289,9 +265,9 @@ class TrackerCoordinator { return; } if (ctx.isMovie) { - await _dispatchMarkUnwatched(ctx); + await _dispatch(_trackers, ctx, watched: false); } else { - await _dispatchUnwatchedToTrackers([SimklTracker.instance], ctx); + await _dispatch([SimklTracker.instance], ctx, watched: false); } } @@ -301,7 +277,7 @@ class TrackerCoordinator { final shouldMarkWatched = ctx != null && !_thresholdCrossed && _timeline.watchedThresholdReached; _reset(); if (ctx != null && shouldMarkWatched) { - await _dispatchMarkWatched(ctx); + await _dispatch(_trackers, ctx, watched: true); } } @@ -311,7 +287,7 @@ class TrackerCoordinator { if (ctx == null || _thresholdCrossed) return; if (!_timeline.watchedThresholdReached) return; _thresholdCrossed = true; - unawaited(_dispatchMarkWatched(ctx)); + unawaited(_dispatch(_trackers, ctx, watched: true)); } void updateDuration(Duration duration) { @@ -340,40 +316,17 @@ class TrackerCoordinator { _thresholdCrossed = false; } - Future _dispatchMarkWatched(TrackerContext ctx) async { - final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); - await _dispatchToTrackers(active, ctx); - } - - Future _dispatchMarkUnwatched(TrackerContext ctx) async { - final active = _trackers.where((t) => t.canScrobble && t.shouldScrobbleForLibrary(ctx.libraryGlobalKey)); - await _dispatchUnwatchedToTrackers(active, ctx); - } - bool _isActive(Tracker tracker, String? libraryGlobalKey) => tracker.canScrobble && tracker.shouldScrobbleForLibrary(libraryGlobalKey); - Future _dispatchToTrackers(Iterable trackers, TrackerContext ctx) async { + Future _dispatch(Iterable trackers, TrackerContext ctx, {required bool watched}) async { final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); await Future.wait( active.map((t) async { try { - await t.markWatched(ctx); + await (watched ? t.markWatched(ctx) : t.markUnwatched(ctx)); } catch (e) { - appLogger.d('${t.name}: markWatched failed', error: e); - } - }), - ); - } - - Future _dispatchUnwatchedToTrackers(Iterable trackers, TrackerContext ctx) async { - final active = trackers.where((t) => _isActive(t, ctx.libraryGlobalKey)); - await Future.wait( - active.map((t) async { - try { - await t.markUnwatched(ctx); - } catch (e) { - appLogger.d('${t.name}: markUnwatched failed', error: e); + appLogger.d('${t.name}: ${watched ? 'markWatched' : 'markUnwatched'} failed', error: e); } }), ); diff --git a/lib/services/trackers/tracker_session.dart b/lib/services/trackers/tracker_session.dart index 45c42114..e105e59e 100644 --- a/lib/services/trackers/tracker_session.dart +++ b/lib/services/trackers/tracker_session.dart @@ -3,7 +3,7 @@ import 'tracker_constants.dart'; import 'tracker_exceptions.dart'; import 'tracker_session_utils.dart'; -class TrackerSession with EncodedTrackerSession { +class TrackerSession { final String accessToken; final String? refreshToken; final int? expiresAt; @@ -43,7 +43,6 @@ class TrackerSession with EncodedTrackerSession { ); } - @override Map toJson() => { 'access_token': accessToken, 'refresh_token': refreshToken, @@ -53,6 +52,8 @@ class TrackerSession with EncodedTrackerSession { 'created_at': createdAt, }; + String encode() => encodeTrackerSessionJson(toJson()); + factory TrackerSession.fromJson(Map json, {TrackerService? service}) { final session = TrackerSession( accessToken: json['access_token'] as String, diff --git a/lib/services/trackers/tracker_session_utils.dart b/lib/services/trackers/tracker_session_utils.dart index 15ec06fc..b99ee847 100644 --- a/lib/services/trackers/tracker_session_utils.dart +++ b/lib/services/trackers/tracker_session_utils.dart @@ -9,12 +9,6 @@ bool isTrackerTokenExpired(int expiresAt, {int? nowSeconds}) => bool trackerTokenNeedsRefresh(int expiresAt, {int refreshWindowSeconds = 300, int? nowSeconds}) => (nowSeconds ?? trackerSessionNowEpochSeconds()) >= expiresAt - refreshWindowSeconds; -mixin EncodedTrackerSession { - Map toJson(); - - String encode() => encodeTrackerSessionJson(toJson()); -} - String encodeTrackerSessionJson(Map value) => convert.json.encode(value); T decodeTrackerSessionJson(String raw, T Function(Map json) fromJson) { diff --git a/lib/services/trakt/trakt_client.dart b/lib/services/trakt/trakt_client.dart index 4facd12f..e59040b4 100644 --- a/lib/services/trakt/trakt_client.dart +++ b/lib/services/trakt/trakt_client.dart @@ -70,10 +70,10 @@ class TraktClient implements DisposableTrackerClient { _request('POST', '/scrobble/stop', body: body.toJson(), allowStatuses: _scrobbleAllowedStatuses); Future addToHistory(TraktScrobbleRequest item, {String? watchedAt}) => - _request('POST', '/sync/history', body: item.toHistoryAddBody(watchedAt: watchedAt)); + _request('POST', '/sync/history', body: item.toHistoryBody(watchedAt: watchedAt)); Future removeFromHistory(TraktScrobbleRequest item) => - _request('POST', '/sync/history/remove', body: item.toHistoryRemoveBody()); + _request('POST', '/sync/history/remove', body: item.toHistoryBody()); Future addRatings(Map body) => _request('POST', '/sync/ratings', body: body, allowStatuses: const {200, 201}); diff --git a/lib/services/trakt/trakt_scrobble_service.dart b/lib/services/trakt/trakt_scrobble_service.dart index 97fdf498..e407e8a0 100644 --- a/lib/services/trakt/trakt_scrobble_service.dart +++ b/lib/services/trakt/trakt_scrobble_service.dart @@ -59,7 +59,7 @@ class TraktScrobbleService implements TrackerRatingSource { if (_isInitialized) return; _isInitialized = true; final settings = await SettingsService.getInstance(); - _isEnabled = settings.read(SettingsService.enableTraktScrobble); + _isEnabled = settings.read(SettingsService.scrobblePref(TrackerService.trakt)); } Future setEnabled(bool enabled) async { diff --git a/lib/services/video_pip_manager.dart b/lib/services/video_pip_manager.dart index b9207559..7d0a554b 100644 --- a/lib/services/video_pip_manager.dart +++ b/lib/services/video_pip_manager.dart @@ -7,22 +7,15 @@ import '../utils/app_logger.dart'; class VideoPIPManager { final Player player; - Size? _playerSize; - VideoPIPManager({required this.player, Size? initialPlayerSize}) : _playerSize = initialPlayerSize; + /// Current viewport size, used as the PiP aspect ratio fallback. + final Size? Function() playerSize; - Size? get playerSize => _playerSize; + VideoPIPManager({required this.player, required this.playerSize}); /// Callback to prepare video filter before entering PiP VoidCallback? onBeforeEnterPip; - /// Update player size for PiP aspect ratio calculation - void updatePlayerSize(Size size) { - _playerSize = size; - } - - ValueNotifier get isPipActive => PipService().isPipActive; - /// Get current video dimensions (display or storage or fallback to viewport) Future<(int? width, int? height)> _getVideoDimensions() async { int? width; @@ -52,8 +45,9 @@ class VideoPIPManager { } } - width ??= _playerSize?.width.toInt(); - height ??= _playerSize?.height.toInt(); + final viewport = playerSize(); + width ??= viewport?.width.toInt(); + height ??= viewport?.height.toInt(); return (width, height); } @@ -63,7 +57,7 @@ class VideoPIPManager { if (!supported) return (false, 'PiP not supported on this device'); // If PiP is already active, exit it - if (isPipActive.value) { + if (PipService().isPipActive.value) { await PipService.exit(); return (true, null); } diff --git a/lib/services/watch_state_resolver.dart b/lib/services/watch_state_resolver.dart index f2949d92..819d6ed4 100644 --- a/lib/services/watch_state_resolver.dart +++ b/lib/services/watch_state_resolver.dart @@ -1,7 +1,10 @@ +import 'package:flutter/foundation.dart'; + import '../database/app_database.dart'; import '../media/media_item.dart'; import '../utils/watch_state_notifier.dart'; +@immutable class WatchStateSnapshot { final bool? isWatched; final bool hasViewOffsetMs; @@ -21,6 +24,17 @@ class WatchStateSnapshot { } return updated; } + + @override + bool operator ==(Object other) => + identical(this, other) || + other is WatchStateSnapshot && + other.isWatched == isWatched && + other.hasViewOffsetMs == hasViewOffsetMs && + other.viewOffsetMs == viewOffsetMs; + + @override + int get hashCode => Object.hash(isWatched, hasViewOffsetMs, viewOffsetMs); } class WatchStateResolver { diff --git a/lib/theme/mono_tokens.dart b/lib/theme/mono_tokens.dart index 8b8d7422..481c3165 100644 --- a/lib/theme/mono_tokens.dart +++ b/lib/theme/mono_tokens.dart @@ -3,6 +3,17 @@ import 'package:flutter/material.dart'; MonoTokens tokens(BuildContext context) => Theme.of(context).extension()!; +/// M3E connected-group geometry for item [index] of a [count]-item group: +/// large radii on the group's outer corners, small radii between adjacent +/// items. Pair with `MonoTokens.groupGap` spacing for the hairline gaps. +BorderRadius groupItemRadii(BuildContext context, int index, int count) { + final t = tokens(context); + return BorderRadius.vertical( + top: Radius.circular(index == 0 ? t.radiusLg : t.radiusXs), + bottom: Radius.circular(index == count - 1 ? t.radiusLg : t.radiusXs), + ); +} + @immutable class MonoTokens extends ThemeExtension { /// Effectively-stadium radius for pill shapes; the renderer proportionally diff --git a/lib/utils/android_exit_diagnostics.dart b/lib/utils/android_exit_diagnostics.dart index bff08dd8..c52f19e7 100644 --- a/lib/utils/android_exit_diagnostics.dart +++ b/lib/utils/android_exit_diagnostics.dart @@ -1,10 +1,10 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter/services.dart'; import 'package:sentry_flutter/sentry_flutter.dart'; import 'app_logger.dart'; +import 'device_channel.dart'; enum AndroidStartupPhase { nativeOnCreate('native_on_create'), @@ -34,7 +34,6 @@ enum AndroidUiState { /// Best-effort bridge for the newest Android 11+ historical process exit. abstract final class AndroidExitDiagnostics { - static const _channel = MethodChannel('com.plezy/device'); static const _allowedReasons = {'crash', 'native_crash', 'anr', 'low_memory', 'user_requested', 'other'}; static const _allowedAbis = {'arm64-v8a', 'armeabi-v7a', 'x86_64', 'x86', 'unknown'}; static const _allowedCodecContexts = { @@ -132,7 +131,7 @@ abstract final class AndroidExitDiagnostics { static Future _persistStartupPhase(String phase) async { try { - await _channel.invokeMethod('setStartupPhase', phase); + await deviceChannel.invokeMethod('setStartupPhase', phase); } catch (_) { // Native phase persistence is best-effort. } @@ -141,7 +140,7 @@ abstract final class AndroidExitDiagnostics { static Future markUiState(AndroidUiState state) async { if (!Platform.isAndroid) return; try { - await _channel.invokeMethod('setRuntimeUiState', state.id); + await deviceChannel.invokeMethod('setRuntimeUiState', state.id); } catch (_) { // Runtime diagnostics are best-effort and must never affect navigation. } @@ -155,7 +154,7 @@ abstract final class AndroidExitDiagnostics { static Future logPreviousExit() async { if (!Platform.isAndroid) return; try { - final raw = await _channel.invokeMapMethod('getPreviousExit'); + final raw = await deviceChannel.invokeMapMethod('getPreviousExit'); final report = _validate(raw); if (report == null) return; diff --git a/lib/utils/async_singleton.dart b/lib/utils/async_singleton.dart new file mode 100644 index 00000000..d3a68401 --- /dev/null +++ b/lib/utils/async_singleton.dart @@ -0,0 +1,62 @@ +/// Memoizes a `static Future getInstance()` singleton whose construction is +/// cheap but whose initialization is async. +/// +/// The instance is published *before* initialization runs, so sync accessors +/// (`isTVSync`, `isReduced`, ...) see it immediately. Concurrent callers await +/// the one in-flight initialization, and a failed initialization rolls the +/// instance back so the next call retries — the `identical` guards keep that +/// rollback safe once a later call has replaced the memoized state. +/// +/// The `debug*` members are test hooks; owners re-expose them behind their own +/// `@visibleForTesting` forwarders. +class AsyncSingleton { + T? _instance; + Future? _initialization; + + /// Awaited before each initialization run, to hold initialization open while + /// a test exercises concurrent callers. + Future? debugGate; + + /// The memoized instance, which may still be initializing. Null before the + /// first [getInstance] call and after a failed initialization. + T? get instance => _instance; + + /// Returns the memoized instance, building it with [create] and running + /// [initialize] on it the first time. + Future getInstance(T Function() create, Future Function(T instance) initialize) async { + final existing = _instance; + if (existing != null) { + final inFlight = _initialization; + if (inFlight != null) await inFlight; + return existing; + } + + final instance = create(); + _instance = instance; + final initialization = _initialize(instance, initialize); + _initialization = initialization; + try { + await initialization; + } catch (_) { + if (identical(_instance, instance)) _instance = null; + rethrow; + } finally { + if (identical(_initialization, initialization)) _initialization = null; + } + return instance; + } + + Future _initialize(T instance, Future Function(T instance) initialize) async { + final gate = debugGate; + if (gate != null) await gate; + await initialize(instance); + } + + /// Drops the memoized state and the gate, optionally seeding [instance] so + /// sync accessors can be exercised without initializing. + void debugReset({T? instance}) { + _instance = instance; + _initialization = null; + debugGate = null; + } +} diff --git a/lib/utils/device_channel.dart b/lib/utils/device_channel.dart new file mode 100644 index 00000000..056884fa --- /dev/null +++ b/lib/utils/device_channel.dart @@ -0,0 +1,5 @@ +import 'package:flutter/services.dart'; + +/// Native device bridge (TV detection, device name, performance signals, +/// process-exit diagnostics). Implemented per platform under `com.plezy/device`. +const MethodChannel deviceChannel = MethodChannel('com.plezy/device'); diff --git a/lib/utils/download_utils.dart b/lib/utils/download_utils.dart index 0e0c3fd3..757c78fc 100644 --- a/lib/utils/download_utils.dart +++ b/lib/utils/download_utils.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import '../media/ids.dart'; import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; +import 'package:provider/provider.dart'; +import '../focus/focusable_action_bar.dart'; import '../i18n/strings.g.dart'; import '../media/media_item.dart'; import '../media/media_kind.dart'; @@ -13,6 +15,7 @@ import '../services/sync_rule_executor.dart'; import 'content_utils.dart'; import 'dialogs.dart'; import 'download_version_utils.dart'; +import 'platform_detector.dart'; import 'snackbar_helper.dart'; @visibleForTesting @@ -461,3 +464,44 @@ Future removeSyncRuleAndSnack( showSuccessSnackBar(context, t.downloads.syncRuleRemoved); } } + +/// The download / manage-sync-rule app-bar pair shared by the collection and +/// playlist detail screens: one entry that downloads (or edits the existing +/// rule) and, when a rule exists, one that removes it. Both are hidden on +/// Apple TV, which has no downloads UI. +/// +/// [hasRule] stays caller-computed so each screen keeps its own +/// `context.select` short-circuit, and [showDownload] carries the screen's +/// own visibility predicate for the first entry. +List buildSyncRuleActions( + BuildContext context, { + required String ruleKey, + required String displayTitle, + required bool hasRule, + required bool showDownload, + required VoidCallback onDownload, +}) { + if (PlatformDetector.isAppleTV()) return const []; + return [ + if (showDownload) + FocusableAction( + icon: hasRule ? Symbols.sync_rounded : Symbols.download_rounded, + tooltip: hasRule ? t.downloads.manageSyncRule : t.downloads.downloadNow, + onPressed: hasRule + ? () => manageSyncRule(context, downloadProvider: context.read(), globalKey: ruleKey) + : onDownload, + iconColor: hasRule ? Colors.teal : null, + ), + if (hasRule) + FocusableAction( + icon: Symbols.sync_disabled_rounded, + tooltip: t.downloads.removeSyncRule, + onPressed: () => removeSyncRuleAndSnack( + context, + downloadProvider: context.read(), + globalKey: ruleKey, + displayTitle: displayTitle, + ), + ), + ]; +} diff --git a/lib/utils/hub_icons.dart b/lib/utils/hub_icons.dart new file mode 100644 index 00000000..b79b1d40 --- /dev/null +++ b/lib/utils/hub_icons.dart @@ -0,0 +1,66 @@ +import 'package:flutter/widgets.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../media/media_hub.dart'; + +/// Leading icon for a hub row, shared by every surface that renders hubs from +/// the same backend rows (Discover and a library's Recommended tab). +/// +/// Continue Watching is matched on the hub key first so synthesized rows and +/// section-specific `*.inprogress.*` hubs are covered, then on title for +/// backends whose resume row is only recognizable by name (Plex "On Deck"). +/// Everything else is keyword-matched on the title; the first match wins, so +/// the more specific keywords are checked before the broader ones. +IconData hubIconFor(MediaHub hub) { + final title = hub.title.toLowerCase(); + + if (hub.isContinueWatchingHub || title.contains('continue watching') || title.contains('on deck')) { + return Symbols.play_circle_rounded; + } + for (final (keywords, icon) in _titleKeywordIcons) { + if (keywords.any(title.contains)) return icon; + } + return _defaultHubIcon; +} + +const _defaultHubIcon = Symbols.auto_awesome_rounded; + +/// Title keywords in match order — see [hubIconFor]. +const _titleKeywordIcons = <(List, IconData)>[ + // Trending/Popular + (['trending'], Symbols.trending_up_rounded), + (['popular', 'imdb'], Symbols.whatshot_rounded), + // Seasonal/Time-based + (['seasonal'], Symbols.calendar_month_rounded), + (['newly', 'new release'], Symbols.new_releases_rounded), + (['recently released', 'recent'], Symbols.schedule_rounded), + // Top/Rated + (['top rated', 'highest rated'], Symbols.star_rounded), + (['top '], Symbols.military_tech_rounded), + // Genre-specific + (['thriller'], Symbols.warning_amber_rounded), + (['comedy', 'comedier'], Symbols.mood_rounded), + (['action'], Symbols.flash_on_rounded), + (['drama'], Symbols.theater_comedy_rounded), + (['fantasy'], Symbols.auto_fix_high_rounded), + (['science', 'sci-fi'], Symbols.rocket_launch_rounded), + (['horror', 'skräck'], Symbols.nights_stay_rounded), + (['romance', 'romantic'], Symbols.favorite_border_rounded), + (['adventure', 'äventyr'], Symbols.explore_rounded), + // Watchlist/Playlists + (['playlist', 'watchlist'], Symbols.playlist_play_rounded), + (['unwatched', 'unplayed'], Symbols.visibility_off_rounded), + (['watched', 'played'], Symbols.visibility_rounded), + // Network/Studio + (['network', 'more from'], Symbols.tv_rounded), + // Actor/Director + (['actor', 'director'], Symbols.person_rounded), + // Decades (80s, 90s, etc.) + (['80', '90', '00'], Symbols.history_rounded), + // Rediscover/Start Watching + (['rediscover', 'start watching'], Symbols.play_arrow_rounded), + // Broad library-hub keywords, last so the specific rows above keep their icons. + (['rated'], Symbols.star_rounded), + (['recommended'], Symbols.thumb_up_rounded), + (['genre'], Symbols.category_rounded), +]; diff --git a/lib/utils/media_event_keys.dart b/lib/utils/media_event_keys.dart new file mode 100644 index 00000000..2d9f2069 --- /dev/null +++ b/lib/utils/media_event_keys.dart @@ -0,0 +1,36 @@ +import '../media/ids.dart'; +import '../media/media_item.dart'; +import 'global_key_utils.dart'; + +/// Builds the id filter for a screen showing [items]. +/// +/// Each item contributes itself plus its parent and grandparent, because an +/// event on a season or show also changes how its episodes render. +Set hierarchicalEventIds(Iterable items) { + final keys = {}; + for (final item in items) { + keys.add(item.id); + if (item.parentId != null) keys.add(item.parentId!); + if (item.grandparentId != null) keys.add(item.grandparentId!); + } + return keys; +} + +/// The [hierarchicalEventIds] filter expressed as `serverId:ratingKey` keys. +/// +/// Items without a server id fall back to [fallbackServerId]; if that is also +/// missing the whole filter collapses to `null`, which callers use to fall back +/// to id-only matching rather than silently under-matching. +Set? hierarchicalEventGlobalKeys(Iterable items, {String? fallbackServerId}) { + final keys = {}; + for (final item in items) { + final rawServerId = item.serverId ?? fallbackServerId; + if (rawServerId == null) return null; + + final serverId = ServerId(rawServerId); + keys.add(buildGlobalKey(serverId, item.id)); + if (item.parentId != null) keys.add(buildGlobalKey(serverId, item.parentId!)); + if (item.grandparentId != null) keys.add(buildGlobalKey(serverId, item.grandparentId!)); + } + return keys; +} diff --git a/lib/utils/media_server_http_client.dart b/lib/utils/media_server_http_client.dart index 52e61524..0162f011 100644 --- a/lib/utils/media_server_http_client.dart +++ b/lib/utils/media_server_http_client.dart @@ -10,6 +10,7 @@ import 'future_extensions.dart'; import 'isolate_helper.dart'; import 'log_redaction_manager.dart'; import 'managed_http_client.dart'; +import 'url_utils.dart'; import '../exceptions/media_server_exceptions.dart'; // Platform-specific imports are conditional @@ -412,39 +413,13 @@ class MediaServerHttpClient { /// Append query parameters to an already-parsed URI. Uri _appendQuery(Uri uri, Map? queryParameters) { if (queryParameters == null || queryParameters.isEmpty) return uri; - final query = MediaServerHttpClient.encodeQueryParameters(queryParameters); + final query = encodeQueryParameters(queryParameters); if (query.isEmpty) return uri; final existing = uri.query; final combined = existing.isEmpty ? query : '$existing&$query'; return uri.replace(query: combined); } - /// Encode query params with `%20` for spaces (not `+`). - /// Null values are omitted and iterable values are emitted as repeated keys. - static String encodeQueryParameters(Map? params) { - if (params == null || params.isEmpty) return ''; - final parts = []; - - void add(String key, Object? value) { - if (value == null) return; - if (value is Iterable) { - for (final item in value) { - add(key, item); - } - return; - } - parts.add( - '${Uri.encodeComponent(key)}=' - '${Uri.encodeComponent(value.toString())}', - ); - } - - for (final entry in params.entries) { - add(entry.key, entry.value); - } - return parts.join('&'); - } - static bool _isAbsoluteUrl(String url) => url.startsWith('http://') || url.startsWith('https://'); /// Set the request body, choosing encoding based on the body type. @@ -461,14 +436,11 @@ class MediaServerHttpClient { return; } + // Content type comes from the caller's headers (Jellyfin/Plex put + // `application/json` in their defaults); `request.body` falls back to + // text/plain. Don't add one here — `request.headers` is case-insensitive, + // and the setter above has already filled the key in either way. request.body = jsonEncode(body); - // http.BaseRequest's headers map is case-sensitive; Jellyfin returns 415 - // if both `Content-Type` (from defaults) and `content-type` (added below) - // end up coexisting, so check both casings before adding. - final hasContentType = request.headers.keys.any((k) => k.toLowerCase() == 'content-type'); - if (!hasContentType) { - request.headers['content-type'] = 'application/json'; - } } /// Decode the response body: lenient UTF-8, then JSON parse if applicable. diff --git a/lib/utils/music_navigation.dart b/lib/utils/music_navigation.dart index a5904911..25bb53a9 100644 --- a/lib/utils/music_navigation.dart +++ b/lib/utils/music_navigation.dart @@ -118,9 +118,54 @@ Future playTracks( if (context.mounted) _autoOpenNowPlayingOnTv(context); } +/// Fetch a track list with [fetch], then play it — the shape every music +/// entry point that needs a server round-trip before playback repeats: +/// availability gate → [MusicPlaybackService.beginPlayIntent] → fetch → +/// mounted/intent re-check → [playTracks]. Guarding the round-trip with the +/// intent keeps a slow fetch from replacing a queue the user started later. +/// +/// [onError] reports a failed fetch and runs only while the intent is still +/// current and [context] mounted; passing null instead lets the failure +/// propagate to the caller's own error boundary. [onEmpty] handles a +/// successful but empty fetch; passing null hands the empty list to +/// [playTracks] unchanged. +Future playFetchedTracks( + BuildContext context, { + required Future> Function() fetch, + required MusicPlayContext playContext, + void Function(Object error, StackTrace stackTrace)? onError, + VoidCallback? onEmpty, + MediaItem? startTrack, + bool shuffle = false, +}) async { + if (!ensureMusicPlaybackAvailable(context)) return; + final service = context.read(); + final intent = service.beginPlayIntent(); + final List tracks; + try { + tracks = await fetch(); + } catch (error, stackTrace) { + if (!service.isPlayIntentCurrent(intent)) return; + if (onError == null) rethrow; + if (!context.mounted) return; + onError(error, stackTrace); + return; + } + if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; + if (tracks.isEmpty && onEmpty != null) { + onEmpty(); + return; + } + await playTracks(context, tracks: tracks, startTrack: startTrack, playContext: playContext, shuffle: shuffle); +} + /// Play [track] within its album queue: fetch the album's tracks and start /// at [track]. Falls back to single-track playback when the track has no /// album, isn't found in it, or the album fetch fails. +/// +/// Hand-written rather than routed through [playFetchedTracks]: the fallback +/// must play under the *same* intent as the album fetch, so a stale fallback +/// can never supersede a newer request. Future playTrackWithAlbumContext(BuildContext context, MediaItem track) async { if (!ensureMusicPlaybackAvailable(context)) return; final service = context.read(); diff --git a/lib/utils/platform_detector.dart b/lib/utils/platform_detector.dart index 17320db9..ca05ca19 100644 --- a/lib/utils/platform_detector.dart +++ b/lib/utils/platform_detector.dart @@ -5,6 +5,9 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'async_singleton.dart'; +import 'device_channel.dart'; + const _androidFeatureTelevision = 'android.hardware.type.television'; const _androidFeatureLeanback = 'android.software.leanback'; const _androidFeatureFireTv = 'amazon.hardware.fire_tv'; @@ -30,10 +33,9 @@ AndroidTvFeatureDetection detectAndroidTvFromSystemFeatures(Iterable fea /// Service for detecting if the app is running on Android TV or Apple TV. class TvDetectionService { - static TvDetectionService? _instance; - static Future? _initialization; + static final AsyncSingleton _singleton = AsyncSingleton(); @visibleForTesting - static Future? debugDetectionGate; + static set debugDetectionGate(Future? value) => _singleton.debugGate = value; static bool? _debugAppleTVOverride; bool _detected = false; bool _forceTv = false; @@ -46,35 +48,12 @@ class TvDetectionService { /// Get the singleton instance, initializing if needed. /// Pass [forceTv] to combine a user override with the system-feature check. - static Future getInstance({bool forceTv = false}) async { - final existing = _instance; - if (existing != null) { - final initialization = _initialization; - if (initialization != null) await initialization; - return existing; - } - - final instance = TvDetectionService._(); - _instance = instance; - final initialization = instance._detect(forceTv); - _initialization = initialization; - try { - await initialization; - } catch (_) { - if (identical(_instance, instance)) _instance = null; - rethrow; - } finally { - if (identical(_initialization, initialization)) _initialization = null; - } - return instance; - } + static Future getInstance({bool forceTv = false}) => + _singleton.getInstance(TvDetectionService._, (instance) => instance._detect(forceTv)); static const bool _tvosBuild = bool.fromEnvironment('TVOS_BUILD'); - static const MethodChannel _deviceChannel = MethodChannel('com.plezy/device'); Future _detect(bool forceTv) async { - final gate = debugDetectionGate; - if (gate != null) await gate; if (_initialized) return; final deviceInfo = DeviceInfoPlugin(); @@ -120,7 +99,7 @@ class TvDetectionService { Future _getNativeAndroidTvDetection() async { try { - final result = await _deviceChannel.invokeMapMethod('getTvDetection'); + final result = await deviceChannel.invokeMapMethod('getTvDetection'); if (result == null) return null; final reasonsValue = result['reasons']; final reasons = reasonsValue is Iterable ? reasonsValue.whereType().toList() : []; @@ -139,7 +118,7 @@ class TvDetectionService { static Future getAndroidDeviceName() async { if (!Platform.isAndroid) return null; try { - final name = (await _deviceChannel.invokeMethod('getDeviceName'))?.trim(); + final name = (await deviceChannel.invokeMethod('getDeviceName'))?.trim(); return (name == null || name.isEmpty) ? null : name; } on MissingPluginException { return null; @@ -155,10 +134,10 @@ class TvDetectionService { } /// Synchronous access after initialization (returns false if not initialized) - static bool isTVSync() => _debugAppleTVOverride ?? _instance?._isTV ?? false; + static bool isTVSync() => _debugAppleTVOverride ?? _singleton.instance?._isTV ?? false; /// Synchronous Apple TV check (returns false if not initialized or not tvOS). - static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _instance?._isAppleTV == true); + static bool isAppleTVSync() => _debugAppleTVOverride ?? (_tvosBuild || _singleton.instance?._isAppleTV == true); @visibleForTesting static void debugSetAppleTVOverride(bool? value) { @@ -167,16 +146,14 @@ class TvDetectionService { @visibleForTesting static void debugReset() { - _instance = null; - _initialization = null; - debugDetectionGate = null; + _singleton.debugReset(); _debugAppleTVOverride = null; } - static List tvDetectionReasonsSync() => _instance?._effectiveDetectionReasons ?? const []; + static List tvDetectionReasonsSync() => _singleton.instance?._effectiveDetectionReasons ?? const []; /// Convenience setter that forwards to the singleton if available. - static void setForceTVSync(bool value) => _instance?.setForceTv(value); + static void setForceTVSync(bool value) => _singleton.instance?.setForceTv(value); } class PlatformDetector { diff --git a/lib/utils/url_utils.dart b/lib/utils/url_utils.dart index f352f03d..e1fae749 100644 --- a/lib/utils/url_utils.dart +++ b/lib/utils/url_utils.dart @@ -14,6 +14,36 @@ String stripTrailingSlash(String input) { return trimmed; } +/// Encode query params with `%20` for spaces (not `+`). +/// Null values are omitted and iterable values are emitted as repeated keys. +/// +/// Used instead of `Uri.queryParameters` (which emits `+` for spaces) wherever +/// the server rejects `+` — Plex's transcode endpoints and Seerr's TMDB-backed +/// `/search` proxy both do. +String encodeQueryParameters(Map? params) { + if (params == null || params.isEmpty) return ''; + final parts = []; + + void add(String key, Object? value) { + if (value == null) return; + if (value is Iterable) { + for (final item in value) { + add(key, item); + } + return; + } + parts.add( + '${Uri.encodeComponent(key)}=' + '${Uri.encodeComponent(value.toString())}', + ); + } + + for (final entry in params.entries) { + add(entry.key, entry.value); + } + return parts.join('&'); +} + final RegExp _schemePattern = RegExp(r'^[A-Za-z][A-Za-z\d+.-]*://'); /// Canonicalizes a server base URL: trims, strips one trailing `/`, and diff --git a/lib/watch_together/screens/watch_together_screen.dart b/lib/watch_together/screens/watch_together_screen.dart index cdc05bdb..80081b84 100644 --- a/lib/watch_together/screens/watch_together_screen.dart +++ b/lib/watch_together/screens/watch_together_screen.dart @@ -227,38 +227,60 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta ); } + /// Persists [code] as a recent room for the active profile and refreshes the + /// list. A null [controlMode] leaves any stored mode untouched. + Future _recordRecentRoom(String code, {ControlMode? controlMode}) async { + final profileId = _profileId; + if (profileId == null || profileId.isEmpty) return; + await RecentRoomsService.addOrUpdateRoom( + code, + profileId: profileId, + endpoint: _relayEndpoint, + controlMode: controlMode, + ); + setStateIfMounted(() => _recentRooms = _loadRecentRooms()); + } + + /// Runs [action] behind a busy flag, logging [logMessage] and showing + /// [failureMessage] in a snackbar when it throws. + Future _runSessionAction({ + required void Function(bool busy) setBusy, + required String logMessage, + required String failureMessage, + required Future Function() action, + }) async { + setState(() => setBusy(true)); + try { + await action(); + } catch (e) { + appLogger.e(logMessage, error: e); + if (mounted) { + showErrorSnackBar(context, '$failureMessage: $e'); + } + } finally { + if (mounted) { + setState(() => setBusy(false)); + } + } + } + Future _createSession() async { final controlMode = await _showControlModeDialog(); if (controlMode == null || !mounted) return; - setState(() => _isCreating = true); - - try { - final sessionId = await widget.watchTogether.createSession( - controlMode: controlMode, - relayEndpoint: _relayEndpoint, - displayName: _plexDisplayName, - ); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom( - sessionId, - profileId: profileId, - endpoint: _relayEndpoint, + await _runSessionAction( + setBusy: (busy) => _isCreating = busy, + logMessage: 'Failed to create session', + failureMessage: t.watchTogether.failedToCreate, + action: () async { + final sessionId = await widget.watchTogether.createSession( controlMode: controlMode, + relayEndpoint: _relayEndpoint, + displayName: _plexDisplayName, ); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to create session', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToCreate}: $e'); - } - } finally { - if (mounted) { - setState(() => _isCreating = false); - } - } + await _recordRecentRoom(sessionId, controlMode: controlMode); + }, + ); } Future _showControlModeDialog() { @@ -287,52 +309,32 @@ class _NotInSessionViewState extends State<_NotInSessionView> with MountedSetSta final sessionId = await showJoinSessionDialog(context); if (sessionId == null || !mounted) return; - setState(() => _isJoining = true); - - try { - await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom(sessionId, profileId: profileId, endpoint: _relayEndpoint); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to join session', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); - } - } finally { - if (mounted) { - setState(() => _isJoining = false); - } - } + await _runSessionAction( + setBusy: (busy) => _isJoining = busy, + logMessage: 'Failed to join session', + failureMessage: t.watchTogether.failedToJoin, + action: () async { + await widget.watchTogether.joinSession(sessionId, relayEndpoint: _relayEndpoint, displayName: _plexDisplayName); + await _recordRecentRoom(sessionId); + }, + ); } Future _enterRoom(RecentRoom room) async { - setState(() => _enteringRoomCode = room.code); - - try { - await widget.watchTogether.enterRoom( - room.code, - relayEndpoint: _relayEndpoint, - controlMode: room.controlMode ?? ControlMode.anyone, - displayName: _plexDisplayName, - ); - final profileId = _profileId; - if (profileId != null && profileId.isNotEmpty) { - await RecentRoomsService.addOrUpdateRoom(room.code, profileId: profileId, endpoint: _relayEndpoint); - setStateIfMounted(() => _recentRooms = _loadRecentRooms()); - } - } catch (e) { - appLogger.e('Failed to enter room', error: e); - if (mounted) { - showErrorSnackBar(context, '${t.watchTogether.failedToJoin}: $e'); - } - } finally { - if (mounted) { - setState(() => _enteringRoomCode = null); - } - } + await _runSessionAction( + setBusy: (busy) => _enteringRoomCode = busy ? room.code : null, + logMessage: 'Failed to enter room', + failureMessage: t.watchTogether.failedToJoin, + action: () async { + await widget.watchTogether.enterRoom( + room.code, + relayEndpoint: _relayEndpoint, + controlMode: room.controlMode ?? ControlMode.anyone, + displayName: _plexDisplayName, + ); + await _recordRecentRoom(room.code); + }, + ); } Future _renameRoom(RecentRoom room) async { diff --git a/lib/watch_together/services/watch_together_peer_service.dart b/lib/watch_together/services/watch_together_peer_service.dart index bc591b7c..a49b0fc9 100644 --- a/lib/watch_together/services/watch_together_peer_service.dart +++ b/lib/watch_together/services/watch_together_peer_service.dart @@ -301,16 +301,7 @@ class WatchTogetherPeerService with KeepaliveMixin { final rejectedSetup = _setupCompleter; if (rejectedSetup == null || rejectedSetup.isCompleted) return; - final leaveCompleter = Completer(); - _setupCompleter = leaveCompleter; - _setupRequestType = RelayProtocol.leave; - _sendRaw({ - 'type': RelayProtocol.leave, - 'sessionId': _sessionId, - 'peerId': _myPeerId, - 'reconnectToken': _reconnectToken, - 'protocolVersion': _relayProtocolVersion, - }); + final leaveCompleter = _announce(RelayProtocol.leave); unawaited(() async { try { await leaveCompleter.future.namedTimeout( @@ -803,16 +794,7 @@ class WatchTogetherPeerService with KeepaliveMixin { ); } - final releaseCompleter = Completer(); - _setupCompleter = releaseCompleter; - _setupRequestType = _isHost ? RelayProtocol.endSession : RelayProtocol.leave; - _sendRaw({ - 'type': _isHost ? RelayProtocol.endSession : RelayProtocol.leave, - 'sessionId': _sessionId, - 'peerId': _myPeerId, - 'reconnectToken': _reconnectToken, - 'protocolVersion': _relayProtocolVersion, - }); + final releaseCompleter = _announce(_isHost ? RelayProtocol.endSession : RelayProtocol.leave); await releaseCompleter.future.namedTimeout( const Duration(seconds: 10), operation: _isHost ? 'WatchTogether end session' : 'WatchTogether leave session', diff --git a/lib/widgets/catalog_source_logo.dart b/lib/widgets/catalog_source_logo.dart index c0ad7e8d..f3b0044e 100644 --- a/lib/widgets/catalog_source_logo.dart +++ b/lib/widgets/catalog_source_logo.dart @@ -3,33 +3,28 @@ import 'package:flutter_svg/flutter_svg.dart'; import '../models/catalog/catalog_item.dart'; -/// Brand mark of a catalog source — or any service SVG via -/// [CatalogSourceLogo.asset] — tinted with the ambient icon color. Uses -/// [SvgTheme.currentColor] so SVGs with multiple explicit fills (AniList -/// keeps its brand-blue L while the A follows the theme) render correctly -/// alongside single-color wordmarks. +/// Brand mark of a service, tinted with the ambient icon color. The single +/// table of brand asset paths: every surface that shows a service logo goes +/// through here, including services that do not participate in the Explore +/// catalog. Uses [SvgTheme.currentColor] so SVGs with multiple explicit fills +/// (AniList keeps its brand-blue L while the A follows the theme) render +/// correctly alongside single-color wordmarks. class CatalogSourceLogo extends StatelessWidget { - final CatalogSourceId? id; - final String? assetPath; + final CatalogSourceId id; final double size; - const CatalogSourceLogo(CatalogSourceId this.id, {super.key, this.size = 20}) : assetPath = null; - - /// For services that do not participate in the Explore catalog. - const CatalogSourceLogo.asset(String this.assetPath, {super.key, this.size = 20}) : id = null; + const CatalogSourceLogo(this.id, {super.key, this.size = 20}); @override Widget build(BuildContext context) { - final asset = - assetPath ?? - switch (id!) { - CatalogSourceId.plex => 'assets/plex_chevron.svg', - CatalogSourceId.trakt => 'assets/trakt_circlemark.svg', - CatalogSourceId.mal => 'assets/mal_mark.svg', - CatalogSourceId.anilist => 'assets/anilist_mark.svg', - CatalogSourceId.simkl => 'assets/simkl_mark.svg', - CatalogSourceId.seerr => 'assets/seerr_mark.svg', - }; + final asset = switch (id) { + CatalogSourceId.plex => 'assets/plex_chevron.svg', + CatalogSourceId.trakt => 'assets/trakt_circlemark.svg', + CatalogSourceId.mal => 'assets/mal_mark.svg', + CatalogSourceId.anilist => 'assets/anilist_mark.svg', + CatalogSourceId.simkl => 'assets/simkl_mark.svg', + CatalogSourceId.seerr => 'assets/seerr_mark.svg', + }; final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; return SvgPicture.asset( asset, diff --git a/lib/widgets/companion_remote/discovery_view.dart b/lib/widgets/companion_remote/discovery_view.dart index 282f54d4..10b8b939 100644 --- a/lib/widgets/companion_remote/discovery_view.dart +++ b/lib/widgets/companion_remote/discovery_view.dart @@ -4,20 +4,15 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../../connection/connection_registry.dart'; import '../../focus/focusable_button.dart'; import '../../focus/focusable_text_field.dart'; import '../../focus/focusable_wrapper.dart'; import '../../i18n/strings.g.dart'; import '../../mixins/controller_disposer_mixin.dart'; import '../../mixins/mounted_set_state_mixin.dart'; -import '../../models/plex/plex_home.dart'; -import '../../profiles/active_plex_identity.dart'; -import '../../profiles/active_profile_provider.dart'; -import '../../profiles/plex_home_service.dart'; -import '../../profiles/profile_connection_registry.dart'; import '../../providers/companion_remote_provider.dart'; import '../../services/base_peer_service.dart'; +import '../../services/companion_remote/companion_remote_host_controller.dart'; import '../../services/settings_service.dart'; import '../../theme/mono_tokens.dart'; import '../../utils/app_logger.dart'; @@ -93,26 +88,7 @@ class _DiscoveryViewState extends State with ControllerDisposerMi Future _initCryptoAndDiscover() async { try { - final connections = context.read(); - final activeProfile = context.read(); - final profileConnections = context.read(); - final plexHome = context.read(); - final identity = await resolveActivePlexIdentity( - activeProfile: activeProfile, - connections: connections, - profileConnections: profileConnections, - ); - if (!mounted) return; - final home = await _resolveHome(identity?.account.id); - if (!mounted) return; - await _provider.ensureCryptoReady( - home, - connections: connections, - activeProfile: activeProfile, - profileConnections: profileConnections, - identity: identity, - plexHomeForConnection: plexHome.materializePlexHomeForConnection, - ); + await ensureCompanionRemoteCryptoFromContext(context); } catch (e) { appLogger.e('CompanionRemote: crypto init failed', error: e); } @@ -137,11 +113,6 @@ class _DiscoveryViewState extends State with ControllerDisposerMi } } - Future _resolveHome(String? connectionId) { - if (connectionId == null) return Future.value(); - return context.read().materializePlexHomeForConnection(connectionId); - } - void _startDiscovery() { final stream = _provider.discoverHosts(); if (stream == null) return; diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index 2dbef406..989ce6ad 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -10,7 +10,6 @@ import '../media/media_kind.dart'; import '../models/download_models.dart'; import '../utils/dialogs.dart'; import '../utils/global_key_utils.dart'; -import 'clickable_cursor.dart'; import 'download_status_icon.dart'; /// Represents a node in the download tree @@ -439,7 +438,10 @@ class _DownloadTreeViewState extends State { /// Pause all active (downloading and queued) children of a container node void _pauseAllChildren(DownloadTreeNode node) { - final keys = _getActiveChildKeys(node); + final keys = _leafKeys( + node, + where: (leaf) => leaf.status == DownloadStatus.downloading || leaf.status == DownloadStatus.queued, + ); for (final key in keys) { widget.onPause?.call(key); } @@ -447,38 +449,12 @@ class _DownloadTreeViewState extends State { /// Resume all paused children of a container node void _resumeAllChildren(DownloadTreeNode node) { - final keys = _getPausedChildKeys(node); + final keys = _leafKeys(node, where: (leaf) => leaf.status == DownloadStatus.paused); for (final key in keys) { widget.onResume?.call(key); } } - /// Get all active (downloading or queued) child keys from a container node - List _getActiveChildKeys(DownloadTreeNode node) { - final List keys = []; - for (final child in node.children) { - if (child.hasChildren) { - keys.addAll(_getActiveChildKeys(child)); - } else if (child.status == DownloadStatus.downloading || child.status == DownloadStatus.queued) { - keys.add(child.key); - } - } - return keys; - } - - /// Get all paused child keys from a container node - List _getPausedChildKeys(DownloadTreeNode node) { - final List keys = []; - for (final child in node.children) { - if (child.hasChildren) { - keys.addAll(_getPausedChildKeys(child)); - } else if (child.status == DownloadStatus.paused) { - keys.add(child.key); - } - } - return keys; - } - /// Delete all children of a container node via the container's globalKey /// so deleteDownload's transitive show/season path cleans up all maps. void _deleteAllChildren(DownloadTreeNode node) { @@ -489,23 +465,22 @@ class _DownloadTreeViewState extends State { } // Container globalKey unresolvable; fall back to per-leaf delete. - for (final key in _getAllChildKeys(node)) { + for (final key in _leafKeys(node)) { widget.onDelete?.call(key); } } - /// Get all leaf node keys from a container node - List _getAllChildKeys(DownloadTreeNode node) { + /// Get the keys of every leaf below a container node, in tree order. + /// [where] filters which leaves are collected; unset collects all of them. + List _leafKeys(DownloadTreeNode node, {bool Function(DownloadTreeNode leaf)? where}) { final List keys = []; - for (final child in node.children) { if (child.hasChildren) { - keys.addAll(_getAllChildKeys(child)); - } else { + keys.addAll(_leafKeys(child, where: where)); + } else if (where == null || where(child)) { keys.add(child.key); } } - return keys; } } @@ -556,6 +531,11 @@ class _FlatNode { const _FlatNode({required this.node, required this.depth}); } +/// A single action button of a tree row: the guards that decide which actions +/// exist live in one place ([_DownloadTreeItemState._actions]), so the focus +/// node count and the rendered buttons can never disagree. +typedef _RowAction = ({IconData icon, String tooltip, VoidCallback onPressed}); + /// A single tree item with focusable row content and action buttons class _DownloadTreeItem extends StatefulWidget { final DownloadTreeNode node; @@ -628,7 +608,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { void didUpdateWidget(_DownloadTreeItem oldWidget) { super.didUpdateWidget(oldWidget); // Reinitialize focus nodes if action count changed - if (_getActionCount() != _buttonFocusNodes.length) { + if (_actions().length != _buttonFocusNodes.length) { _disposeButtonFocusNodes(); _initButtonFocusNodes(); } @@ -641,7 +621,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { } void _initButtonFocusNodes() { - final actionCount = _getActionCount(); + final actionCount = _actions().length; for (int i = 0; i < actionCount; i++) { _buttonFocusNodes.add(FocusNode(debugLabel: 'download_action_$i')); } @@ -661,40 +641,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { super.dispose(); } - int _getActionCount() { - final isContainer = - widget.node.type == DownloadNodeType.show || - widget.node.type == DownloadNodeType.season || - widget.node.type == DownloadNodeType.album; - if (isContainer) { - return _getContainerActionCount(); - } - return _getItemActionCount(); - } - - int _getItemActionCount() { - int count = 0; - final status = widget.node.status; - if (status == DownloadStatus.downloading && widget.onPause != null) count++; - if (status == DownloadStatus.paused && widget.onResume != null) count++; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) count++; - if (status == DownloadStatus.failed && widget.onRetry != null) count++; - if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && - widget.onDelete != null) { - count++; - } - return count; - } - - int _getContainerActionCount() { - int count = 0; - final status = widget.node.status; - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) count++; - if (status == DownloadStatus.paused && widget.onResume != null) count++; - if (widget.onDelete != null) count++; - return count; - } - void _focusFirstButton() { if (_buttonFocusNodes.isNotEmpty) { _buttonFocusNodes.first.requestFocus(); @@ -709,7 +655,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { Widget build(BuildContext context) { final theme = Theme.of(context); final canExpand = widget.node.hasChildren; - final hasActions = _buttonFocusNodes.isNotEmpty; + final actions = _actions(); return Padding( padding: .only(left: widget.depth * 16.0), @@ -718,7 +664,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { autofocus: widget.autofocus, onSelect: canExpand ? widget.onToggleExpansion : null, onNavigateLeft: widget.onNavigateLeft, - onNavigateRight: hasActions ? _focusFirstButton : null, + onNavigateRight: actions.isNotEmpty ? _focusFirstButton : null, onBack: widget.onBack, borderRadius: 8.0, disableScale: true, @@ -734,7 +680,11 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { Expanded(child: _buildRowContent(theme, canExpand)), // Action buttons - if (hasActions) _buildActions(), + if (actions.isNotEmpty) + Row( + mainAxisSize: .min, + children: [for (int i = 0; i < actions.length; i++) _buildActionButton(actions[i], i)], + ), ], ), ), @@ -755,7 +705,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { const SizedBox(width: 8), // Status icon - _buildStatusIcon(_effectiveStatus), + DownloadStatusIcon(status: _effectiveStatus, size: 20), const SizedBox(width: 12), @@ -826,180 +776,121 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { ); } - Widget _buildStatusIcon(DownloadStatus status) { - return DownloadStatusIcon(status: status, size: 20); - } - String _getNodeSummary() { final total = widget.node.children.length; final completed = widget.node.completedChildrenCount; return '$completed/$total completed'; } - Widget _buildActions() { + /// The actions this row offers, in render order. Single source of truth: + /// both the button widgets and the focus nodes sizing come from this list, + /// so they cannot drift apart. Uses the raw node status, not + /// [_effectiveStatus] (which only remaps the row content). + List<_RowAction> _actions() { + final status = widget.node.status; final isContainer = widget.node.type == DownloadNodeType.show || widget.node.type == DownloadNodeType.season || widget.node.type == DownloadNodeType.album; + final actions = <_RowAction>[]; - final actions = isContainer ? _buildContainerActions() : _buildItemActions(); + if (isContainer) { + // Pause all button + if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { + actions.add(( + icon: Symbols.pause_rounded, + tooltip: t.downloads.pauseAll, + onPressed: () => widget.pauseAllChildren(widget.node), + )); + } - return Row(mainAxisSize: .min, children: actions); - } + // Resume all button + if (status == DownloadStatus.paused && widget.onResume != null) { + actions.add(( + icon: Symbols.play_arrow_rounded, + tooltip: t.downloads.resumeAll, + onPressed: () => widget.resumeAllChildren(widget.node), + )); + } + + // Delete all button + if (widget.onDelete != null) { + actions.add(( + icon: Symbols.delete_sweep_rounded, + tooltip: t.downloads.deleteAll, + onPressed: () async { + if (await _confirmDelete()) widget.deleteAllChildren(widget.node); + }, + )); + } + + return actions; + } - List _buildItemActions() { final globalKey = widget.node.key; - final status = widget.node.status; - final actions = []; - int buttonIndex = 0; // Pause button for downloading items if (status == DownloadStatus.downloading && widget.onPause != null) { - actions.add( - _buildActionButton( - icon: Symbols.pause_rounded, - tooltip: t.common.pause, - onPressed: () => widget.onPause!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add((icon: Symbols.pause_rounded, tooltip: t.common.pause, onPressed: () => widget.onPause!(globalKey))); } // Resume button for paused items if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: t.common.resume, - onPressed: () => widget.onResume!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.play_arrow_rounded, + tooltip: t.common.resume, + onPressed: () => widget.onResume!(globalKey), + )); } // Cancel button for downloading/queued items if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onCancel != null) { - actions.add( - _buildActionButton( - icon: Symbols.close_rounded, - tooltip: t.common.cancel, - onPressed: () => widget.onCancel!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.close_rounded, + tooltip: t.common.cancel, + onPressed: () => widget.onCancel!(globalKey), + )); } // Retry button for failed items if (status == DownloadStatus.failed && widget.onRetry != null) { - actions.add( - _buildActionButton( - icon: Symbols.refresh_rounded, - tooltip: t.downloads.retryDownload, - onPressed: () => widget.onRetry!(globalKey), - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.refresh_rounded, + tooltip: t.downloads.retryDownload, + onPressed: () => widget.onRetry!(globalKey), + )); } // Delete button for completed/failed/cancelled items if ((status == DownloadStatus.completed || status == DownloadStatus.failed || status == DownloadStatus.cancelled) && widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_rounded, - tooltip: t.common.delete, - onPressed: () async { - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: widget.node.title), - ); - if (confirmed) widget.onDelete!(globalKey); - }, - buttonIndex: buttonIndex++, - ), - ); + actions.add(( + icon: Symbols.delete_rounded, + tooltip: t.common.delete, + onPressed: () async { + if (await _confirmDelete()) widget.onDelete!(globalKey); + }, + )); } return actions; } - List _buildContainerActions() { - final status = widget.node.status; - final actions = []; - int buttonIndex = 0; - - // Pause all button - if ((status == DownloadStatus.downloading || status == DownloadStatus.queued) && widget.onPause != null) { - actions.add( - _buildActionButton( - icon: Symbols.pause_rounded, - tooltip: t.downloads.pauseAll, - onPressed: () => widget.pauseAllChildren(widget.node), - buttonIndex: buttonIndex++, - ), - ); - } - - // Resume all button - if (status == DownloadStatus.paused && widget.onResume != null) { - actions.add( - _buildActionButton( - icon: Symbols.play_arrow_rounded, - tooltip: t.downloads.resumeAll, - onPressed: () => widget.resumeAllChildren(widget.node), - buttonIndex: buttonIndex++, - ), - ); - } - - // Delete all button - if (widget.onDelete != null) { - actions.add( - _buildActionButton( - icon: Symbols.delete_sweep_rounded, - tooltip: t.downloads.deleteAll, - onPressed: () async { - final confirmed = await showDeleteConfirmation( - context, - title: t.downloads.deleteDownload, - message: t.downloads.deleteConfirm(title: widget.node.title), - ); - if (confirmed) widget.deleteAllChildren(widget.node); - }, - buttonIndex: buttonIndex++, - ), - ); - } - - return actions; + Future _confirmDelete() { + return showDeleteConfirmation( + context, + title: t.downloads.deleteDownload, + message: t.downloads.deleteConfirm(title: widget.node.title), + ); } - Widget _buildActionButton({ - required IconData icon, - required String tooltip, - required VoidCallback onPressed, - required int buttonIndex, - }) { - // Guard against race condition where action count changed between didUpdateWidget and build - if (buttonIndex >= _buttonFocusNodes.length) { - return Tooltip( - message: tooltip, - child: ClickableCursor( - child: GestureDetector( - onTap: onPressed, - child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), - ), - ), - ); - } - + Widget _buildActionButton(_RowAction action, int buttonIndex) { final isFirst = buttonIndex == 0; final isLast = buttonIndex == _buttonFocusNodes.length - 1; return FocusableWrapper( focusNode: _buttonFocusNodes[buttonIndex], - onSelect: onPressed, + onSelect: action.onPressed, onNavigateLeft: isFirst ? _focusRow : () => _buttonFocusNodes[buttonIndex - 1].requestFocus(), onNavigateRight: isLast ? null : () => _buttonFocusNodes[buttonIndex + 1].requestFocus(), onBack: widget.onBack, @@ -1008,10 +899,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { useBackgroundFocus: true, autoScroll: false, child: Tooltip( - message: tooltip, + message: action.tooltip, child: GestureDetector( - onTap: onPressed, - child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(icon, fill: 1, size: 20)), + onTap: action.onPressed, + child: Padding(padding: const EdgeInsets.all(8.0), child: AppIcon(action.icon, fill: 1, size: 20)), ), ), ); diff --git a/lib/widgets/focusable_filter_chip.dart b/lib/widgets/focusable_filter_chip.dart index 8c2afb92..feccce88 100644 --- a/lib/widgets/focusable_filter_chip.dart +++ b/lib/widgets/focusable_filter_chip.dart @@ -56,24 +56,6 @@ class _FocusableFilterChipState extends State with Focusabl @override String get debugLabel => 'filter_chip_${widget.label}'; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableFilterChip oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { return handleChipKeyEvent( node, diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart index bbf26ed5..619107d9 100644 --- a/lib/widgets/focusable_tab_chip.dart +++ b/lib/widgets/focusable_tab_chip.dart @@ -91,24 +91,6 @@ class _FocusableTabChipState extends State with FocusableChipS @override String get debugLabel => 'tab_chip_${widget.label}'; - @override - void initState() { - super.initState(); - initFocusNode(); - } - - @override - void didUpdateWidget(FocusableTabChip oldWidget) { - super.didUpdateWidget(oldWidget); - updateFocusNode(oldWidget.focusNode); - } - - @override - void dispose() { - disposeFocusNode(); - super.dispose(); - } - KeyEventResult _handleKeyEvent(FocusNode node, KeyEvent event) { return handleChipKeyEvent( node, diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index 6f1b6cd0..c72590c4 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -1,21 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; -import '../focus/dpad_navigator.dart'; +import '../focus/dpad_reorder_mixin.dart'; import '../focus/focus_theme.dart'; import '../focus/input_mode_tracker.dart'; -import '../focus/key_event_utils.dart'; import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_library.dart'; import '../media/media_server_client.dart'; import '../providers/hidden_libraries_provider.dart'; import '../providers/libraries_provider.dart'; -import '../services/plex_client.dart'; import '../utils/app_logger.dart'; import '../utils/content_utils.dart'; import '../utils/dialogs.dart'; @@ -182,48 +179,24 @@ Future _handleLibraryMenuAction(BuildContext context, String action, Media } } -Future _performLibraryAction( +/// Runs a library admin action, wrapping it in progress/success/failure +/// snackbars. +/// +/// [resolveClient] picks the client flavour: `getPlexClientForLibrary` for the +/// Plex-only endpoints (scan / analyze / empty trash), `getMediaClientForLibrary` +/// for ops that exist on the backend-neutral [MediaServerClient] interface +/// (currently just refresh metadata). Both resolvers require the library's exact +/// owning server and throw the same error when it isn't available. +Future _performLibraryAction( BuildContext context, { - required MediaLibrary library, - required Future Function(PlexClient client) action, + required T Function(BuildContext context) resolveClient, + required Future Function(T client) action, required String progressMessage, required String successMessage, required String Function(Object error) failureMessage, }) async { try { - final client = context.getPlexClientForLibrary(library); - - if (context.mounted) { - showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); - } - - await action(client); - - if (context.mounted) { - showSuccessSnackBar(context, successMessage); - } - } catch (e) { - appLogger.e('Library action failed', error: e); - if (context.mounted) { - showErrorSnackBar(context, failureMessage(e)); - } - } -} - -/// Backend-neutral counterpart to [_performLibraryAction] for ops that exist -/// on the [MediaServerClient] interface (currently just refresh metadata). -/// Resolves the client through `getMediaClientForLibrary` so the action requires -/// the library's exact owning server. -Future _performMediaLibraryAction( - BuildContext context, { - required MediaLibrary library, - required Future Function(MediaServerClient client) action, - required String progressMessage, - required String successMessage, - required String Function(Object error) failureMessage, -}) async { - try { - final client = context.getMediaClientForLibrary(library); + final client = resolveClient(context); if (context.mounted) { showAppSnackBar(context, progressMessage, duration: const Duration(seconds: 2)); @@ -245,7 +218,7 @@ Future _performMediaLibraryAction( Future _scanLibrary(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.scanLibrary(library.id), progressMessage: t.messages.libraryScanning(title: library.title), successMessage: t.messages.libraryScanStarted(title: library.title), @@ -254,9 +227,9 @@ Future _scanLibrary(BuildContext context, MediaLibrary library) { } Future _refreshLibraryMetadata(BuildContext context, MediaLibrary library) { - return _performMediaLibraryAction( + return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getMediaClientForLibrary(library), action: (client) => client.refreshLibraryMetadata(library.id), progressMessage: t.messages.metadataRefreshing(title: library.title), successMessage: t.messages.metadataRefreshStarted(title: library.title), @@ -267,7 +240,7 @@ Future _refreshLibraryMetadata(BuildContext context, MediaLibrary library) Future _emptyLibraryTrash(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.emptyLibraryTrash(library.id), progressMessage: t.libraries.emptyingTrash(title: library.title), successMessage: t.libraries.trashEmptied(title: library.title), @@ -278,7 +251,7 @@ Future _emptyLibraryTrash(BuildContext context, MediaLibrary library) { Future _analyzeLibrary(BuildContext context, MediaLibrary library) { return _performLibraryAction( context, - library: library, + resolveClient: (ctx) => ctx.getPlexClientForLibrary(library), action: (client) => client.analyzeLibrary(library.id), progressMessage: t.libraries.analyzing(title: library.title), successMessage: t.libraries.analysisStarted(title: library.title), @@ -309,19 +282,41 @@ class _LibraryManagementSheet extends StatefulWidget { State<_LibraryManagementSheet> createState() => _LibraryManagementSheetState(); } -class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { +class _LibraryManagementSheetState extends State<_LibraryManagementSheet> + with DpadReorderListMixin { late List _tempLibraries; - // Keyboard navigation state - int _focusedIndex = 0; - int _focusedColumn = 0; // 0 = row, 1 = visibility button, 2 = options button - int? _movingIndex; // Non-null when in move mode - int? _originalIndex; // Original position before move (for cancel) - List? _originalOrder; // Original order before move (for cancel) final FocusNode _listFocusNode = FocusNode(); final ScrollController _dialogScrollController = ScrollController(); final ScrollController _sheetScrollController = ScrollController(); - bool _backKeyDownSeen = false; + + // Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button. + @override + List get reorderItems => _tempLibraries; + + @override + set reorderItems(List value) => _tempLibraries = value; + + @override + int get lastReorderColumn => 2; + + /// Only the TV dialog scrolls the focused row into view; the bottom sheet + /// list is not keyboard-driven. + @override + ScrollController? get reorderScrollController => widget.isDialog ? _dialogScrollController : null; + + @override + void onReorderMoveConfirmed() => widget.onReorder(_tempLibraries); + + @override + void onReorderColumnActivated(int column, int index) { + final library = _tempLibraries[index]; + if (column == 1) { + widget.onToggleVisibility(library); + } else if (column == 2) { + _showLibraryMenuBottomSheet(context, library); + } + } @override void initState() { @@ -337,157 +332,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { super.dispose(); } - void _ensureFocusedVisible() { - if (!widget.isDialog) return; - if (!_dialogScrollController.hasClients) return; - - const double itemHeight = 72.0; // Material ListTile with subtitle - const double listTopPadding = 8.0; - final double targetTop = listTopPadding + (_focusedIndex * itemHeight); - final double targetBottom = targetTop + itemHeight; - - final double viewportTop = _dialogScrollController.offset; - final double viewportHeight = _dialogScrollController.position.viewportDimension; - final double viewportBottom = viewportTop + viewportHeight; - - // Already fully visible — skip - if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; - - // Place item at ~25% from top of viewport - final double destination = (targetTop - viewportHeight * 0.25).clamp( - 0.0, - _dialogScrollController.position.maxScrollExtent, - ); - - _dialogScrollController.animateTo(destination, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); - } - - KeyEventResult _handleKeyEvent(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(() { - if (_originalOrder != null) { - _tempLibraries = List.from(_originalOrder!); - } - _focusedIndex = _originalIndex ?? 0; - _movingIndex = null; - _originalIndex = null; - _originalOrder = null; - }); - } else { - OverlaySheetController.popAdaptive(context); - } - }); - if (backResult != KeyEventResult.ignored) { - return backResult; - } - - if (!event.isActionable) return KeyEventResult.ignored; - - if (_movingIndex != null) { - // Move mode - arrows reorder the item - if (key.isUpKey && _movingIndex! > 0) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! - 1, item); - _movingIndex = _movingIndex! - 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isDownKey && _movingIndex! < _tempLibraries.length - 1) { - setState(() { - final item = _tempLibraries.removeAt(_movingIndex!); - _tempLibraries.insert(_movingIndex! + 1, item); - _movingIndex = _movingIndex! + 1; - _focusedIndex = _movingIndex!; - }); - _ensureFocusedVisible(); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - // Confirm move - apply the reorder - widget.onReorder(_tempLibraries); - 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 < _tempLibraries.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 < 2) { - setState(() => _focusedColumn++); - return KeyEventResult.handled; - } - if (key.isSelectKey) { - if (_focusedColumn == 0) { - // Enter move mode - setState(() { - _movingIndex = _focusedIndex; - _originalIndex = _focusedIndex; - _originalOrder = List.from(_tempLibraries); - }); - } else if (_focusedColumn == 1) { - // Toggle visibility - final library = _tempLibraries[_focusedIndex]; - widget.onToggleVisibility(library); - } else if (_focusedColumn == 2) { - // Show options menu - final library = _tempLibraries[_focusedIndex]; - _showLibraryMenuBottomSheet(context, library); - } - 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 _reorderLibraries(int oldIndex, int newIndex) { setState(() { final library = _tempLibraries.removeAt(oldIndex); @@ -527,7 +371,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { if (widget.isDialog) { return Dialog( child: PopScope( - canPop: false, // Prevent system back from double-popping; handled by _handleKeyEvent + canPop: false, // Prevent system back from double-popping; handled by handleReorderKeyEvent // ignore: no-empty-block - required callback, blocks system back on Android TV onPopInvokedWithResult: (didPop, result) {}, child: Scaffold( @@ -551,8 +395,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, - child: _buildFlatLibraryListDialog(hiddenLibraryKeys), + onKeyEvent: handleReorderKeyEvent, + child: _buildFlatLibraryList(_dialogScrollController, hiddenLibraryKeys), ), ), ), @@ -567,7 +411,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { focusNode: _listFocusNode, descendantsAreFocusable: false, autofocus: InputModeTracker.isKeyboardMode(context), - onKeyEvent: _handleKeyEvent, + onKeyEvent: handleReorderKeyEvent, child: _buildFlatLibraryList(_sheetScrollController, hiddenLibraryKeys), ), ), @@ -575,37 +419,9 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { ); } - /// Build library list for dialog (TV) using ListView with scroll-into-view support - Widget _buildFlatLibraryListDialog(Set hiddenLibraryKeys) { - final showServerNames = _hasMultipleServers(); - final isKeyboardMode = InputModeTracker.isKeyboardMode(context); - - return ReorderableListView.builder( - scrollController: _dialogScrollController, - onReorderItem: _reorderLibraries, - itemCount: _tempLibraries.length, - padding: const EdgeInsets.symmetric(vertical: 8), - buildDefaultDragHandles: false, - itemBuilder: (context, index) { - final library = _tempLibraries[index]; - final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; - - return _buildLibraryTile( - library, - index, - hiddenLibraryKeys, - showServerName: showServerName, - isFocused: isFocused, - isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, - ); - }, - ); - } - - /// Build flat library list with a server subtitle when multiple servers are connected + /// Build flat library list with a server subtitle when multiple servers are + /// connected. The TV dialog passes [_dialogScrollController] so focused rows + /// can be scrolled into view; the bottom sheet passes its own controller. Widget _buildFlatLibraryList(ScrollController scrollController, Set hiddenLibraryKeys) { final showServerNames = _hasMultipleServers(); final isKeyboardMode = InputModeTracker.isKeyboardMode(context); @@ -619,8 +435,8 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { itemBuilder: (context, index) { final library = _tempLibraries[index]; final showServerName = showServerNames && library.serverName != null; - final isFocused = isKeyboardMode && index == _focusedIndex; - final isMoving = index == _movingIndex; + final isFocused = isKeyboardMode && index == focusedIndex; + final isMoving = index == movingIndex; return _buildLibraryTile( library, index, @@ -628,7 +444,7 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> { showServerName: showServerName, isFocused: isFocused, isMoving: isMoving, - focusedColumn: isFocused ? _focusedColumn : null, + focusedColumn: isFocused ? focusedColumn : null, ); }, ); diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 1a92c4c2..cd2f8059 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -1060,22 +1060,11 @@ class MediaContextMenuState extends State { await playTrackWithAlbumContext(context, item); return; } - // Availability gate before the container fetch so the stub costs no - // server round-trip. - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - List tracks; - try { - tracks = await _musicTracksForItem(item); - } catch (_) { - if (!service.isPlayIntentCurrent(intent)) return; - rethrow; - } - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - await playTracks( + // No onError: a failed container fetch falls through to this menu's own + // error boundary, which logs it and shows the snackbar. + await playFetchedTracks( context, - tracks: tracks, + fetch: () => _musicTracksForItem(item), playContext: MusicPlayContext( id: item.id, title: item.displayTitle, @@ -1402,29 +1391,15 @@ class MediaContextMenuState extends State { Future _launchAudioPlaylist(BuildContext context, MediaPlaylist playlist, {required bool shuffle}) async { // Match PlaylistDetailScreen: fail the availability gate before paying // for a full playlist fetch, then hand the tracks to the music session. - if (!ensureMusicPlaybackAvailable(context)) return; - final service = context.read(); - final intent = service.beginPlayIntent(); - - List tracks; - try { - tracks = await fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id); - } catch (e, st) { - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); - showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); - return; - } - if (!context.mounted || !service.isPlayIntentCurrent(intent)) return; - if (tracks.isEmpty) { - showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems); - return; - } - - await playTracks( + await playFetchedTracks( context, - tracks: tracks, + fetch: () => fetchAllPlaylistItems(_getMediaClientForItem(), playlist.id), playContext: MusicPlayContext(id: playlist.id, title: playlist.title, kind: MusicPlayContextKind.playlist), + onError: (e, st) { + appLogger.w('Failed to fetch audio playlist ${playlist.id}', error: e, stackTrace: st); + showErrorSnackBar(context, t.messages.errorLoading(error: e.toString())); + }, + onEmpty: () => showErrorSnackBar(context, t.messages.failedToCreatePlayQueueNoItems), shuffle: shuffle, ); } diff --git a/lib/widgets/rating_bottom_sheet.dart b/lib/widgets/rating_bottom_sheet.dart index e1b8c74e..b12727fd 100644 --- a/lib/widgets/rating_bottom_sheet.dart +++ b/lib/widgets/rating_bottom_sheet.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_svg/flutter_svg.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; @@ -12,20 +11,18 @@ import '../i18n/strings.g.dart'; import '../media/media_backend.dart'; import '../media/media_item.dart'; import '../media/media_server_client.dart'; +import '../models/catalog/catalog_item.dart'; import '../providers/trackers_provider.dart'; -import '../providers/trakt_account_provider.dart'; -import '../services/trackers/anilist/anilist_tracker.dart'; -import '../services/trackers/mal/mal_tracker.dart'; -import '../services/trackers/simkl/simkl_tracker.dart'; +import '../screens/settings/tracker_service_info.dart'; import '../services/trackers/tracker.dart'; import '../services/trackers/tracker_constants.dart'; import '../services/trackers/tracker_id_resolver.dart'; -import '../services/trakt/trakt_scrobble_service.dart'; import '../utils/app_logger.dart'; import '../utils/snackbar_helper.dart'; import 'app_icon.dart'; import 'backend_badge.dart'; import 'bottom_sheet_header.dart'; +import 'catalog_source_logo.dart'; import 'clickable_cursor.dart'; class RatingBottomSheet extends StatefulWidget { @@ -92,9 +89,10 @@ class _RatingBottomSheetState extends State { final size = MediaQuery.sizeOf(context); final maxHeight = size.height * (size.width > 600 ? 0.64 : 0.74); - return Consumer2( - builder: (context, trakt, trackers, _) { - final allTrackerSources = _trackerSources(trakt, trackers); + // Trakt's account provider is watched by [_trackerSources] via `context`. + return Consumer( + builder: (context, trackers, _) { + final allTrackerSources = _trackerSources(context); final trackerSources = allTrackerSources.where((source) => !_hiddenTrackers.contains(source.service)).toList(); _updateTrackerSourceMap(trackerSources); _resolverNeedsFribb = trackers.isMalConnected || trackers.isAnilistConnected; @@ -227,7 +225,7 @@ class _RatingBottomSheetState extends State { return _RatingRow( focusNode: focusNode, autofocus: autofocus, - leading: _TrackerLogo(source.logoAsset), + leading: CatalogSourceLogo(source.logoSource, size: 24), title: source.title, subtitle: source.username != null ? t.services.connectedAs(username: source.username!) : source.connectedLabel, loading: loading, @@ -247,58 +245,20 @@ class _RatingBottomSheetState extends State { ); } - List<_TrackerRatingSource> _trackerSources(TraktAccountProvider trakt, TrackersProvider trackers) { - final sources = <_TrackerRatingSource>[]; - if (trakt.isConnected) { - sources.add( + /// Snapshot of every connected tracker, in the shared display order. Must be + /// called from a build so the provider reads register a dependency. + List<_TrackerRatingSource> _trackerSources(BuildContext context) => [ + for (final info in TrackerServiceInfo.all) + if (info.isConnected(context)) _TrackerRatingSource( - service: TrackerService.trakt, - title: t.trakt.title, - username: trakt.username, + service: info.service, + title: info.displayName, + username: info.username(context), connectedLabel: t.trakt.connected, - logoAsset: 'assets/trakt_circlemark.svg', - ratingSource: TraktScrobbleService.instance, + logoSource: info.logoSource, + ratingSource: info.ratingSource, ), - ); - } - if (trackers.isMalConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.mal, - title: t.services.names.mal, - username: trackers.malUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/mal_mark.svg', - ratingSource: MalTracker.instance, - ), - ); - } - if (trackers.isAnilistConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.anilist, - title: t.services.names.anilist, - username: trackers.anilistUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/anilist_mark.svg', - ratingSource: AnilistTracker.instance, - ), - ); - } - if (trackers.isSimklConnected) { - sources.add( - _TrackerRatingSource( - service: TrackerService.simkl, - title: t.services.names.simkl, - username: trackers.simklUsername, - connectedLabel: t.trakt.connected, - logoAsset: 'assets/simkl_mark.svg', - ratingSource: SimklTracker.instance, - ), - ); - } - return sources; - } + ]; void _updateTrackerSourceMap(List<_TrackerRatingSource> sources) { _trackerSourcesByKey @@ -618,7 +578,7 @@ class _TrackerRatingSource { final String title; final String? username; final String connectedLabel; - final String logoAsset; + final CatalogSourceId logoSource; final TrackerRatingSource ratingSource; const _TrackerRatingSource({ @@ -626,7 +586,7 @@ class _TrackerRatingSource { required this.title, required this.username, required this.connectedLabel, - required this.logoAsset, + required this.logoSource, required this.ratingSource, }); } @@ -889,15 +849,3 @@ class _FavoriteControl extends StatelessWidget { ); } } - -class _TrackerLogo extends StatelessWidget { - final String asset; - - const _TrackerLogo(this.asset); - - @override - Widget build(BuildContext context) { - final color = IconTheme.of(context).color ?? Theme.of(context).colorScheme.onSurface; - return SvgPicture.asset(asset, width: 24, height: 24, theme: SvgTheme(currentColor: color)); - } -} diff --git a/lib/widgets/setting_tile.dart b/lib/widgets/setting_tile.dart index f8af24c2..bdf62aab 100644 --- a/lib/widgets/setting_tile.dart +++ b/lib/widgets/setting_tile.dart @@ -13,8 +13,46 @@ import 'settings_section.dart'; /// Eliminates the field-mirror + setState + manual reload pattern that used to /// surround every settings row. -class _TileBase { - static SettingsService get _svc => SettingsService.instance; +/// Shared commit path for every tile: persist [value] under [pref], then hand +/// it to the tile's optional [onAfterWrite] callback. +Future _writeAndNotify(Pref pref, T value, FutureOr Function(T)? onAfterWrite) async { + await SettingsService.instance.write(pref, value); + if (onAfterWrite != null) await onAfterWrite(value); +} + +/// Shared scaffold for the tiles that render a tappable settings row: same +/// leading icon, title style and row density everywhere. [trailing] defaults +/// to the chevron used by every row that opens a dialog. +class _SettingRow extends StatelessWidget { + final IconData icon; + final String title; + final Widget? subtitle; + final Widget? trailing; + final VoidCallback onTap; + final FocusNode? focusNode; + + const _SettingRow({ + required this.icon, + required this.title, + required this.onTap, + this.subtitle, + this.trailing, + this.focusNode, + }); + + @override + Widget build(BuildContext context) { + return FocusableListTile( + focusNode: focusNode, + leading: AppIcon(icon, fill: 1), + title: Text(title, style: settingsOptionTitleStyle(context)), + subtitle: subtitle, + trailing: trailing ?? const AppIcon(Symbols.chevron_right_rounded, fill: 1), + onTap: onTap, + dense: settingsRowDense(context), + visualDensity: settingsRowVisualDensity(context), + ); + } } /// SwitchListTile bound to a [Pref]. @@ -40,9 +78,8 @@ class SettingSwitchTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), + valueListenable: SettingsService.instance.listenable(pref), builder: (_, value, _) => FocusableSwitchListTile( focusNode: focusNode, secondary: AppIcon(icon, fill: 1), @@ -51,13 +88,7 @@ class SettingSwitchTile extends StatelessWidget { value: value, dense: settingsRowDense(context), visualDensity: settingsRowVisualDensity(context), - onChanged: enabled - ? (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - } - : null, + onChanged: enabled ? (v) => _writeAndNotify(pref, v, onAfterWrite) : null, ), ); } @@ -86,15 +117,13 @@ class SettingNavigationTile extends StatelessWidget { @override Widget build(BuildContext context) { - return FocusableListTile( + return _SettingRow( focusNode: focusNode, - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + icon: icon, + title: title, subtitle: subtitle != null ? Text(subtitle!) : null, trailing: AppIcon(trailingIcon, fill: 1), onTap: onTap ?? () => Navigator.push(context, MaterialPageRoute(builder: destinationBuilder!)), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), ); } } @@ -126,16 +155,12 @@ class SettingNumberTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, value, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showNumericInputDialog( context: context, title: title, @@ -144,11 +169,7 @@ class SettingNumberTile extends StatelessWidget { min: min, max: max, currentValue: value, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); @@ -156,16 +177,12 @@ class SettingNumberTile extends StatelessWidget { } /// ListTile that opens [showSelectionDialog] and writes the chosen value. -/// [encode]/[decode] map between the [Pref] storage type and the option -/// type [T] (e.g. enum-stored-as-string preset → [TranscodeQualityPreset]). -class SettingSelectionTile extends StatelessWidget { - final Pref pref; +class SettingSelectionTile extends StatelessWidget { + final Pref pref; final IconData icon; final String title; final String Function(T) subtitleBuilder; final List> options; - final T Function(S) decode; - final S Function(T) encode; final FutureOr Function(T)? onAfterWrite; const SettingSelectionTile({ @@ -175,39 +192,28 @@ class SettingSelectionTile extends StatelessWidget { required this.title, required this.subtitleBuilder, required this.options, - required this.decode, - required this.encode, this.onAfterWrite, }); @override Widget build(BuildContext context) { - final svc = _TileBase._svc; - return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, raw, _) { - final value = decode(raw); - return FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), - subtitle: Text(subtitleBuilder(value)), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), - onTap: () async { - final picked = await showSelectionDialog( - context: context, - title: title, - options: options, - currentValue: value, - ); - if (picked == null) return; - await svc.write(pref, encode(picked)); - final callback = onAfterWrite; - if (callback != null) await callback(picked); - }, - ); - }, + return ValueListenableBuilder( + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, + subtitle: Text(subtitleBuilder(value)), + onTap: () async { + final picked = await showSelectionDialog( + context: context, + title: title, + options: options, + currentValue: value, + ); + if (picked == null) return; + await _writeAndNotify(pref, picked, onAfterWrite); + }, + ), ); } } @@ -233,42 +239,30 @@ class SettingRegexTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, value, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => _SettingRow( + icon: icon, + title: title, subtitle: Text(subtitle), - trailing: const AppIcon(Symbols.chevron_right_rounded, fill: 1), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showRegexInputDialog( context: context, title: title, currentValue: value, defaultValue: defaultValue, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); } } -/// SegmentedSetting bound to a [Pref]. Use [encode]/[decode] when the -/// stored type differs from the segment type (e.g. bool stored, segments -/// over enum). -class SettingSegmentedTile extends StatelessWidget { - final Pref pref; +/// SegmentedSetting bound to a [Pref]. +class SettingSegmentedTile extends StatelessWidget { + final Pref pref; final IconData icon; final String title; final List> segments; - final T Function(S) decode; - final S Function(T) encode; final FutureOr Function(T)? onAfterWrite; const SettingSegmentedTile({ @@ -277,30 +271,20 @@ class SettingSegmentedTile extends StatelessWidget { required this.icon, required this.title, required this.segments, - required this.decode, - required this.encode, this.onAfterWrite, }); @override Widget build(BuildContext context) { - final svc = _TileBase._svc; - return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, raw, _) { - final value = decode(raw); - return SegmentedSetting( - icon: icon, - title: title, - segments: segments, - selected: value, - onChanged: (v) async { - await svc.write(pref, encode(v)); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, - ); - }, + return ValueListenableBuilder( + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, value, _) => SegmentedSetting( + icon: icon, + title: title, + segments: segments, + selected: value, + onChanged: (v) => _writeAndNotify(pref, v, onAfterWrite), + ), ); } } @@ -325,12 +309,11 @@ class SettingColorTile extends StatelessWidget { @override Widget build(BuildContext context) { - final svc = _TileBase._svc; return ValueListenableBuilder( - valueListenable: svc.listenable(pref), - builder: (_, hex, _) => FocusableListTile( - leading: AppIcon(icon, fill: 1), - title: Text(title, style: settingsOptionTitleStyle(context)), + valueListenable: SettingsService.instance.listenable(pref), + builder: (_, hex, _) => _SettingRow( + icon: icon, + title: title, subtitle: subtitle != null ? Text(subtitle!) : null, trailing: Container( width: 28, @@ -341,17 +324,11 @@ class SettingColorTile extends StatelessWidget { border: Border.all(color: Theme.of(context).colorScheme.outlineVariant), ), ), - dense: settingsRowDense(context), - visualDensity: settingsRowVisualDensity(context), onTap: () => showColorInputDialog( context: context, title: title, currentHex: hex, - onSave: (v) async { - await svc.write(pref, v); - final callback = onAfterWrite; - if (callback != null) await callback(v); - }, + onSave: (v) => _writeAndNotify(pref, v, onAfterWrite), ), ), ); diff --git a/lib/widgets/settings_section.dart b/lib/widgets/settings_section.dart index 8d8fd6fc..84dcd37a 100644 --- a/lib/widgets/settings_section.dart +++ b/lib/widgets/settings_section.dart @@ -62,13 +62,6 @@ class SettingsGroup extends StatelessWidget { this.margin = const EdgeInsets.symmetric(horizontal: 16), }); - BorderRadius _radiusFor(int i, MonoTokens t) { - return BorderRadius.vertical( - top: Radius.circular(i == 0 ? t.radiusLg : t.radiusXs), - bottom: Radius.circular(i == children.length - 1 ? t.radiusLg : t.radiusXs), - ); - } - @override Widget build(BuildContext context) { final t = tokens(context); @@ -85,7 +78,7 @@ class SettingsGroup extends StatelessWidget { Material( color: t.surface, clipBehavior: Clip.antiAlias, - shape: RoundedRectangleBorder(borderRadius: _radiusFor(i, t)), + shape: RoundedRectangleBorder(borderRadius: groupItemRadii(context, i, children.length)), child: children[i], ), ], diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 0563db84..163ed362 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -23,6 +23,7 @@ import '../../models/livetv_capture_buffer.dart'; import 'models/track_controls_state.dart'; import 'player_chrome_controller.dart'; import 'widgets/content_strip.dart'; +import 'widgets/content_strip_panel.dart'; import 'widgets/live_timeline_bar.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; @@ -614,51 +615,28 @@ class DesktopVideoControlsState extends State { _buildBottomControlsContent(context, hasFrame: true), // Down arrow hint when strip content is available if (widget.useDpadNavigation && _hasStripContent) - const Positioned( - left: 0, - right: 0, - bottom: 12, - child: AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white24, size: 24), - ), + const ContentStripHint(Symbols.keyboard_arrow_down_rounded), ], ), // Content strip (TV/dpad only) — replaces normal controls if (_contentStripVisible && widget.useDpadNavigation) - Container( + ContentStripPanel( padding: const EdgeInsets.only(left: 8, right: 8, bottom: 8, top: 32), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.65), - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.42, 1.0], - ), - ), - child: Column( - mainAxisSize: .min, - children: [ - const AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white38, size: 20), - const SizedBox(height: 4), - ContentStrip( - key: _contentStripKey, - player: widget.player, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - serverId: widget.serverId, - canControl: _canControl, - showQueueTab: widget.showQueueTab, - onQueueItemSelected: widget.onQueueItemSelected, - onSeekRequested: widget.onSeekRequested, - onSeekCompleted: widget.onSeekCompleted, - useFocusNavigation: true, - onNavigateUp: _onContentStripNavigateUp, - onFocusActivity: widget.onFocusActivity, - ), - ], + chevron: Symbols.keyboard_arrow_up_rounded, + child: ContentStrip( + key: _contentStripKey, + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + serverId: widget.serverId, + canControl: _canControl, + showQueueTab: widget.showQueueTab, + onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, + onSeekCompleted: widget.onSeekCompleted, + useFocusNavigation: true, + onNavigateUp: _onContentStripNavigateUp, + onFocusActivity: widget.onFocusActivity, ), ), ], diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 99ea3da5..32df0e97 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -1,7 +1,6 @@ import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; -import '../../widgets/app_icon.dart'; import '../../media/media_item.dart'; import '../../mpv/mpv.dart'; import '../../models/livetv_capture_buffer.dart'; @@ -12,6 +11,7 @@ import '../../i18n/strings.g.dart'; import 'player_chrome_controller.dart'; import 'widgets/circular_control_button.dart'; import 'widgets/content_strip.dart'; +import 'widgets/content_strip_panel.dart'; import 'widgets/first_frame_guard.dart'; import 'widgets/play_pause_stream_builder.dart'; import 'widgets/live_timeline_bar.dart'; @@ -270,12 +270,7 @@ class _MobileVideoControlsState extends State with SingleTi _buildBottomBar(context), ], ), - const Positioned( - left: 0, - right: 0, - bottom: 12, - child: AppIcon(Symbols.keyboard_arrow_up_rounded, color: Colors.white24, size: 24), - ), + const ContentStripHint(Symbols.keyboard_arrow_up_rounded), ], ), ), @@ -290,37 +285,19 @@ class _MobileVideoControlsState extends State with SingleTi ignoring: t < 0.5, child: Opacity( opacity: (t * 2).clamp(0.0, 1.0), - child: Container( + child: ContentStripPanel( padding: const EdgeInsets.only(top: 32), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.65), - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.42, 1.0], - ), - ), - child: Column( - mainAxisSize: .min, - children: [ - const AppIcon(Symbols.keyboard_arrow_down_rounded, color: Colors.white38, size: 20), - const SizedBox(height: 4), - ContentStrip( - player: widget.player, - chapters: widget.chapters, - chaptersLoaded: widget.chaptersLoaded, - canControl: widget.canControl, - serverId: widget.serverId, - showQueueTab: widget.showQueueTab, - onQueueItemSelected: widget.onQueueItemSelected, - onSeekRequested: widget.onSeekRequested, - onSeekCompleted: widget.onSeekCompleted, - ), - ], + chevron: Symbols.keyboard_arrow_down_rounded, + child: ContentStrip( + player: widget.player, + chapters: widget.chapters, + chaptersLoaded: widget.chaptersLoaded, + canControl: widget.canControl, + serverId: widget.serverId, + showQueueTab: widget.showQueueTab, + onQueueItemSelected: widget.onQueueItemSelected, + onSeekRequested: widget.onSeekRequested, + onSeekCompleted: widget.onSeekCompleted, ), ), ), diff --git a/lib/widgets/video_controls/models/track_controls_state.dart b/lib/widgets/video_controls/models/track_controls_state.dart index 57e3a49e..5f55a247 100644 --- a/lib/widgets/video_controls/models/track_controls_state.dart +++ b/lib/widgets/video_controls/models/track_controls_state.dart @@ -34,7 +34,6 @@ class TrackControlsState { final int audioSyncOffset; final int subtitleSyncOffset; final bool isRotationLocked; - final bool isScreenLocked; final bool isFullscreen; final bool isAlwaysOnTop; final VoidCallback? onTogglePIPMode; @@ -96,7 +95,6 @@ class TrackControlsState { this.audioSyncOffset = 0, this.subtitleSyncOffset = 0, this.isRotationLocked = false, - this.isScreenLocked = false, this.isFullscreen = false, this.isAlwaysOnTop = false, this.onTogglePIPMode, diff --git a/lib/widgets/video_controls/parts/key_events.dart b/lib/widgets/video_controls/parts/key_events.dart index 0c2bb81b..f366dde8 100644 --- a/lib/widgets/video_controls/parts/key_events.dart +++ b/lib/widgets/video_controls/parts/key_events.dart @@ -100,6 +100,37 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return KeyEventResult.ignored; } + KeyEventResult _dispatchShortcut(KeyEvent event, {VoidCallback? onSkipMarker}) { + return _keyboardService!.handleVideoPlayerKeyEvent( + event, + widget.player, + _toggleFullscreen, + _toggleSubtitles, + _nextAudioTrack, + _nextSubtitleTrack, + _nextChapter, + _previousChapter, + canControlPlayback: widget.canControl, + canNavigateMediaItems: widget.canNavigateMediaItems, + onPlayPause: () => unawaited(_playOrPause()), + onToggleShader: _toggleShader, + onSkipMarker: onSkipMarker, + onNextEpisode: widget.onNext, + onPreviousEpisode: widget.onPrevious, + onScreenshot: _showScreenshotToast, + onZoomIn: widget.onZoomIn, + onZoomOut: widget.onZoomOut, + onZoomReset: widget.onResetVideoZoom, + onVolumeUp: () => widget.volumeController.adjust(10), + onVolumeDown: () => widget.volumeController.adjust(-10), + onToggleMute: widget.volumeController.toggleMute, + currentPositionEpoch: widget.currentPositionEpoch, + onLiveSeek: widget.onLiveSeek, + onLiveSeekBy: widget.onLiveSeekBy, + onSeekRequested: widget.onSeekRequested, + ); + } + /// Global key event handler for focus-independent shortcuts (desktop only) bool _handleGlobalKeyEvent(KeyEvent event) { if (!mounted) return false; @@ -140,33 +171,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { // (e.g. after controls auto-hide). The !hasFocus guard prevents // double-handling when the Focus onKeyEvent already processes the event. if (!_focusNode.hasFocus && _keyboardService != null) { - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - canControlPlayback: widget.canControl, - canNavigateMediaItems: widget.canNavigateMediaItems, - onPlayPause: () => unawaited(_playOrPause()), - onToggleShader: _toggleShader, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - onScreenshot: _showScreenshotToast, - onZoomIn: widget.onZoomIn, - onZoomOut: widget.onZoomOut, - onZoomReset: widget.onResetVideoZoom, - onVolumeUp: () => widget.volumeController.adjust(10), - onVolumeDown: () => widget.volumeController.adjust(-10), - onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - onLiveSeekBy: widget.onLiveSeekBy, - onSeekRequested: widget.onSeekRequested, - ); + final result = _dispatchShortcut(event); if (result == KeyEventResult.handled) { _focusNode.requestFocus(); // self-heal focus return true; @@ -270,34 +275,7 @@ extension _PlexVideoControlsKeyEventMethods on _PlexVideoControlsState { return event.logicalKey.isNavigationKey ? KeyEventResult.handled : KeyEventResult.ignored; } - final result = _keyboardService!.handleVideoPlayerKeyEvent( - event, - widget.player, - _toggleFullscreen, - _toggleSubtitles, - _nextAudioTrack, - _nextSubtitleTrack, - _nextChapter, - _previousChapter, - canControlPlayback: widget.canControl, - canNavigateMediaItems: widget.canNavigateMediaItems, - onPlayPause: () => unawaited(_playOrPause()), - onToggleShader: _toggleShader, - onSkipMarker: _performAutoSkip, - onNextEpisode: widget.onNext, - onPreviousEpisode: widget.onPrevious, - onScreenshot: _showScreenshotToast, - onZoomIn: widget.onZoomIn, - onZoomOut: widget.onZoomOut, - onZoomReset: widget.onResetVideoZoom, - onVolumeUp: () => widget.volumeController.adjust(10), - onVolumeDown: () => widget.volumeController.adjust(-10), - onToggleMute: widget.volumeController.toggleMute, - currentPositionEpoch: widget.currentPositionEpoch, - onLiveSeek: widget.onLiveSeek, - onLiveSeekBy: widget.onLiveSeekBy, - onSeekRequested: widget.onSeekRequested, - ); + final result = _dispatchShortcut(event, onSkipMarker: _performAutoSkip); if (!event.logicalKey.isNavigationKey) return result; // Never return .ignored for navigation keys — prevent leaking to previous routes. return result == KeyEventResult.ignored ? KeyEventResult.handled : result; diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index d117162d..e25faf2f 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -142,7 +142,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { audioSyncOffset: _audioSyncOffset, subtitleSyncOffset: _subtitleSyncOffset, isRotationLocked: _isRotationLocked, - isScreenLocked: _isScreenLocked, isFullscreen: _isFullscreen, isAlwaysOnTop: _isAlwaysOnTop, onTogglePIPMode: (_isPipSupported && !PlatformDetector.isTV()) ? widget.onTogglePIPMode : null, diff --git a/lib/widgets/video_controls/sheets/sheet_selection_column.dart b/lib/widgets/video_controls/sheets/sheet_selection_column.dart new file mode 100644 index 00000000..64edc8cf --- /dev/null +++ b/lib/widgets/video_controls/sheets/sheet_selection_column.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../../../utils/scroll_utils.dart'; +import '../../../widgets/overlay_sheet.dart'; +import 'sheet_column_header.dart'; + +/// Per-row handle handed to [SheetSelectionColumn.itemBuilder]. +abstract class SheetSelectionColumnScope { + /// Key for the row at [index]. Only the first row is keyed, so the one-time + /// initial scroll can measure a real item height. + Key? keyFor(int index); + + /// Runs an async selection: re-entrant taps are ignored while one is in + /// flight, a progress bar is shown meanwhile, and the sheet is closed once + /// [action] completes. + void runExclusive(Future Function() action); +} + +/// Shared scaffold for the selectable columns inside the video control sheets: +/// an optional header, a one-shot scroll to the selected row, the async +/// selection guard, the scrolling list, and an optional footer. +class SheetSelectionColumn extends StatefulWidget { + /// Header text, or null to omit the header entirely. + final String? headerLabel; + final int itemCount; + + /// Row to scroll into view on first build; ignored when null or <= 0. + final int? initialIndex; + final Widget Function(BuildContext context, int index, SheetSelectionColumnScope scope) itemBuilder; + final List footer; + + const SheetSelectionColumn({ + super.key, + this.headerLabel, + required this.itemCount, + required this.initialIndex, + required this.itemBuilder, + this.footer = const [], + }); + + @override + State createState() => _SheetSelectionColumnState(); +} + +class _SheetSelectionColumnState extends State implements SheetSelectionColumnScope { + final _initialScroll = InitialItemScrollController(); + bool _selectionPending = false; + + @override + void dispose() { + _initialScroll.dispose(); + super.dispose(); + } + + @override + Key? keyFor(int index) => index == 0 ? _initialScroll.firstItemKey : null; + + @override + void runExclusive(Future Function() action) => unawaited(_select(action)); + + Future _select(Future Function() action) async { + if (_selectionPending) return; + setState(() => _selectionPending = true); + try { + await action(); + if (mounted) OverlaySheetController.of(context).close(); + } finally { + if (mounted) setState(() => _selectionPending = false); + } + } + + @override + Widget build(BuildContext context) { + _initialScroll.maybeScrollTo(widget.initialIndex); + + return Column( + children: [ + if (widget.headerLabel != null) SheetColumnHeader(label: widget.headerLabel!), + if (_selectionPending) const LinearProgressIndicator(minHeight: 2), + Expanded( + child: ListView.builder( + controller: _initialScroll.controller, + itemCount: widget.itemCount, + itemBuilder: (context, index) => widget.itemBuilder(context, index, this), + ), + ), + ...widget.footer, + ], + ); + } +} diff --git a/lib/widgets/video_controls/sheets/track_sheet.dart b/lib/widgets/video_controls/sheets/track_sheet.dart index b43a4f0f..56af4738 100644 --- a/lib/widgets/video_controls/sheets/track_sheet.dart +++ b/lib/widgets/video_controls/sheets/track_sheet.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; @@ -7,13 +5,12 @@ import '../../../media/media_source_info.dart'; import '../../../mpv/mpv.dart'; import '../../../services/playback_subtitle_resolver.dart'; import '../../../i18n/strings.g.dart'; -import '../../../utils/scroll_utils.dart'; import '../../../utils/track_label_builder.dart'; import '../../../widgets/app_icon.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; import 'base_video_control_sheet.dart'; -import 'sheet_column_header.dart'; +import 'sheet_selection_column.dart'; import 'subtitle_search_sheet.dart'; import '../models/track_controls_state.dart'; import '../helpers/track_filter_helper.dart'; @@ -134,7 +131,7 @@ class TrackSheet extends StatelessWidget { } } -class _SourceAudioColumn extends StatefulWidget { +class _SourceAudioColumn extends StatelessWidget { final List tracks; final int? selectedStreamId; final Future Function(int) onSelected; @@ -147,157 +144,94 @@ class _SourceAudioColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_SourceAudioColumn> createState() => _SourceAudioColumnState(); -} - -class _SourceAudioColumnState extends State<_SourceAudioColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _select(int streamId) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.onSelected(streamId); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { final selectedId = _effectiveSelectedStreamId(); - final selectedIndex = selectedId == null ? null : widget.tracks.indexWhere((t) => t.id == selectedId); - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = selectedId == null ? null : tracks.indexWhere((t) => t.id == selectedId); - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length, - itemBuilder: (context, index) { - final track = widget.tracks[index]; - final isSelected = track.id == selectedId; - return TrackSelectionHelper.buildTrackTile( - context: context, - key: index == 0 ? _initialScroll.firstItemKey : null, - label: track.label, - isSelected: isSelected, - onTap: () => unawaited(_select(track.id)), - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.audioLabel : null, + itemCount: tracks.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final track = tracks[index]; + return TrackSelectionHelper.buildTrackTile( + context: context, + key: scope.keyFor(index), + label: track.label, + isSelected: track.id == selectedId, + onTap: () => scope.runExclusive(() => onSelected(track.id)), + ); + }, ); } int? _effectiveSelectedStreamId() { - final explicit = widget.selectedStreamId; - if (explicit != null && widget.tracks.any((track) => track.id == explicit)) return explicit; - for (final track in widget.tracks) { + final explicit = selectedStreamId; + if (explicit != null && tracks.any((track) => track.id == explicit)) return explicit; + for (final track in tracks) { if (track.selected) return track.id; } return null; } } -class _SourceSubtitleColumn extends StatefulWidget { +class _SourceSubtitleColumn extends StatelessWidget { final List tracks; final TrackControlsState trackControlsState; final bool showHeader; const _SourceSubtitleColumn({required this.tracks, required this.trackControlsState, required this.showHeader}); - @override - State<_SourceSubtitleColumn> createState() => _SourceSubtitleColumnState(); -} - -class _SourceSubtitleColumnState extends State<_SourceSubtitleColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _select(PlaybackSourceSubtitleChoice choice) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.trackControlsState.onSwitchSubtitle!(choice); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { final selectedChoice = _effectiveSelectedChoice(); final selectedId = selectedChoice.sourceStreamId; - final selectedIndex = selectedChoice.isOff ? 0 : widget.tracks.indexWhere((t) => t.id == selectedId) + 1; - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = selectedChoice.isOff ? 0 : tracks.indexWhere((t) => t.id == selectedId) + 1; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length + 1, - itemBuilder: (context, index) { - if (index == 0) { - return TrackSelectionHelper.buildOffTile( - context: context, - key: _initialScroll.firstItemKey, - isSelected: selectedChoice.isOff, - onTap: () => unawaited(_select(const PlaybackSourceSubtitleChoice.off())), - ); - } + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.subtitlesLabel : null, + itemCount: tracks.length + 1, + initialIndex: selectedIndex, + footer: _buildSubtitleSearchFooter(context, trackControlsState), + itemBuilder: (context, index, scope) { + if (index == 0) { + return TrackSelectionHelper.buildOffTile( + context: context, + key: scope.keyFor(index), + isSelected: selectedChoice.isOff, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(const PlaybackSourceSubtitleChoice.off()), + ), + ); + } - final track = widget.tracks[index - 1]; - return TrackSelectionHelper.buildTrackTile( - context: context, - label: track.labelForIndex(index - 1), - isSelected: track.id == selectedId, - onTap: () => unawaited(_select(PlaybackSourceSubtitleChoice.source(track.id))), - ); - }, + final track = tracks[index - 1]; + return TrackSelectionHelper.buildTrackTile( + context: context, + label: track.labelForIndex(index - 1), + isSelected: track.id == selectedId, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(track.id)), ), - ), - ..._buildSubtitleSearchFooter(context, widget.trackControlsState), - ], + ); + }, ); } PlaybackSourceSubtitleChoice _effectiveSelectedChoice() { - final explicit = widget.trackControlsState.selectedSubtitleChoice; - if (explicit != null && (explicit.isOff || widget.tracks.any((track) => track.id == explicit.sourceStreamId))) { + final explicit = trackControlsState.selectedSubtitleChoice; + if (explicit != null && (explicit.isOff || tracks.any((track) => track.id == explicit.sourceStreamId))) { return explicit; } - for (final track in widget.tracks) { + for (final track in tracks) { if (track.selected) return PlaybackSourceSubtitleChoice.source(track.id); } return const PlaybackSourceSubtitleChoice.off(); } } -class _AudioColumn extends StatefulWidget { +class _AudioColumn extends StatelessWidget { final List tracks; final TrackSelection selection; final Player player; @@ -312,61 +246,41 @@ class _AudioColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_AudioColumn> createState() => _AudioColumnState(); -} - -class _AudioColumnState extends State<_AudioColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final selectedId = widget.selection.audio?.id ?? ''; - final selectedIndex = widget.tracks.indexWhere((t) => t.id == selectedId); - _initialScroll.maybeScrollTo(selectedIndex); + final selectedId = selection.audio?.id ?? ''; + final selectedIndex = tracks.indexWhere((t) => t.id == selectedId); - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.audioLabel), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.tracks.length, - itemBuilder: (context, index) { - final track = widget.tracks[index]; - final label = TrackLabelBuilder.audioLabel( - title: track.title, - language: track.language, - codec: track.codec, - channels: track.channelsCount, - index: index, - ); - return TrackSelectionHelper.buildTrackTile( - context: context, - key: index == 0 ? _initialScroll.firstItemKey : null, - label: label, - isSelected: track.id == selectedId, - onTap: () { - widget.player.selectAudioTrack(track); - widget.onTrackChanged?.call(track); - OverlaySheetController.of(context).close(); - }, - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.audioLabel : null, + itemCount: tracks.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final track = tracks[index]; + final label = TrackLabelBuilder.audioLabel( + title: track.title, + language: track.language, + codec: track.codec, + channels: track.channelsCount, + index: index, + ); + return TrackSelectionHelper.buildTrackTile( + context: context, + key: scope.keyFor(index), + label: label, + isSelected: track.id == selectedId, + onTap: () { + player.selectAudioTrack(track); + onTrackChanged?.call(track); + OverlaySheetController.of(context).close(); + }, + ); + }, ); } } -class _SubtitleColumn extends StatefulWidget { +class _SubtitleColumn extends StatelessWidget { final List tracks; final TrackSelection selection; final Player player; @@ -385,166 +299,135 @@ class _SubtitleColumn extends StatefulWidget { this.sourceSidecars = const [], }); - @override - State<_SubtitleColumn> createState() => _SubtitleColumnState(); -} - -class _SubtitleColumnState extends State<_SubtitleColumn> { - final _initialScroll = InitialItemScrollController(); - bool _selectionPending = false; - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - - Future _selectSourceSidecar(int streamId) async { - if (_selectionPending) return; - setState(() => _selectionPending = true); - try { - await widget.trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(streamId)); - if (mounted) OverlaySheetController.of(context).close(); - } finally { - if (mounted) setState(() => _selectionPending = false); - } - } - @override Widget build(BuildContext context) { - final selectedSub = widget.selection.subtitle; - final secondarySub = widget.selection.secondarySubtitle; + final selectedSub = selection.subtitle; + final secondarySub = selection.secondarySubtitle; final isOffSelected = selectedSub == null || selectedSub.id == 'no'; - final hasSecondary = widget.supportsSecondary && secondarySub != null; - final selectedSourceId = widget.trackControlsState.selectedSubtitleChoice?.sourceStreamId; - final selectedSecondarySourceId = widget.trackControlsState.selectedSecondarySubtitleStreamId; - final unloadedSourceSidecars = widget.sourceSidecars + final hasSecondary = supportsSecondary && secondarySub != null; + final selectedSourceId = trackControlsState.selectedSubtitleChoice?.sourceStreamId; + final selectedSecondarySourceId = trackControlsState.selectedSecondarySubtitleStreamId; + final unloadedSourceSidecars = sourceSidecars .where((track) => track.id != selectedSourceId && track.id != selectedSecondarySourceId) .toList(growable: false); // +1 for "Off" row. The selected direct-play sidecar is already present // in [tracks], so only the other server sidecars are appended. - final itemCount = widget.tracks.length + unloadedSourceSidecars.length + 1; + final itemCount = tracks.length + unloadedSourceSidecars.length + 1; - final selectedIndex = isOffSelected ? null : widget.tracks.indexWhere((t) => t.id == selectedSub.id) + 1; - _initialScroll.maybeScrollTo(selectedIndex); + final selectedIndex = isOffSelected ? null : tracks.indexWhere((t) => t.id == selectedSub.id) + 1; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.subtitlesLabel), - if (_selectionPending) const LinearProgressIndicator(minHeight: 2), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: itemCount, - itemBuilder: (context, index) { - if (index == 0) { - return TrackSelectionHelper.buildOffTile( - context: context, - key: _initialScroll.firstItemKey, - isSelected: isOffSelected, - onTap: () { - // Turning off primary also clears secondary - if (hasSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - widget.player.selectSubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off); - OverlaySheetController.of(context).close(); - }, - onLongPress: widget.supportsSecondary && hasSecondary - ? () { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - : null, - onSecondaryTap: widget.supportsSecondary && hasSecondary - ? () { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - : null, - ); + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.subtitlesLabel : null, + itemCount: itemCount, + initialIndex: selectedIndex, + footer: _buildSubtitleSearchFooter(context, trackControlsState), + itemBuilder: (context, index, scope) { + if (index == 0) { + return TrackSelectionHelper.buildOffTile( + context: context, + key: scope.keyFor(index), + isSelected: isOffSelected, + onTap: () { + // Turning off primary also clears secondary + if (hasSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); } - - final trackIndex = index - 1; - if (trackIndex >= widget.tracks.length) { - final sourceIndex = trackIndex - widget.tracks.length; - final sourceTrack = unloadedSourceSidecars[sourceIndex]; - return TrackSelectionHelper.buildTrackTile( - context: context, - label: sourceTrack.labelForIndex(trackIndex), - isSelected: false, - onTap: () => unawaited(_selectSourceSidecar(sourceTrack.id)), - ); - } - - final track = widget.tracks[trackIndex]; - final isPrimary = !isOffSelected && track.id == selectedSub.id; - final isSecondary = hasSecondary && track.id == secondarySub.id; - final label = TrackLabelBuilder.subtitleLabel( - title: track.title, - language: track.language, - codec: track.codec, - forced: track.isForced, - index: trackIndex, - ); - - Widget? badge; - if (widget.supportsSecondary && hasSecondary) { - if (isPrimary) { - badge = TrackSelectionHelper.buildTrackBadge(context, 1); - } else if (isSecondary) { - badge = TrackSelectionHelper.buildTrackBadge(context, 2); - } - } - - return TrackSelectionHelper.buildTrackTile( - context: context, - label: label, - isSelected: isPrimary, - badge: badge, - onTap: () { - // If tapping a track that is currently the secondary, clear secondary first - if (isSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } - widget.player.selectSubtitleTrack(track); - widget.trackControlsState.onSubtitleTrackChanged?.call(track); - OverlaySheetController.of(context).close(); - }, - onLongPress: widget.supportsSecondary - ? () { - if (isSecondary) { - // Already secondary — clear it - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } else if (!isPrimary) { - // Set as secondary (don't close sheet so user sees badge update) - widget.player.selectSecondarySubtitleTrack(track); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); - } - } - : null, - onSecondaryTap: widget.supportsSecondary - ? () { - if (isSecondary) { - widget.player.selectSecondarySubtitleTrack(SubtitleTrack.off); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); - } else if (!isPrimary) { - widget.player.selectSecondarySubtitleTrack(track); - widget.trackControlsState.onSecondarySubtitleTrackChanged?.call(track); - } - } - : null, - ); + player.selectSubtitleTrack(SubtitleTrack.off); + trackControlsState.onSubtitleTrackChanged?.call(SubtitleTrack.off); + OverlaySheetController.of(context).close(); }, - ), - ), - ..._buildSubtitleSearchFooter(context, widget.trackControlsState), - ], + onLongPress: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + : null, + onSecondaryTap: supportsSecondary && hasSecondary + ? () { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + : null, + ); + } + + final trackIndex = index - 1; + if (trackIndex >= tracks.length) { + final sourceIndex = trackIndex - tracks.length; + final sourceTrack = unloadedSourceSidecars[sourceIndex]; + return TrackSelectionHelper.buildTrackTile( + context: context, + label: sourceTrack.labelForIndex(trackIndex), + isSelected: false, + onTap: () => scope.runExclusive( + () => trackControlsState.onSwitchSubtitle!(PlaybackSourceSubtitleChoice.source(sourceTrack.id)), + ), + ); + } + + final track = tracks[trackIndex]; + final isPrimary = !isOffSelected && track.id == selectedSub.id; + final isSecondary = hasSecondary && track.id == secondarySub.id; + final label = TrackLabelBuilder.subtitleLabel( + title: track.title, + language: track.language, + codec: track.codec, + forced: track.isForced, + index: trackIndex, + ); + + Widget? badge; + if (supportsSecondary && hasSecondary) { + if (isPrimary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 1); + } else if (isSecondary) { + badge = TrackSelectionHelper.buildTrackBadge(context, 2); + } + } + + return TrackSelectionHelper.buildTrackTile( + context: context, + label: label, + isSelected: isPrimary, + badge: badge, + onTap: () { + // If tapping a track that is currently the secondary, clear secondary first + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } + player.selectSubtitleTrack(track); + trackControlsState.onSubtitleTrackChanged?.call(track); + OverlaySheetController.of(context).close(); + }, + onLongPress: supportsSecondary + ? () { + if (isSecondary) { + // Already secondary — clear it + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + // Set as secondary (don't close sheet so user sees badge update) + player.selectSecondarySubtitleTrack(track); + trackControlsState.onSecondarySubtitleTrackChanged?.call(track); + } + } + : null, + onSecondaryTap: supportsSecondary + ? () { + if (isSecondary) { + player.selectSecondarySubtitleTrack(SubtitleTrack.off); + trackControlsState.onSecondarySubtitleTrackChanged?.call(SubtitleTrack.off); + } else if (!isPrimary) { + player.selectSecondarySubtitleTrack(track); + trackControlsState.onSecondarySubtitleTrackChanged?.call(track); + } + } + : null, + ); + }, ); } } diff --git a/lib/widgets/video_controls/sheets/version_quality_sheet.dart b/lib/widgets/video_controls/sheets/version_quality_sheet.dart index e3b4ad1e..ef978e3d 100644 --- a/lib/widgets/video_controls/sheets/version_quality_sheet.dart +++ b/lib/widgets/video_controls/sheets/version_quality_sheet.dart @@ -6,10 +6,9 @@ import '../../../i18n/strings.g.dart'; import '../../../media/media_version.dart'; import '../../../models/transcode_quality_preset.dart'; import '../../../utils/quality_preset_labels.dart'; -import '../../../utils/scroll_utils.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; -import 'sheet_column_header.dart'; +import 'sheet_selection_column.dart'; String versionQualityPickerTitle({required bool showVersions, required bool showQuality}) { return showQuality @@ -114,7 +113,7 @@ class VersionQualityPicker extends StatelessWidget { } } -class _VersionColumn extends StatefulWidget { +class _VersionColumn extends StatelessWidget { final List versions; final int selectedIndex; final ValueChanged onSelected; @@ -127,48 +126,27 @@ class _VersionColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_VersionColumn> createState() => _VersionColumnState(); -} - -class _VersionColumnState extends State<_VersionColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { - _initialScroll.maybeScrollTo(widget.selectedIndex); - - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.versionColumnHeader), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: widget.versions.length, - itemBuilder: (context, index) { - final version = widget.versions[index]; - final isSelected = index == widget.selectedIndex; - return _SelectionTile( - key: index == 0 ? _initialScroll.firstItemKey : null, - label: version.displayLabel, - isSelected: isSelected, - onTap: () => widget.onSelected(index), - ); - }, - ), - ), - ], + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.versionColumnHeader : null, + itemCount: versions.length, + initialIndex: selectedIndex, + itemBuilder: (context, index, scope) { + final version = versions[index]; + final isSelected = index == selectedIndex; + return _SelectionTile( + key: scope.keyFor(index), + label: version.displayLabel, + isSelected: isSelected, + onTap: () => onSelected(index), + ); + }, ); } } -class _QualityColumn extends StatefulWidget { +class _QualityColumn extends StatelessWidget { final TranscodeQualityPreset selected; final bool enabledForTranscoding; final int? sourceBitrateKbps; @@ -187,58 +165,36 @@ class _QualityColumn extends StatefulWidget { required this.showHeader, }); - @override - State<_QualityColumn> createState() => _QualityColumnState(); -} - -class _QualityColumnState extends State<_QualityColumn> { - final _initialScroll = InitialItemScrollController(); - - @override - void dispose() { - _initialScroll.dispose(); - super.dispose(); - } - @override Widget build(BuildContext context) { final presets = TranscodeQualityPreset.displayOrder; - final selectedIndex = presets.indexOf(widget.selected); - _initialScroll.maybeScrollTo(selectedIndex); + return SheetSelectionColumn( + headerLabel: showHeader ? t.videoControls.qualityColumnHeader : null, + itemCount: presets.length, + initialIndex: presets.indexOf(selected), + itemBuilder: (context, index, scope) { + final preset = presets[index]; + final isSelected = preset == selected; + final isOriginal = preset.isOriginal; + final enabled = isOriginal || enabledForTranscoding; - return Column( - children: [ - if (widget.showHeader) SheetColumnHeader(label: t.videoControls.qualityColumnHeader), - Expanded( - child: ListView.builder( - controller: _initialScroll.controller, - itemCount: presets.length, - itemBuilder: (context, index) { - final preset = presets[index]; - final isSelected = preset == widget.selected; - final isOriginal = preset.isOriginal; - final enabled = isOriginal || widget.enabledForTranscoding; + final trailing = qualityPresetSizeEstimate( + preset: preset, + sourceBitrateKbps: sourceBitrateKbps, + sourceDurationMs: sourceDurationMs, + sourceSizeBytes: sourceSizeBytes, + ); - final trailing = qualityPresetSizeEstimate( - preset: preset, - sourceBitrateKbps: widget.sourceBitrateKbps, - sourceDurationMs: widget.sourceDurationMs, - sourceSizeBytes: widget.sourceSizeBytes, - ); - - return _SelectionTile( - key: index == 0 ? _initialScroll.firstItemKey : null, - label: qualityPresetLabel(preset), - trailingText: trailing, - isSelected: isSelected, - enabled: enabled, - onTap: enabled ? () => widget.onSelected(preset) : null, - ); - }, - ), - ), - ], + return _SelectionTile( + key: scope.keyFor(index), + label: qualityPresetLabel(preset), + trailingText: trailing, + isSelected: isSelected, + enabled: enabled, + onTap: enabled ? () => onSelected(preset) : null, + ); + }, ); } } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 3c6d3f41..49bb89a4 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -12,13 +12,10 @@ import 'package:path/path.dart' as path; import 'package:provider/provider.dart'; import '../../../models/shader_preset.dart'; -import '../../../models/transcode_quality_preset.dart'; -import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../providers/shader_provider.dart'; import '../../../services/file_picker_service.dart'; import '../../../services/settings_service.dart'; -import '../../../services/shader_service.dart'; import '../../../services/sleep_timer_service.dart'; import '../../../services/video_filter_manager.dart'; import '../../../focus/focusable_wrapper.dart'; @@ -32,6 +29,7 @@ import '../../../utils/snackbar_helper.dart'; import '../../../theme/mono_tokens.dart'; import '../../../widgets/focusable_list_tile.dart'; import '../../../widgets/overlay_sheet.dart'; +import '../models/track_controls_state.dart'; import '../widgets/sync_offset_control.dart'; import '../widgets/sleep_timer_content.dart'; import '../../../i18n/strings.g.dart'; @@ -190,73 +188,17 @@ class VideoSettingsSheet extends StatefulWidget { /// Defaults to the native platform capability, but can be supplied by /// embedders whose capability is known independently of the host platform. final bool? supportsHdrControl; - final int audioSyncOffset; - final int subtitleSyncOffset; - final double videoZoomScale; - final ValueChanged? onVideoZoomChanged; - final VoidCallback? onResetVideoZoom; - /// Whether the user can control playback (false hides speed option in host-only mode). - final bool canControl; - - /// Whether this is a live TV stream (hides speed settings). - final bool isLive; - - /// Available media versions and quality controls shown inside playback settings. - final List availableVersions; - final int selectedMediaIndex; - final TranscodeQualityPreset selectedQualityPreset; - final bool serverSupportsTranscoding; - final int? sourceDurationMs; - final ValueChanged? onVersionSelected; - final ValueChanged? onQualitySelected; - - /// Optional shader service for MPV shader control - final ShaderService? shaderService; - - /// Called when shader preset changes - final VoidCallback? onShaderChanged; - - /// Whether ambient lighting is currently enabled - final bool isAmbientLightingEnabled; - - /// Called to toggle ambient lighting on/off (null if unsupported) - final VoidCallback? onToggleAmbientLighting; - - /// Called to cancel the video controls auto-hide timer. - final VoidCallback? onCancelAutoHide; - - /// Called to restart the video controls auto-hide timer. - final VoidCallback? onStartAutoHide; - - /// Called when a sync offset changes (so the parent can update its state). - final void Function(String propertyName, int offset)? onSyncOffsetChanged; + /// Shared player-control state. Every playback value and callback this sheet + /// shows (sync offsets, zoom, versions/quality, shaders, ambient lighting, + /// auto-hide) is read straight off it. + final TrackControlsState trackControlsState; const VideoSettingsSheet({ super.key, required this.player, this.supportsHdrControl, - required this.audioSyncOffset, - required this.subtitleSyncOffset, - this.videoZoomScale = 1.0, - this.onVideoZoomChanged, - this.onResetVideoZoom, - this.canControl = true, - this.isLive = false, - this.availableVersions = const [], - this.selectedMediaIndex = 0, - this.selectedQualityPreset = TranscodeQualityPreset.original, - this.serverSupportsTranscoding = false, - this.sourceDurationMs, - this.onVersionSelected, - this.onQualitySelected, - this.shaderService, - this.onShaderChanged, - this.isAmbientLightingEnabled = false, - this.onToggleAmbientLighting, - this.onCancelAutoHide, - this.onStartAutoHide, - this.onSyncOffsetChanged, + required this.trackControlsState, }); @override @@ -271,6 +213,8 @@ class _VideoSettingsSheetState extends State { String _dvConversionMode = 'auto'; int _dvConversionWriteGeneration = 0; + TrackControlsState get _state => widget.trackControlsState; + bool get _supportsHdrControl => widget.supportsHdrControl ?? (Platform.isIOS || Platform.isMacOS || Platform.isWindows); @@ -283,16 +227,16 @@ class _VideoSettingsSheetState extends State { @override void initState() { super.initState(); - _audioSyncOffset = widget.audioSyncOffset; - _subtitleSyncOffset = widget.subtitleSyncOffset; - _zoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale); + _audioSyncOffset = _state.audioSyncOffset; + _subtitleSyncOffset = _state.subtitleSyncOffset; + _zoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale); _loadDebugDvConversionMode(); } @override void didUpdateWidget(covariant VideoSettingsSheet oldWidget) { super.didUpdateWidget(oldWidget); - final nextZoomScale = VideoFilterManager.normalizeZoomScale(widget.videoZoomScale); + final nextZoomScale = VideoFilterManager.normalizeZoomScale(_state.videoZoomScale); if (_zoomScale != nextZoomScale) { _zoomScale = nextZoomScale; } @@ -372,19 +316,19 @@ class _VideoSettingsSheetState extends State { } else { await settings.write(SettingsService.audioSyncOffset, offset); } - widget.onSyncOffsetChanged?.call(propertyName, offset); + _state.onSyncOffsetChanged?.call(propertyName, offset); }, ), ) .whenComplete(() { sliderFocusNode.dispose(); - widget.onStartAutoHide?.call(); + _state.onStartAutoHide?.call(); }); // Cancel auto-hide after show() — the previous sheet's whenComplete // fires as a microtask and restarts the timer, so schedule our cancel // to run after that microtask. - Future.microtask(() => widget.onCancelAutoHide?.call()); + Future.microtask(() => _state.onCancelAutoHide?.call()); } void _navigateBack() { @@ -476,44 +420,44 @@ class _VideoSettingsSheetState extends State { setState(() { _zoomScale = next; }); - widget.onVideoZoomChanged?.call(next); + _state.onVideoZoomChanged?.call(next); } void _resetZoomScale() { setState(() { _zoomScale = 1.0; }); - final reset = widget.onResetVideoZoom; + final reset = _state.onResetVideoZoom; if (reset != null) { reset(); } else { - widget.onVideoZoomChanged?.call(1.0); + _state.onVideoZoomChanged?.call(1.0); } } bool get _hasVersionQuality { - return (widget.availableVersions.length > 1 || widget.serverSupportsTranscoding) && - (widget.onVersionSelected != null || widget.onQualitySelected != null); + return (_state.availableVersions.length > 1 || _state.serverSupportsTranscoding) && + (_state.onSwitchVersion != null || _state.onSwitchQualityPreset != null); } String _versionQualityTitle() { return versionQualityPickerTitle( - showVersions: widget.availableVersions.length > 1, - showQuality: widget.serverSupportsTranscoding, + showVersions: _state.availableVersions.length > 1, + showQuality: _state.serverSupportsTranscoding, ); } String _versionQualityValueText() { final values = []; - if (widget.availableVersions.length > 1) values.add(_selectedVersionLabel()); - if (widget.serverSupportsTranscoding) values.add(qualityPresetLabel(widget.selectedQualityPreset)); + if (_state.availableVersions.length > 1) values.add(_selectedVersionLabel()); + if (_state.serverSupportsTranscoding) values.add(qualityPresetLabel(_state.selectedQualityPreset)); return values.join(' / '); } String _selectedVersionLabel() { - final index = widget.selectedMediaIndex; - if (index >= 0 && index < widget.availableVersions.length) { - return widget.availableVersions[index].displayLabel; + final index = _state.selectedMediaIndex; + if (index >= 0 && index < _state.availableVersions.length) { + return _state.availableVersions[index].displayLabel; } return t.videoControls.versionColumnHeader; } @@ -525,7 +469,7 @@ class _VideoSettingsSheetState extends State { return ListView( children: [ // Playback Speed - hidden for live TV and when user cannot control playback - if (widget.canControl && !widget.isLive) + if (_state.canControl && !_state.isLive) StreamBuilder( stream: widget.player.streams.rate, initialData: widget.player.state.rate, @@ -540,7 +484,7 @@ class _VideoSettingsSheetState extends State { }, ), - if (widget.onVideoZoomChanged != null || widget.onResetVideoZoom != null) + if (_state.onVideoZoomChanged != null || _state.onResetVideoZoom != null) _SettingsMenuItem( icon: Symbols.zoom_in_rounded, title: t.videoSettings.zoom, @@ -656,36 +600,36 @@ class _VideoSettingsSheetState extends State { ), // Shader Preset (MPV only) - if (widget.shaderService != null && widget.shaderService!.isSupported) + if (_state.shaderService != null && _state.shaderService!.isSupported) _SettingsMenuItem( icon: Symbols.auto_fix_high_rounded, title: t.shaders.title, - valueText: widget.shaderService!.currentPreset.id == ShaderPreset.none.id + valueText: _state.shaderService!.currentPreset.id == ShaderPreset.none.id ? t.common.off - : widget.shaderService!.currentPreset.name, - isHighlighted: widget.shaderService!.currentPreset.isEnabled, + : _state.shaderService!.currentPreset.name, + isHighlighted: _state.shaderService!.currentPreset.isEnabled, onTap: () => _navigateTo(_SettingsView.shader), ), // Ambient Lighting (MPV only) - if (widget.onToggleAmbientLighting != null) + if (_state.onToggleAmbientLighting != null) FocusableListTile( leading: AppIcon( Symbols.blur_on_rounded, fill: 1, - color: widget.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, + color: _state.isAmbientLightingEnabled ? Colors.amber : tokens(context).textMuted, ), title: Text(t.videoControls.ambientLighting), trailing: Switch( - value: widget.isAmbientLightingEnabled, + value: _state.isAmbientLightingEnabled, onChanged: (_) { - widget.onToggleAmbientLighting?.call(); + _state.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, activeThumbColor: Colors.amber, ), onTap: () { - widget.onToggleAmbientLighting?.call(); + _state.onToggleAmbientLighting?.call(); OverlaySheetController.of(context).close(); }, ), @@ -831,13 +775,13 @@ class _VideoSettingsSheetState extends State { Widget _buildVersionQualityView() { return VersionQualityPicker( - availableVersions: widget.availableVersions, - selectedMediaIndex: widget.selectedMediaIndex, - selectedQualityPreset: widget.selectedQualityPreset, - serverSupportsTranscoding: widget.serverSupportsTranscoding, - sourceDurationMs: widget.sourceDurationMs, - onVersionSelected: (index) => widget.onVersionSelected?.call(index), - onQualitySelected: (preset) => widget.onQualitySelected?.call(preset), + availableVersions: _state.availableVersions, + selectedMediaIndex: _state.selectedMediaIndex, + selectedQualityPreset: _state.selectedQualityPreset, + serverSupportsTranscoding: _state.serverSupportsTranscoding, + sourceDurationMs: _state.sourceDurationMs, + onVersionSelected: (index) => _state.onSwitchVersion?.call(index), + onQualitySelected: (preset) => _state.onSwitchQualityPreset?.call(preset), ); } @@ -942,11 +886,11 @@ class _VideoSettingsSheetState extends State { } Widget _buildShaderView() { - if (widget.shaderService == null) return const SizedBox.shrink(); + if (_state.shaderService == null) return const SizedBox.shrink(); return Consumer( builder: (context, shaderProvider, _) { - final currentPreset = widget.shaderService!.currentPreset; + final currentPreset = _state.shaderService!.currentPreset; final presets = shaderProvider.allPresets; // +1 for the import button at the end @@ -986,13 +930,13 @@ class _VideoSettingsSheetState extends State { ), onTap: () async { // Disable ambient lighting when selecting a shader - if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { - widget.onToggleAmbientLighting?.call(); + if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) { + _state.onToggleAmbientLighting?.call(); } - await widget.shaderService!.applyPreset(preset); + await _state.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!context.mounted) return; - widget.onShaderChanged?.call(); + _state.onShaderChanged?.call(); OverlaySheetController.of(context).close(); }, ); @@ -1014,14 +958,14 @@ class _VideoSettingsSheetState extends State { final displayName = path.basenameWithoutExtension(filePath); final preset = await shaderProvider.importCustomShader(filePath, displayName); - if (widget.shaderService != null && mounted) { - if (preset.type != ShaderPresetType.none && widget.isAmbientLightingEnabled) { - widget.onToggleAmbientLighting?.call(); + if (_state.shaderService != null && mounted) { + if (preset.type != ShaderPresetType.none && _state.isAmbientLightingEnabled) { + _state.onToggleAmbientLighting?.call(); } - await widget.shaderService!.applyPreset(preset); + await _state.shaderService!.applyPreset(preset); await shaderProvider.setPreset(preset); if (!mounted) return; - widget.onShaderChanged?.call(); + _state.onShaderChanged?.call(); } if (mounted) showSuccessSnackBar(context, t.shaders.shaderImported); @@ -1039,9 +983,9 @@ class _VideoSettingsSheetState extends State { if (!confirmed || !mounted) return; // If the deleted shader is active, clear it from the player first - if (widget.shaderService!.currentPreset.id == preset.id) { - await widget.shaderService!.applyPreset(ShaderPreset.none); - if (mounted) widget.onShaderChanged?.call(); + if (_state.shaderService!.currentPreset.id == preset.id) { + await _state.shaderService!.applyPreset(ShaderPreset.none); + if (mounted) _state.onShaderChanged?.call(); } await shaderProvider.deleteCustomShader(preset); @@ -1080,7 +1024,7 @@ class _VideoSettingsSheetState extends State { @override Widget build(BuildContext context) { final sleepTimer = SleepTimerService(); - final isShaderActive = widget.shaderService != null && widget.shaderService!.currentPreset.isEnabled; + final isShaderActive = _state.shaderService != null && _state.shaderService!.currentPreset.isEnabled; final isZoomActive = (_zoomScale - 1.0).abs() > 0.0001; final isIconActive = _currentView == _SettingsView.menu && diff --git a/lib/widgets/video_controls/widgets/content_strip.dart b/lib/widgets/video_controls/widgets/content_strip.dart index f8cb894f..b9ceee4b 100644 --- a/lib/widgets/video_controls/widgets/content_strip.dart +++ b/lib/widgets/video_controls/widgets/content_strip.dart @@ -76,9 +76,7 @@ class ContentStripState extends State { late _StripTab _activeTab; final ScrollController _chapterScrollController = ScrollController(); final ScrollController _queueScrollController = ScrollController(); - int? _lastAutoScrolledChapterIndex; - int? _lastAutoScrolledQueueItemID; - int? _lastAutoScrolledQueueIndex; + final Map<_StripTab, Object?> _lastAutoScrolled = {}; final Map _chapterItemKeys = {}; final Map _queueItemKeys = {}; late Stream _chapterIndexStream; @@ -110,11 +108,10 @@ class ContentStripState extends State { void _normalizeActiveTab() { if (_activeTab == _StripTab.chapters && !_hasChapters && _hasQueue) { _activeTab = _StripTab.queue; - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; + _lastAutoScrolled.remove(_StripTab.queue); } else if (_activeTab == _StripTab.queue && !_hasQueue && _hasChapters) { _activeTab = _StripTab.chapters; - _lastAutoScrolledChapterIndex = null; + _lastAutoScrolled.remove(_StripTab.chapters); } } @@ -204,12 +201,7 @@ class ContentStripState extends State { void _selectTab(_StripTab tab) { setState(() { _activeTab = tab; - if (tab == _StripTab.chapters) { - _lastAutoScrolledChapterIndex = null; - } else { - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; - } + _lastAutoScrolled.remove(tab); }); } @@ -218,21 +210,12 @@ class ContentStripState extends State { final key = event.logicalKey; - if (key == LogicalKeyboardKey.arrowLeft) { + if (key == LogicalKeyboardKey.arrowLeft || key == LogicalKeyboardKey.arrowRight) { final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; - if (index > 0) { - nodes[index - 1].requestFocus(); - _scrollToFocusedNode(nodes[index - 1]); - widget.onFocusActivity?.call(); - } - return KeyEventResult.handled; - } - - if (key == LogicalKeyboardKey.arrowRight) { - final nodes = page == _StripTab.chapters ? _chapterFocusNodes : _queueFocusNodes; - if (index < totalItems - 1) { - nodes[index + 1].requestFocus(); - _scrollToFocusedNode(nodes[index + 1]); + final target = index + (key == LogicalKeyboardKey.arrowLeft ? -1 : 1); + if (target >= 0 && target < totalItems) { + nodes[target].requestFocus(); + _scrollToFocusedNode(nodes[target]); widget.onFocusActivity?.call(); } return KeyEventResult.handled; @@ -243,7 +226,7 @@ class ContentStripState extends State { // Switch to chapters page and focus current chapter setState(() { _activeTab = _StripTab.chapters; - _lastAutoScrolledChapterIndex = null; + _lastAutoScrolled.remove(_StripTab.chapters); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _chapterFocusNodes.isNotEmpty) { @@ -266,8 +249,7 @@ class ContentStripState extends State { // Switch to queue page and focus current queue item setState(() { _activeTab = _StripTab.queue; - _lastAutoScrolledQueueItemID = null; - _lastAutoScrolledQueueIndex = null; + _lastAutoScrolled.remove(_StripTab.queue); }); WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _queueFocusNodes.isNotEmpty) { @@ -407,41 +389,86 @@ class ContentStripState extends State { ); } - Widget _buildChapterStrip(bool isTablet) { - final thumbWidth = isTablet ? 200.0 : 120.0; - final thumbHeight = isTablet ? 112.0 : 68.0; + /// Horizontal list of strip items: auto-scrolls to [autoScrollIndex] whenever + /// [autoScrollToken] changes, and wraps items for focus navigation. + Widget _buildStrip({ + required _StripTab tab, + required ScrollController controller, + required Map keys, + required List nodes, + required String focusPrefix, + required int itemCount, + required bool isTablet, + required int? autoScrollIndex, + required Object? autoScrollToken, + required (Widget, VoidCallback?) Function(BuildContext context, int index, Key key) itemBuilder, + }) { + _trimItemKeys(keys, itemCount); + if (autoScrollIndex != null && _lastAutoScrolled[tab] != autoScrollToken) { + _lastAutoScrolled[tab] = autoScrollToken; + _autoScrollTo( + controller, + keys, + autoScrollIndex, + isTablet: isTablet, + isCurrent: () => _lastAutoScrolled[tab] == autoScrollToken, + ); + } + + if (widget.useFocusNavigation) { + _ensureFocusNodes(nodes, itemCount, focusPrefix); + } + + return ListView.builder( + controller: controller, + scrollDirection: Axis.horizontal, + clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + itemCount: itemCount, + padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), + itemBuilder: (context, index) { + final (item, onTap) = itemBuilder(context, index, _itemKeyFor(keys, index)); + + if (!widget.useFocusNavigation) return item; + + return Align( + alignment: .topCenter, + child: FocusableWrapper( + focusNode: nodes[index], + onSelect: onTap, + onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, itemCount, tab), + onFocusChange: (hasFocus) { + if (hasFocus) widget.onFocusActivity?.call(); + }, + borderRadius: 6, + autoScroll: false, + useBackgroundFocus: true, + child: item, + ), + ); + }, + ); + } + + Widget _buildChapterStrip(bool isTablet) { return StreamBuilder( stream: _chapterIndexStream, initialData: MediaChapter.indexAtPosition(widget.player.state.position, widget.chapters), builder: (context, chapterSnapshot) { final currentChapterIndex = chapterSnapshot.data; - _trimItemKeys(_chapterItemKeys, widget.chapters.length); - if (currentChapterIndex != null && _lastAutoScrolledChapterIndex != currentChapterIndex) { - _lastAutoScrolledChapterIndex = currentChapterIndex; - _autoScrollTo( - _chapterScrollController, - _chapterItemKeys, - currentChapterIndex, - isTablet: isTablet, - isCurrent: () => _lastAutoScrolledChapterIndex == currentChapterIndex, - ); - } - - if (widget.useFocusNavigation) { - _ensureFocusNodes(_chapterFocusNodes, widget.chapters.length, 'ChapterFocus'); - } - - return ListView.builder( + return _buildStrip( + tab: _StripTab.chapters, controller: _chapterScrollController, - scrollDirection: Axis.horizontal, - clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + keys: _chapterItemKeys, + nodes: _chapterFocusNodes, + focusPrefix: 'ChapterFocus', itemCount: widget.chapters.length, - padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), - itemBuilder: (context, index) { + isTablet: isTablet, + autoScrollIndex: currentChapterIndex, + autoScrollToken: currentChapterIndex, + itemBuilder: (context, index, itemKey) { final chapter = widget.chapters[index]; - final isCurrent = currentChapterIndex == index; final localThumbPath = widget.serverId != null && chapter.thumb != null ? DownloadStorageService.instance.getArtworkPathSync(ServerId(widget.serverId!), chapter.thumb!) @@ -451,48 +478,25 @@ class ContentStripState extends State { ? () => unawaited(_handleChapterTap(chapter.startTime)) : null; - final itemKey = _itemKeyFor(_chapterItemKeys, index); - final item = _buildStripItem( - key: itemKey, - isCurrent: isCurrent, - isTablet: isTablet, - thumbnail: chapter.thumb != null - ? OptimizedMediaImage.thumb( - client: _tryGetClient(context, serverIdOrNull(widget.serverId)), - imagePath: chapter.thumb, - localFilePath: localThumbPath, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - errorWidget: (_, _, _) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ) - : null, - title: chapter.label, - subtitle: formatDurationTimestamp(chapter.startTime), - onTap: onTap, + return ( + _buildStripItem( + key: itemKey, + isCurrent: currentChapterIndex == index, + isTablet: isTablet, + thumbnail: chapter.thumb != null + ? _buildStripThumbnail( + client: _tryGetClient(context, serverIdOrNull(widget.serverId)), + imagePath: chapter.thumb, + localFilePath: localThumbPath, + isTablet: isTablet, + ) + : null, + title: chapter.label, + subtitle: formatDurationTimestamp(chapter.startTime), + onTap: onTap, + ), + onTap, ); - - if (widget.useFocusNavigation) { - return Align( - alignment: .topCenter, - child: FocusableWrapper( - focusNode: _chapterFocusNodes[index], - onSelect: onTap, - onKeyEvent: (_, event) => - _handleFocusItemKeyEvent(event, index, widget.chapters.length, _StripTab.chapters), - onFocusChange: (hasFocus) { - if (hasFocus) widget.onFocusActivity?.call(); - }, - borderRadius: 6, - autoScroll: false, - useBackgroundFocus: true, - child: item, - ), - ); - } - - return item; }, ); }, @@ -500,9 +504,6 @@ class ContentStripState extends State { } Widget _buildQueueStrip(bool isTablet) { - final thumbWidth = isTablet ? 200.0 : 120.0; - final thumbHeight = isTablet ? 112.0 : 68.0; - return SettingValueBuilder( pref: SettingsService.hideSpoilers, builder: (context, hideSpoilers, _) => Consumer( @@ -513,35 +514,18 @@ class ContentStripState extends State { ? -1 : items.indexWhere((item) => playbackState.playQueueItemIdFor(item) == currentItemID); - _trimItemKeys(_queueItemKeys, items.length); - - if (currentIndex >= 0 && - (_lastAutoScrolledQueueItemID != currentItemID || _lastAutoScrolledQueueIndex != currentIndex)) { - _lastAutoScrolledQueueItemID = currentItemID; - _lastAutoScrolledQueueIndex = currentIndex; - _autoScrollTo( - _queueScrollController, - _queueItemKeys, - currentIndex, - isTablet: isTablet, - isCurrent: () => - _lastAutoScrolledQueueItemID == currentItemID && _lastAutoScrolledQueueIndex == currentIndex, - ); - } - - if (widget.useFocusNavigation) { - _ensureFocusNodes(_queueFocusNodes, items.length, 'QueueFocus'); - } - - return ListView.builder( + return _buildStrip( + tab: _StripTab.queue, controller: _queueScrollController, - scrollDirection: Axis.horizontal, - clipBehavior: widget.useFocusNavigation ? Clip.none : Clip.hardEdge, + keys: _queueItemKeys, + nodes: _queueFocusNodes, + focusPrefix: 'QueueFocus', itemCount: items.length, - padding: .symmetric(horizontal: widget.useFocusNavigation ? 12 : 4), - itemBuilder: (context, index) { + isTablet: isTablet, + autoScrollIndex: currentIndex >= 0 ? currentIndex : null, + autoScrollToken: (currentItemID, currentIndex), + itemBuilder: (context, index, itemKey) { final item = items[index]; - final isCurrent = playbackState.playQueueItemIdFor(item) == currentItemID; final client = item.serverId != null ? context.tryGetMediaClientForServer(serverIdOrNull(item.serverId)) @@ -549,47 +533,21 @@ class ContentStripState extends State { void onTap() => widget.onQueueItemSelected?.call(item); - final itemKey = _itemKeyFor(_queueItemKeys, index); - final stripItem = _buildStripItem( - key: itemKey, - isCurrent: isCurrent, - isTablet: isTablet, - thumbnail: item.thumbPath != null - ? OptimizedMediaImage.thumb( - client: client, - imagePath: item.thumbPath, - width: thumbWidth, - height: thumbHeight, - fit: BoxFit.cover, - errorWidget: (_, _, _) => - const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), - ) - : null, - blurThumbnail: hideSpoilers && item.shouldHideSpoiler, - title: item.title ?? '', - subtitle: formatQueueItemSubtitle(item), - onTap: onTap, + return ( + _buildStripItem( + key: itemKey, + isCurrent: playbackState.playQueueItemIdFor(item) == currentItemID, + isTablet: isTablet, + thumbnail: item.thumbPath != null + ? _buildStripThumbnail(client: client, imagePath: item.thumbPath, isTablet: isTablet) + : null, + blurThumbnail: hideSpoilers && item.shouldHideSpoiler, + title: item.title ?? '', + subtitle: formatQueueItemSubtitle(item), + onTap: onTap, + ), + onTap, ); - - if (widget.useFocusNavigation) { - return Align( - alignment: .topCenter, - child: FocusableWrapper( - focusNode: _queueFocusNodes[index], - onSelect: onTap, - onKeyEvent: (_, event) => _handleFocusItemKeyEvent(event, index, items.length, _StripTab.queue), - onFocusChange: (hasFocus) { - if (hasFocus) widget.onFocusActivity?.call(); - }, - borderRadius: 6, - autoScroll: false, - useBackgroundFocus: true, - child: stripItem, - ), - ); - } - - return stripItem; }, ); }, @@ -597,6 +555,23 @@ class ContentStripState extends State { ); } + Widget _buildStripThumbnail({ + required MediaServerClient? client, + required String? imagePath, + required bool isTablet, + String? localFilePath, + }) { + return OptimizedMediaImage.thumb( + client: client, + imagePath: imagePath, + localFilePath: localFilePath, + width: isTablet ? 200.0 : 120.0, + height: isTablet ? 112.0 : 68.0, + fit: BoxFit.cover, + errorWidget: (_, _, _) => const AppIcon(Symbols.image_rounded, fill: 1, color: Colors.white54, size: 34), + ); + } + Widget _buildStripItem({ Key? key, required bool isCurrent, diff --git a/lib/widgets/video_controls/widgets/content_strip_panel.dart b/lib/widgets/video_controls/widgets/content_strip_panel.dart new file mode 100644 index 00000000..75ea0dae --- /dev/null +++ b/lib/widgets/video_controls/widgets/content_strip_panel.dart @@ -0,0 +1,52 @@ +import 'package:flutter/material.dart'; + +import '../../app_icon.dart'; + +/// Gradient scrim that hosts the content strip once it is on screen. +/// +/// [chevron] points back at the controls the strip replaced — down for the +/// mobile swipe, up for D-pad focus. [padding] compensates for the strip's +/// own horizontal padding, which differs between touch and focus navigation. +class ContentStripPanel extends StatelessWidget { + final EdgeInsetsGeometry padding; + final IconData chevron; + final Widget child; + + const ContentStripPanel({super.key, required this.padding, required this.chevron, required this.child}); + + @override + Widget build(BuildContext context) { + return Container( + padding: padding, + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.65), Colors.black.withValues(alpha: 0.7)], + stops: const [0.0, 0.42, 1.0], + ), + ), + child: Column( + mainAxisSize: .min, + children: [ + AppIcon(chevron, color: Colors.white38, size: 20), + const SizedBox(height: 4), + child, + ], + ), + ); + } +} + +/// Chevron pinned to the bottom of the controls hinting that the content +/// strip can be pulled into view. Must be placed directly in a [Stack]. +class ContentStripHint extends StatelessWidget { + final IconData chevron; + + const ContentStripHint(this.chevron, {super.key}); + + @override + Widget build(BuildContext context) { + return Positioned(left: 0, right: 0, bottom: 12, child: AppIcon(chevron, color: Colors.white24, size: 24)); + } +} diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 72efae64..99bbc9f0 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -3,8 +3,6 @@ import 'package:material_symbols_icons/symbols.dart'; import 'package:flutter/services.dart'; import '../../../focus/dpad_navigator.dart'; -import '../../../media/media_item.dart'; -import '../../../media/media_version.dart'; import '../../../mpv/mpv.dart'; import '../../../media/media_source_info.dart'; import '../../../services/sleep_timer_service.dart'; @@ -13,12 +11,10 @@ import '../../../utils/quality_preset_labels.dart'; import '../../../i18n/strings.g.dart'; import '../../../widgets/overlay_sheet.dart'; import '../models/track_controls_state.dart'; -import '../../../models/transcode_quality_preset.dart'; import '../sheets/chapter_sheet.dart'; import '../sheets/queue_sheet.dart'; import '../sheets/track_sheet.dart'; import '../sheets/video_settings_sheet.dart'; -import '../../../services/shader_service.dart'; import '../../../utils/track_label_builder.dart'; import '../video_control_button.dart'; @@ -65,43 +61,6 @@ class TrackChapterControls extends StatelessWidget { this.hideChaptersAndQueue = false, }); - List get availableVersions => trackControlsState.availableVersions; - int get selectedMediaIndex => trackControlsState.selectedMediaIndex; - TranscodeQualityPreset get selectedQualityPreset => trackControlsState.selectedQualityPreset; - bool get serverSupportsTranscoding => trackControlsState.serverSupportsTranscoding; - ValueChanged? get onSwitchQualityPreset => trackControlsState.onSwitchQualityPreset; - int get boxFitMode => trackControlsState.boxFitMode; - double get videoZoomScale => trackControlsState.videoZoomScale; - int get audioSyncOffset => trackControlsState.audioSyncOffset; - int get subtitleSyncOffset => trackControlsState.subtitleSyncOffset; - bool get isRotationLocked => trackControlsState.isRotationLocked; - bool get isScreenLocked => trackControlsState.isScreenLocked; - bool get isFullscreen => trackControlsState.isFullscreen; - bool get isAlwaysOnTop => trackControlsState.isAlwaysOnTop; - VoidCallback? get onTogglePIPMode => trackControlsState.onTogglePIPMode; - VoidCallback? get onCycleBoxFitMode => trackControlsState.onCycleBoxFitMode; - ValueChanged? get onVideoZoomChanged => trackControlsState.onVideoZoomChanged; - VoidCallback? get onResetVideoZoom => trackControlsState.onResetVideoZoom; - VoidCallback? get onToggleRotationLock => trackControlsState.onToggleRotationLock; - VoidCallback? get onToggleScreenLock => trackControlsState.onToggleScreenLock; - VoidCallback? get onToggleFullscreen => trackControlsState.onToggleFullscreen; - VoidCallback? get onToggleAlwaysOnTop => trackControlsState.onToggleAlwaysOnTop; - Function(int)? get onSwitchVersion => trackControlsState.onSwitchVersion; - VoidCallback? get onLoadSeekTimes => trackControlsState.onLoadSeekTimes; - VoidCallback? get onCancelAutoHide => trackControlsState.onCancelAutoHide; - VoidCallback? get onStartAutoHide => trackControlsState.onStartAutoHide; - void Function(String propertyName, int offset)? get onSyncOffsetChanged => trackControlsState.onSyncOffsetChanged; - String? get serverId => trackControlsState.serverId; - ShaderService? get shaderService => trackControlsState.shaderService; - VoidCallback? get onShaderChanged => trackControlsState.onShaderChanged; - bool get isAmbientLightingEnabled => trackControlsState.isAmbientLightingEnabled; - VoidCallback? get onToggleAmbientLighting => trackControlsState.onToggleAmbientLighting; - bool get canControl => trackControlsState.canControl; - bool get isLive => trackControlsState.isLive; - bool get subtitlesVisible => trackControlsState.subtitlesVisible; - bool get showQueueButton => trackControlsState.showQueueButton; - Function(MediaItem)? get onQueueItemSelected => trackControlsState.onQueueItemSelected; - /// Handle key event for button navigation KeyEventResult _handleButtonKeyEvent(FocusNode _, KeyEvent event, int index, int totalButtons) { if (!event.isActionable) { @@ -183,6 +142,7 @@ class TrackChapterControls extends StatelessWidget { initialData: player.state.tracks, builder: (context, snapshot) { final tracks = snapshot.data; + final state = trackControlsState; final isMobile = PlatformDetector.isMobile(context); final isDesktop = PlatformDetector.isDesktopOS(); @@ -196,13 +156,14 @@ class TrackChapterControls extends StatelessWidget { listenable: SleepTimerService(), builder: (context, _) { final sleepTimer = SleepTimerService(); + final shaderService = state.shaderService; final isShaderActive = - shaderService != null && shaderService!.isSupported && shaderService!.currentPreset.isEnabled; - final isZoomActive = (videoZoomScale - 1.0).abs() > 0.0001; + shaderService != null && shaderService.isSupported && shaderService.currentPreset.isEnabled; + final isZoomActive = (state.videoZoomScale - 1.0).abs() > 0.0001; final isActive = sleepTimer.isActive || - audioSyncOffset != 0 || - subtitleSyncOffset != 0 || + state.audioSyncOffset != 0 || + state.subtitleSyncOffset != 0 || isShaderActive || isZoomActive; return _buildTrackButton( @@ -216,37 +177,14 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( - builder: (_) => VideoSettingsSheet( - player: player, - audioSyncOffset: audioSyncOffset, - subtitleSyncOffset: subtitleSyncOffset, - videoZoomScale: videoZoomScale, - onVideoZoomChanged: onVideoZoomChanged, - onResetVideoZoom: onResetVideoZoom, - canControl: canControl, - isLive: isLive, - availableVersions: availableVersions, - selectedMediaIndex: selectedMediaIndex, - selectedQualityPreset: selectedQualityPreset, - serverSupportsTranscoding: serverSupportsTranscoding, - sourceDurationMs: trackControlsState.sourceDurationMs, - onVersionSelected: onSwitchVersion == null ? null : (i) => onSwitchVersion!(i), - onQualitySelected: onSwitchQualityPreset, - shaderService: shaderService, - onShaderChanged: onShaderChanged, - isAmbientLightingEnabled: isAmbientLightingEnabled, - onToggleAmbientLighting: onToggleAmbientLighting, - onCancelAutoHide: onCancelAutoHide, - onStartAutoHide: onStartAutoHide, - onSyncOffsetChanged: onSyncOffsetChanged, - ), + builder: (_) => VideoSettingsSheet(player: player, trackControlsState: state), ) .whenComplete(() { - onStartAutoHide?.call(); - onLoadSeekTimes?.call(); + state.onStartAutoHide?.call(); + state.onLoadSeekTimes?.call(); }); }, ); @@ -264,10 +202,10 @@ class TrackChapterControls extends StatelessWidget { initialData: player.state.track, builder: (context, selectionSnapshot) { final selection = selectionSnapshot.data ?? player.state.track; - final hasSubtitleControls = trackControlsState.hasSubtitleControls(tracks); + final hasSubtitleControls = state.hasSubtitleControls(tracks); final selectedSub = selection.subtitle; final hasActiveSubtitle = selectedSub != null && selectedSub.id != SubtitleTrack.off.id; - final isHidden = hasSubtitleControls && hasActiveSubtitle && !subtitlesVisible; + final isHidden = hasSubtitleControls && hasActiveSubtitle && !state.subtitlesVisible; final icon = hasSubtitleControls ? (isHidden ? Symbols.subtitles_off_rounded : Symbols.subtitles_rounded) : Symbols.audiotrack_rounded; @@ -280,12 +218,12 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( - builder: (_) => TrackSheet(player: player, trackControlsState: trackControlsState), + builder: (_) => TrackSheet(player: player, trackControlsState: state), ) - .whenComplete(() => onStartAutoHide?.call()); + .whenComplete(() => state.onStartAutoHide?.call()); }, ); }, @@ -306,20 +244,20 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) .show( builder: (_) => ChapterSheet( player: player, chapters: chapters, chaptersLoaded: chaptersLoaded, - canControl: canControl, - serverId: serverId, + canControl: state.canControl, + serverId: state.serverId, onSeekRequested: onSeekRequested, onSeekCompleted: onSeekCompleted, ), ) - .whenComplete(() => onStartAutoHide?.call()); + .whenComplete(() => state.onStartAutoHide?.call()); }, ), ); @@ -327,7 +265,7 @@ class TrackChapterControls extends StatelessWidget { } // Queue button (hidden on mobile when content strip is available) - if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) { + if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -338,10 +276,10 @@ class TrackChapterControls extends StatelessWidget { isMobile: isMobile, isDesktop: isDesktop, onPressed: () { - onCancelAutoHide?.call(); + state.onCancelAutoHide?.call(); OverlaySheetController.of(context) - .show(builder: (_) => QueueSheet(onItemSelected: onQueueItemSelected!)) - .whenComplete(() => onStartAutoHide?.call()); + .show(builder: (_) => QueueSheet(onItemSelected: state.onQueueItemSelected!)) + .whenComplete(() => state.onStartAutoHide?.call()); }, ), ); @@ -349,7 +287,7 @@ class TrackChapterControls extends StatelessWidget { } // Picture-in-Picture mode - if (onTogglePIPMode != null) { + if (state.onTogglePIPMode != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -359,25 +297,25 @@ class TrackChapterControls extends StatelessWidget { semanticLabel: t.videoControls.pipButton, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onTogglePIPMode, + onPressed: state.onTogglePIPMode, ), ); buttonIndex++; } // BoxFit mode button - if (onCycleBoxFitMode != null) { + if (state.onCycleBoxFitMode != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: _getBoxFitIcon(boxFitMode), - tooltip: _getBoxFitTooltip(boxFitMode), + icon: _getBoxFitIcon(state.boxFitMode), + tooltip: _getBoxFitTooltip(state.boxFitMode), semanticLabel: t.videoControls.aspectRatioButton, - semanticValue: _getBoxFitTooltip(boxFitMode), + semanticValue: _getBoxFitTooltip(state.boxFitMode), isMobile: isMobile, isDesktop: isDesktop, - onPressed: onCycleBoxFitMode, + onPressed: state.onCycleBoxFitMode, ), ); buttonIndex++; @@ -389,13 +327,13 @@ class TrackChapterControls extends StatelessWidget { buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded, - tooltip: isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation, + icon: state.isRotationLocked ? Symbols.screen_lock_rotation_rounded : Symbols.screen_rotation_rounded, + tooltip: state.isRotationLocked ? t.videoControls.unlockRotation : t.videoControls.lockRotation, semanticLabel: t.videoControls.rotationLockButton, - checked: isRotationLocked, + checked: state.isRotationLocked, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleRotationLock, + onPressed: state.onToggleRotationLock, ), ); buttonIndex++; @@ -412,14 +350,14 @@ class TrackChapterControls extends StatelessWidget { semanticLabel: t.videoControls.screenLockButton, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleScreenLock, + onPressed: state.onToggleScreenLock, ), ); buttonIndex++; } // Always on top button (desktop only, not TV) - if (isDesktop && onToggleAlwaysOnTop != null) { + if (isDesktop && state.onToggleAlwaysOnTop != null) { final currentIndex = buttonIndex; buttons.add( _buildTrackButton( @@ -427,11 +365,11 @@ class TrackChapterControls extends StatelessWidget { icon: Symbols.layers_rounded, tooltip: t.videoControls.alwaysOnTopButton, semanticLabel: t.videoControls.alwaysOnTopButton, - isActive: isAlwaysOnTop, - checked: isAlwaysOnTop, + isActive: state.isAlwaysOnTop, + checked: state.isAlwaysOnTop, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleAlwaysOnTop, + onPressed: state.onToggleAlwaysOnTop, ), ); buttonIndex++; @@ -443,13 +381,15 @@ class TrackChapterControls extends StatelessWidget { buttons.add( _buildTrackButton( buttonIndex: currentIndex, - icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, - tooltip: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, - semanticLabel: isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, - checked: isFullscreen, + icon: state.isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, + tooltip: state.isFullscreen ? t.videoControls.exitFullscreenButton : t.videoControls.fullscreenButton, + semanticLabel: state.isFullscreen + ? t.videoControls.exitFullscreenButton + : t.videoControls.fullscreenButton, + checked: state.isFullscreen, isMobile: isMobile, isDesktop: isDesktop, - onPressed: onToggleFullscreen, + onPressed: state.onToggleFullscreen, ), ); } @@ -462,15 +402,16 @@ class TrackChapterControls extends StatelessWidget { } String? _versionQualitySemanticValue() { + final state = trackControlsState; final values = []; - if (availableVersions.length > 1) { - final index = selectedMediaIndex; - if (index >= 0 && index < availableVersions.length) { - values.add(availableVersions[index].displayLabel); + if (state.availableVersions.length > 1) { + final index = state.selectedMediaIndex; + if (index >= 0 && index < state.availableVersions.length) { + values.add(state.availableVersions[index].displayLabel); } } - if (serverSupportsTranscoding) { - values.add(qualityPresetLabel(selectedQualityPreset)); + if (state.serverSupportsTranscoding) { + values.add(qualityPresetLabel(state.selectedQualityPreset)); } return values.isEmpty ? null : values.join(' / '); } @@ -519,14 +460,15 @@ class TrackChapterControls extends StatelessWidget { /// Calculate total button count for navigation int _getButtonCount(bool isMobile, bool isDesktop) { + final state = trackControlsState; int count = 1; // Settings button always shown count++; // Audio & subtitles button always shown if (chapters.isNotEmpty && !hideChaptersAndQueue) count++; - if (showQueueButton && onQueueItemSelected != null && !hideChaptersAndQueue) count++; - if (onTogglePIPMode != null) count++; - if (onCycleBoxFitMode != null) count++; + if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) count++; + if (state.onTogglePIPMode != null) count++; + if (state.onCycleBoxFitMode != null) count++; if (isMobile && !PlatformDetector.isTV()) count++; // Rotation lock (not on TV) - if (isDesktop && onToggleAlwaysOnTop != null) count++; // Always on top + if (isDesktop && state.onToggleAlwaysOnTop != null) count++; // Always on top if (isDesktop) count++; // Fullscreen return count; } diff --git a/linux/runner/mpv/mpv_player.cc b/linux/runner/mpv/mpv_player.cc index 38879bf7..5b9aa395 100644 --- a/linux/runner/mpv/mpv_player.cc +++ b/linux/runner/mpv/mpv_player.cc @@ -617,20 +617,7 @@ void MpvPlayer::CommandAsync(const std::vector& args, CommandCallba return; } - std::vector c_args; - c_args.reserve(args.size() + 1); - for (const auto& arg : args) { - c_args.push_back(arg.c_str()); - } - c_args.push_back(nullptr); - - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - int result = mpv_command_async(mpv_, request_id, c_args.data()); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback)); } void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { @@ -658,14 +645,7 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val if (completion) completion(error); }; } - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - char* property_value = const_cast(value.c_str()); - int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback)); } void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) { @@ -674,13 +654,7 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca return; } - uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback)); - - int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING); - if (result < 0) { - auto cb = pending_requests_.TakeProperty(request_id); - if (cb) cb(result, ""); - } + plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback)); } void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { @@ -920,33 +894,12 @@ void MpvPlayer::EnsureAudioRecoveryTimer() { } void MpvPlayer::HandleMpvEvent(mpv_event* event) { + if (plezy::mpv_common::DispatchReplyEvent( + pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) { + return; + } + switch (event->event_id) { - case MPV_EVENT_COMMAND_REPLY: - case MPV_EVENT_SET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - StatusCallback callback = pending_requests_.TakeStatus(request_id); - if (callback) { - callback(event->error); - } - break; - } - case MPV_EVENT_GET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - GetPropertyCallback callback = pending_requests_.TakeProperty(request_id); - if (callback) { - int error = event->error; - std::string value; - if (error >= 0) { - auto* prop = static_cast(event->data); - if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { - auto c_value = *static_cast(prop->data); - if (c_value) value = SanitizeUtf8(c_value); - } - } - callback(error, value); - } - break; - } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); if (!msg) break; @@ -963,54 +916,12 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { case MPV_EVENT_PROPERTY_CHANGE: { auto* prop = static_cast(event->data); if (!prop || !prop->name) break; - mpv_node node; - node.format = prop->format; + mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop); - switch (prop->format) { - case MPV_FORMAT_STRING: - node.u.string = prop->data ? *static_cast(prop->data) : nullptr; - break; - case MPV_FORMAT_FLAG: - node.u.flag = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_INT64: - node.u.int64 = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_DOUBLE: - node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; - break; - case MPV_FORMAT_NODE: - if (prop->data) { - node = *static_cast(prop->data); - } else { - node.format = MPV_FORMAT_NONE; - } - break; - default: - node.format = MPV_FORMAT_NONE; - break; - } - - if (strcmp(prop->name, "current-ao") == 0) { - const char* current_ao = nullptr; - if (prop->format == MPV_FORMAT_STRING && prop->data) { - current_ao = *static_cast(prop->data); - } - const bool is_null = current_ao && strcmp(current_ao, "null") == 0; - const auto transition = - audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now()); - if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) { - LogRecovery("current-ao fell back to null; starting recovery"); - EnsureAudioRecoveryTimer(); - } else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) { - LogRecovery("audio recovered (current-ao no longer null)"); - } - } - if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && - audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) { - LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload"); - EnsureAudioRecoveryTimer(); - } + const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop); + if (notice.message) LogRecovery(notice.message); + // Recovery runs off a GLib timer here, so newly queued work has to arm it. + if (notice.scheduled_work) EnsureAudioRecoveryTimer(); SendPropertyChange(prop->name, &node); break; @@ -1048,77 +959,41 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { break; } } -FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { - NodeConversionBudget budget{ - /*remaining_entries=*/16384, - /*remaining_bytes=*/16 * 1024 * 1024, - }; - return NodeToFlValue(node, 0, &budget); -} -bool MpvPlayer::ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result) { - if (!input || !budget || !result) return false; - const size_t length = strnlen(input, budget->remaining_bytes + 1); - if (length > budget->remaining_bytes) return false; - budget->remaining_bytes -= length; - *result = SanitizeUtf8(input, length); - return true; -} +namespace { -FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget) { - constexpr size_t kMaxNodeDepth = 32; - constexpr int kMaxNodeEntries = 16384; - if (!node || !budget || depth >= kMaxNodeDepth || budget->remaining_entries == 0) { - return fl_value_new_null(); +// Adapts the shared, bounded mpv_node walk onto GLib-owned FlValues. +struct FlValueNodeBuilder { + using Value = FlValue*; + using ListBuilder = FlValue*; + using MapBuilder = FlValue*; + + static Value Null() { return fl_value_new_null(); } + static Value Bool(bool value) { return fl_value_new_bool(value); } + static Value Int(int64_t value) { return fl_value_new_int(value); } + static Value Double(double value) { return fl_value_new_float(value); } + static Value String(const char* value, size_t length) { + return fl_value_new_string(SanitizeUtf8(value, length).c_str()); } - --budget->remaining_entries; - switch (node->format) { - case MPV_FORMAT_STRING: { - std::string value; - if (!ConvertNodeString(node->u.string, budget, &value)) return fl_value_new_null(); - return fl_value_new_string(value.c_str()); - } - case MPV_FORMAT_FLAG: - return fl_value_new_bool(node->u.flag != 0); - case MPV_FORMAT_INT64: - return fl_value_new_int(node->u.int64); - case MPV_FORMAT_DOUBLE: - return fl_value_new_float(node->u.double_); - case MPV_FORMAT_NODE_ARRAY: { - const mpv_node_list* list = node->u.list; - if (!list || list->num < 0 || list->num > kMaxNodeEntries || (list->num > 0 && !list->values)) { - return fl_value_new_null(); - } - FlValue* result = fl_value_new_list(); - for (int i = 0; i < list->num; i++) { - fl_value_append_take(result, NodeToFlValue(&list->values[i], depth + 1, budget)); - } - return result; - } - case MPV_FORMAT_NODE_MAP: { - const mpv_node_list* map = node->u.list; - if (!map || map->num < 0 || map->num > kMaxNodeEntries || (map->num > 0 && (!map->keys || !map->values))) { - return fl_value_new_null(); - } - FlValue* result = fl_value_new_map(); - for (int i = 0; i < map->num; i++) { - if (!map->keys[i]) { - fl_value_unref(result); - return fl_value_new_null(); - } - std::string key; - if (!ConvertNodeString(map->keys[i], budget, &key)) { - fl_value_unref(result); - return fl_value_new_null(); - } - fl_value_set_string_take(result, key.c_str(), NodeToFlValue(&map->values[i], depth + 1, budget)); - } - return result; - } - default: - return fl_value_new_null(); + static ListBuilder NewList() { return fl_value_new_list(); } + static void Append(ListBuilder& list, Value value) { fl_value_append_take(list, value); } + static Value FinishList(ListBuilder list) { return list; } + + static MapBuilder NewMap() { return fl_value_new_map(); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + fl_value_set_string_take(map, SanitizeUtf8(key, key_length).c_str(), value); } + static Value FinishMap(MapBuilder map) { return map; } + static void AbandonMap(MapBuilder& map) { fl_value_unref(map); } +}; + +} // namespace + +FlValue* MpvPlayer::NodeToFlValue(mpv_node* node) { return plezy::mpv_common::ConvertNode(node); } + +FlValue* MpvPlayer::NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget) { + return plezy::mpv_common::ConvertNode(node, 0, budget); } void MpvPlayer::SendPropertyChange(const char* name, mpv_node* data) { diff --git a/linux/runner/mpv/mpv_player.h b/linux/runner/mpv/mpv_player.h index 61b76fec..2654be8c 100644 --- a/linux/runner/mpv/mpv_player.h +++ b/linux/runner/mpv/mpv_player.h @@ -242,15 +242,9 @@ class MpvPlayer { void LogRecovery(const std::string& text); void SetHDREnabled(bool enabled, StatusCallback callback = nullptr); - struct NodeConversionBudget { - size_t remaining_entries; - size_t remaining_bytes; - }; - - /// Helper to convert mpv_node to FlValue. + /// Helper to convert mpv_node to FlValue, bounded by the shared node budget. ::_FlValue* NodeToFlValue(mpv_node* node); - ::_FlValue* NodeToFlValue(mpv_node* node, size_t depth, NodeConversionBudget* budget); - bool ConvertNodeString(const char* input, NodeConversionBudget* budget, std::string* result); + ::_FlValue* NodeToFlValue(mpv_node* node, plezy::mpv_common::NodeConversionBudget* budget); const bool audio_only_; mpv_handle* mpv_ = nullptr; diff --git a/linux/runner/mpv/mpv_player_lifecycle_test.cc b/linux/runner/mpv/mpv_player_lifecycle_test.cc index f7f97e43..c481467a 100644 --- a/linux/runner/mpv/mpv_player_lifecycle_test.cc +++ b/linux/runner/mpv/mpv_player_lifecycle_test.cc @@ -102,8 +102,8 @@ class MpvPlayerLifecycleTestPeer { static FlValue* ConvertNode(MpvPlayer& player, mpv_node* node) { return player.NodeToFlValue(node); } static FlValue* ConvertNodeWithBudget( MpvPlayer& player, mpv_node* node, size_t remaining_entries, size_t remaining_bytes) { - MpvPlayer::NodeConversionBudget budget{remaining_entries, remaining_bytes}; - return player.NodeToFlValue(node, 0, &budget); + plezy::mpv_common::NodeConversionBudget budget{remaining_entries, remaining_bytes}; + return player.NodeToFlValue(node, &budget); } static void RegisterObservedNode(MpvPlayer& player, const std::string& name, int id) { player.observed_properties_.Register(name, "node", id); diff --git a/scripts/check_build_workflow.py b/scripts/check_build_workflow.py index f894955f..89ab4d70 100644 --- a/scripts/check_build_workflow.py +++ b/scripts/check_build_workflow.py @@ -5,8 +5,15 @@ from pathlib import Path import re import sys +from workflow_yaml import iter_uses_references, job_block -DEFAULT_WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/build.yml" + +ROOT = Path(__file__).resolve().parents[1] +DEFAULT_WORKFLOW = ROOT / ".github/workflows/build.yml" +# The shared bootstrap both windows-arm jobs call, and the pins it must keep. +SETUP_FLUTTER_GIT = ROOT / ".github/actions/setup-flutter-git/action.yml" +FLUTTER_VERSION = "3.44.0" +FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42" if len(sys.argv) > 2: raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]") WORKFLOW = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_WORKFLOW @@ -20,11 +27,9 @@ def require(condition: bool, message: str) -> None: def job(name: str) -> str: - match = re.search( - rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text - ) - require(match is not None, f"missing {name} job") - return match.group(0) if match else "" + block = job_block(text, name) + require(bool(block), f"missing {name} job") + return block def named_step(block: str, name: str) -> str: @@ -132,7 +137,7 @@ require( for expected in ( "if: matrix.flutter_setup == 'action'", "if: matrix.flutter_setup == 'git'", - "git -C $root fetch --depth 1 origin 559ffa3f75e7402d65a8def9c28389a9b2e6fe42", + "uses: ./.github/actions/setup-flutter-git", "flutter pub get --enforce-lockfile --no-example", "--dart-define=SENTRY_DIST=github-windows-${{ matrix.arch }}", "--split-debug-info=debug-info/windows-${{ matrix.arch }}", @@ -154,6 +159,20 @@ require( ) require_explicit_shells("build-windows", windows, "pwsh") +setup_flutter_git = ( + SETUP_FLUTTER_GIT.read_text(encoding="utf-8") if SETUP_FLUTTER_GIT.is_file() else "" +) +require(bool(setup_flutter_git), "missing .github/actions/setup-flutter-git/action.yml") +for expected in ( + f'$version = "{FLUTTER_VERSION}"', + f'$expectedCommit = "{FLUTTER_COMMIT}"', + "$actualCommit -ne $expectedCommit", +): + require( + expected in setup_flutter_git, + f"shared Flutter bootstrap must keep its immutable pin: {expected}", + ) + linux = job("build-linux") require("runs-on: ${{ matrix.runner }}" in linux, "Linux must use its matrix runner") require("fail-fast: false" in linux, "Linux matrix must not cancel its other architecture") @@ -181,7 +200,7 @@ require( ) for expected in ( "channel: ${{ matrix.flutter_channel }}", - 'flutter-version: "3.44.0"', + "flutter-version: ${{ env.FLUTTER_VERSION }}", "flutter pub get --enforce-lockfile --no-example", "lib/${{ matrix.pkg_config_arch }}/pkgconfig", "--dart-define=SENTRY_DIST=github-linux-${{ matrix.arch }}", @@ -286,6 +305,10 @@ for protected_job in ( f"{protected_job} must depend on trusted-ref validation", ) +require( + text.count(FLUTTER_VERSION) == 1 and f'FLUTTER_VERSION: "{FLUTTER_VERSION}"' in text, + "the Flutter SDK version must be written once, as the workflow FLUTTER_VERSION env", +) require( "TRUSTED_BUILD_CACHE_VERSION: trusted-build-v1" in text, "build caches must use a dedicated trusted namespace", @@ -303,17 +326,18 @@ require( "every Flutter SDK cache must define its trusted cache key", ) -action_refs = re.findall(r"(?m)^\s*(?:-\s+)?uses:\s+([^\s@]+)@([^\s#]+)", text) -require(bool(action_refs), "build workflow must use pinned actions") -for action, ref in action_refs: - require( - re.fullmatch(r"[0-9a-f]{40}", ref) is not None, - f"action {action} must be pinned to a full commit SHA", - ) - -checkout_count = sum(action == "actions/checkout" for action, _ in action_refs) +# check_workflow_action_pins.py owns the SHA-pin rule for every workflow, this +# one included; build.yml only adds the credential invariant on top, because it +# is workflow_dispatch-only and so escapes the pull-request rule in +# check_workflow_security.py. +remote_actions = [ + reference.rpartition("@")[0] + for _, reference in iter_uses_references(text) + if not reference.startswith("./") +] +require(bool(remote_actions), "build workflow must use pinned actions") require( - text.count("persist-credentials: false") == checkout_count, + text.count("persist-credentials: false") == remote_actions.count("actions/checkout"), "every build checkout must discard GitHub credentials", ) diff --git a/scripts/check_update_packages_workflow.py b/scripts/check_update_packages_workflow.py index 75f6a501..65172130 100644 --- a/scripts/check_update_packages_workflow.py +++ b/scripts/check_update_packages_workflow.py @@ -5,6 +5,8 @@ from pathlib import Path import re import sys +from workflow_yaml import job_block + WORKFLOW = Path(__file__).resolve().parents[1] / ".github/workflows/update-packages.yml" text = WORKFLOW.read_text(encoding="utf-8") @@ -17,11 +19,9 @@ def require(condition: bool, message: str) -> None: def job(name: str) -> str: - match = re.search( - rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text - ) - require(match is not None, f"missing {name} job") - return match.group(0) if match else "" + block = job_block(text, name) + require(bool(block), f"missing {name} job") + return block require( diff --git a/scripts/check_workflow_action_pins.py b/scripts/check_workflow_action_pins.py index 2f30fc69..8cf66929 100755 --- a/scripts/check_workflow_action_pins.py +++ b/scripts/check_workflow_action_pins.py @@ -7,336 +7,21 @@ import re import sys from pathlib import Path +import workflow_yaml + ROOT = Path(__file__).resolve().parent.parent WORKFLOWS = ROOT / ".github" / "workflows" -MAPPING_RE = re.compile( - r"""^\s*(?:-\s*)?(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*:\s*(?P.*?)\s*$""" -) -EXPLICIT_KEY_RE = re.compile( - r"""^\s*(?:-\s*)?\?\s*(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*$""" -) -EXPLICIT_VALUE_RE = re.compile(r"^\s*:\s*(?P.*?)\s*$") +ACTIONS = ROOT / ".github" / "actions" REMOTE_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+(?:/[A-Za-z0-9_./-]+)?@[0-9a-fA-F]{40}$") -BLOCK_SCALAR_RE = re.compile(r":\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$") -BLOCK_SCALAR_VALUE_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$") -YAML_DOUBLE_ESCAPES = { - "0": "\0", - "a": "\a", - "b": "\b", - "t": "\t", - "\t": "\t", - "n": "\n", - "v": "\v", - "f": "\f", - "r": "\r", - "e": "\x1b", - " ": " ", - '"': '"', - "/": "/", - "\\": "\\", - "N": "\u0085", - "_": "\u00a0", - "L": "\u2028", - "P": "\u2029", -} -def iter_workflow_files(directory: Path = WORKFLOWS): - yield from sorted((*directory.glob("*.yml"), *directory.glob("*.yaml"))) - - -def _strip_yaml_comment(value: str) -> str: - quote = None - escaped = False - for index, char in enumerate(value): - if escaped: - escaped = False - continue - if char == "\\" and quote == '"': - escaped = True - continue - if char in ("'", '"'): - if quote is None: - quote = char - elif quote == char: - quote = None - continue - if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()): - return value[:index].rstrip() - return value.rstrip() - - -def _decode_quoted_yaml_string(value: str) -> str | None: - if len(value) < 2 or value[0] != value[-1] or value[0] not in ("'", '"'): - return None - if value[0] == "'": - return value[1:-1].replace("''", "'") - - decoded = [] - index = 1 - end = len(value) - 1 - while index < end: - char = value[index] - if char != "\\": - decoded.append(char) - index += 1 - continue - index += 1 - if index >= end: - return None - escape = value[index] - if escape in YAML_DOUBLE_ESCAPES: - decoded.append(YAML_DOUBLE_ESCAPES[escape]) - index += 1 - continue - width = {"x": 2, "u": 4, "U": 8}.get(escape) - if width is None or index + width >= end: - return None - digits = value[index + 1 : index + 1 + width] - if not re.fullmatch(rf"[0-9a-fA-F]{{{width}}}", digits): - return None - try: - decoded.append(chr(int(digits, 16))) - except ValueError: - return None - index += width + 1 - return "".join(decoded) - - -def _unquote(value: str) -> str: - decoded = _decode_quoted_yaml_string(value) - return value if decoded is None else decoded - - -def _flow_value(line: str, start: int, mapping_depth: int) -> str: - index = start - quote = None - escaped = False - depth = mapping_depth - while index < len(line): - char = line[index] - if escaped: - escaped = False - elif char == "\\" and quote == '"': - escaped = True - elif quote is not None: - if char == quote: - quote = None - elif char in ("'", '"'): - quote = char - elif char in ("{", "["): - depth += 1 - elif char in ("}", "]"): - if depth == mapping_depth: - break - depth -= 1 - elif char == "," and depth == mapping_depth: - break - index += 1 - return _unquote(line[start:index].strip()) - - -def _has_unsupported_block_mapping_key(line: str) -> bool: - candidate = line.lstrip() - if candidate.startswith("-") and not candidate.startswith("---"): - candidate = candidate[1:].lstrip() - if not candidate: - return False - if candidate[0] in "!&*": - return True - if candidate[0] not in ("'", '"'): - return False - - quote = candidate[0] - escaped = False - index = 1 - while index < len(candidate): - char = candidate[index] - if quote == "'" and char == "'" and index + 1 < len(candidate) and candidate[index + 1] == "'": - index += 2 - continue - if escaped: - escaped = False - elif quote == '"' and char == "\\": - escaped = True - elif char == quote: - return False - index += 1 - return True - - -def _flow_uses_references(line: str, initial_depth: int) -> tuple[list[str], int]: - references = [] - depth = initial_depth - index = 0 - while index < len(line): - char = line[index] - if char in ("'", '"'): - quote = char - escaped = False - end = index + 1 - while end < len(line): - quoted_char = line[end] - if escaped: - escaped = False - elif quoted_char == "\\" and quote == '"': - escaped = True - elif quoted_char == quote: - break - end += 1 - if end >= len(line): - if depth > 0: - references.append("") - return references, depth - key = _decode_quoted_yaml_string(line[index : end + 1]) - after_key = end + 1 - while after_key < len(line) and line[after_key].isspace(): - after_key += 1 - if depth > 0 and after_key < len(line) and line[after_key] == ":": - if key == "uses": - references.append(_flow_value(line, after_key + 1, depth)) - elif key is None: - references.append("") - index = end + 1 - continue - if line.startswith("${{", index): - expression_end = line.find("}}", index + 3) - if expression_end < 0: - references.append("") - return references, depth - index = expression_end + 2 - continue - if char in ("{", "["): - depth += 1 - index += 1 - continue - if char in ("}", "]"): - depth = max(0, depth - 1) - index += 1 - continue - if depth > 0 and char == "?": - references.append("") - index += 1 - continue - if depth > 0 and char in "!&*": - references.append("") - index += 1 - continue - if depth > 0 and (char.isalpha() or char == "_"): - end = index + 1 - while end < len(line) and (line[end].isalnum() or line[end] in "_-"): - end += 1 - after_key = end - while after_key < len(line) and line[after_key].isspace(): - after_key += 1 - if line[index:end] == "uses" and after_key < len(line) and line[after_key] == ":": - references.append(_flow_value(line, after_key + 1, depth)) - index = end - continue - index += 1 - return references, depth +def iter_action_files(directory: Path = ACTIONS): + """Local composite actions run in the same trust boundary as the workflows.""" + yield from sorted((*directory.glob("*/action.yml"), *directory.glob("*/action.yaml"))) def iter_uses_references(path: Path): - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content: list[str] = [] - explicit_uses_line = None - flow_start_line = None - flow_depth = 0 - for line_number, raw_line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1): - stripped = raw_line.lstrip() - indent = len(raw_line) - len(stripped) - if block_parent_indent is not None: - if not stripped: - if block_uses_line is not None: - block_uses_content.append("") - continue - if indent <= block_parent_indent: - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content = [] - elif block_content_indent is None: - block_content_indent = indent - if block_uses_line is not None: - block_uses_content.append(raw_line[block_content_indent:]) - continue - elif indent >= block_content_indent: - if block_uses_line is not None: - block_uses_content.append(raw_line[block_content_indent:]) - continue - else: - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() - block_parent_indent = None - block_content_indent = None - block_uses_line = None - block_uses_content = [] - if stripped.startswith("#") or not stripped: - continue - active_line = _strip_yaml_comment(raw_line) - if explicit_uses_line is not None: - explicit_value = EXPLICIT_VALUE_RE.match(active_line) - if explicit_value is None: - yield explicit_uses_line, "" - else: - value = explicit_value.group("value").strip() - if BLOCK_SCALAR_VALUE_RE.fullmatch(value): - block_parent_indent = indent - block_content_indent = None - block_uses_line = explicit_uses_line - block_uses_content = [] - else: - yield explicit_uses_line, _unquote(value) - explicit_uses_line = None - continue - explicit_uses_line = None - explicit_key = EXPLICIT_KEY_RE.match(active_line) - if explicit_key: - if _unquote(explicit_key.group("key")) == "uses": - explicit_uses_line = line_number - continue - if re.match(r"^\s*(?:-\s*)?\?", active_line): - yield line_number, "" - continue - if _has_unsupported_block_mapping_key(active_line): - yield line_number, "" - continue - match = MAPPING_RE.match(active_line) if flow_depth == 0 else None - if match: - key = _unquote(match.group("key")) - value = match.group("value").strip() - if BLOCK_SCALAR_VALUE_RE.fullmatch(value): - block_parent_indent = indent - block_content_indent = None - if key == "uses": - block_uses_line = line_number - block_uses_content = [] - continue - if key == "uses": - yield line_number, _unquote(value) - if BLOCK_SCALAR_RE.search(raw_line): - block_parent_indent = indent - block_content_indent = None - continue - previous_flow_depth = flow_depth - flow_references, flow_depth = _flow_uses_references(active_line, flow_depth) - for reference in flow_references: - yield line_number, reference - if previous_flow_depth == 0 and flow_depth > 0: - flow_start_line = line_number - elif flow_depth == 0: - flow_start_line = None - if explicit_uses_line is not None: - yield explicit_uses_line, "" - if flow_depth > 0: - yield flow_start_line or 1, "" - if block_uses_line is not None: - yield block_uses_line, "\n".join(block_uses_content).strip() + return workflow_yaml.iter_uses_references(path.read_text(encoding="utf-8")) def validate_reference(reference: str) -> str | None: @@ -349,7 +34,11 @@ def validate_reference(reference: str) -> str | None: def main(argv: list[str] | None = None) -> int: args = list(sys.argv[1:] if argv is None else argv) - paths = [Path(value) for value in args] if args else list(iter_workflow_files()) + paths = ( + [Path(value) for value in args] + if args + else [*workflow_yaml.iter_workflow_files(WORKFLOWS), *iter_action_files()] + ) violations = [] for path in paths: for line_number, reference in iter_uses_references(path): diff --git a/scripts/check_workflow_security.py b/scripts/check_workflow_security.py index 03811133..cf6f9703 100755 --- a/scripts/check_workflow_security.py +++ b/scripts/check_workflow_security.py @@ -5,12 +5,13 @@ from pathlib import Path import re import sys +from workflow_yaml import iter_uses_references, iter_workflow_files, scalar + ROOT = Path(__file__).resolve().parents[1] WORKFLOWS = ROOT / ".github" / "workflows" CI_WORKFLOW = Path(".github/workflows/ci.yml") FULL_SHA = re.compile(r"[0-9a-f]{40}") -USES_LINE = re.compile(r"^\s*(?:-\s+)?(?:uses|'uses'|\"uses\")\s*:\s*(.*?)\s*$") def _active_text(text: str) -> str: @@ -19,34 +20,6 @@ def _active_text(text: str) -> str: ) -def _scalar(value: str) -> str: - """Remove an inline YAML comment and matching scalar quotes.""" - quote: str | None = None - escaped = False - end = len(value) - for index, character in enumerate(value): - if escaped: - escaped = False - continue - if quote == '"' and character == "\\": - escaped = True - continue - if character in ("'", '"'): - if quote is None: - quote = character - elif quote == character: - quote = None - elif character == "#" and quote is None and ( - index == 0 or value[index - 1].isspace() - ): - end = index - break - result = value[:end].strip() - if len(result) >= 2 and result[0] == result[-1] and result[0] in ("'", '"'): - return result[1:-1] - return result - - def _has_trigger(text: str, event: str) -> bool: lines = text.splitlines() on_key = r"""(?:on|'on'|"on")""" @@ -61,7 +34,7 @@ def _has_trigger(text: str, event: str) -> bool: match = re.fullmatch(rf"{on_key}:\s*(.+?)\s*", line) if match is not None and re.search( rf"""(?:^|[\[{{,\s])['"]?{re.escape(event)}['"]?(?:$|[\]}},\s:])""", - _scalar(match.group(1)), + scalar(match.group(1)), ): return True return False @@ -104,7 +77,7 @@ def _check_fail_open(path: Path, text: str) -> list[str]: r"""^\s*(?:-\s+)?(?:continue-on-error|'continue-on-error'|"continue-on-error")\s*:\s*(.*?)\s*$""", line, ) - if match is not None and _scalar(match.group(1)).lower() != "false": + if match is not None and scalar(match.group(1)).lower() != "false": errors.append(f"{path}:{line_number}: continue-on-error must remain false") if re.search(r"\|\|\s*true(?:\s|$)", line): errors.append(f"{path}:{line_number}: command must not suppress failure with || true") @@ -121,26 +94,22 @@ def check_workflow(path: Path, text: str) -> list[str]: pull_request = _has_trigger(active, "pull_request") lines = active.splitlines() - for line_index, line in enumerate(lines): - match = USES_LINE.match(line) - if match is None: - continue - reference = _scalar(match.group(1)) + for line_number, reference in iter_uses_references(active): if reference.startswith("./"): continue action, separator, ref = reference.rpartition("@") if not separator or not action or FULL_SHA.fullmatch(ref) is None: errors.append( - f"{path}:{line_index + 1}: external action must use a full commit SHA: {reference}" + f"{path}:{line_number}: external action must use a full commit SHA: {reference}" ) if pull_request and action == "actions/checkout": - step = _step_block(lines, line_index) + step = _step_block(lines, line_number - 1) if re.search( r"""(?mi)^\s+(?:persist-credentials|'persist-credentials'|"persist-credentials")\s*:\s*['"]?false['"]?\s*(?:#.*)?$""", step, ) is None: errors.append( - f"{path}:{line_index + 1}: pull-request checkout must discard GitHub credentials" + f"{path}:{line_number}: pull-request checkout must discard GitHub credentials" ) if re.search( @@ -163,7 +132,7 @@ def check_workflow(path: Path, text: str) -> list[str]: def main() -> int: errors: list[str] = [] - for path in sorted((*WORKFLOWS.glob("*.yml"), *WORKFLOWS.glob("*.yaml"))): + for path in iter_workflow_files(WORKFLOWS): errors.extend(check_workflow(path.relative_to(ROOT), path.read_text(encoding="utf-8"))) if errors: diff --git a/scripts/ci_checks.sh b/scripts/ci_checks.sh index 4ed46376..a4bf1622 100755 --- a/scripts/ci_checks.sh +++ b/scripts/ci_checks.sh @@ -82,29 +82,7 @@ fi # 4. Workflow and script regression guards section "workflow and script guards" -if 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/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/verify_runtime_inputs.py && - python3 scripts/test_verify_runtime_inputs.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/check_update_packages_workflow.py && - python3 scripts/test_pubspec_version.py && - python3 scripts/test_clean_translations.py && - python3 scripts/test_run_maestro.py && - python3 scripts/test_maestro_flow_contracts.py && - python3 scripts/test_maestro_jellyfin_proxy.py && - python3 scripts/test_check_icon_consistency.py; then +if bash scripts/ci_guard_checks.sh; then ok "workflow and script guards passed" else fail "workflow or script guard failed" diff --git a/scripts/ci_guard_checks.sh b/scripts/ci_guard_checks.sh new file mode 100644 index 00000000..be182dbb --- /dev/null +++ b/scripts/ci_guard_checks.sh @@ -0,0 +1,30 @@ +#!/usr/bin/env bash +# Workflow and script regression guards. +# +# Single source of truth for the guard roster, shared by the "Verify workflow +# and script guards" step in .github/workflows/ci.yml and section 4 of +# scripts/ci_checks.sh. The checkers are named explicitly because a few of them +# belong to other jobs (check_bun_audit.py needs Bun, check_codegen.py runs via +# codegen.sh), but their regression tests are discovered by glob so a newly +# added scripts/test_*.py is picked up automatically instead of having to be +# remembered in two places. +set -euo pipefail +shopt -s nullglob + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +for checker in \ + scripts/check_build_workflow.py \ + scripts/check_apple_spm_locks.py \ + scripts/verify_runtime_inputs.py \ + scripts/check_workflow_security.py \ + scripts/check_workflow_action_pins.py \ + scripts/check_container_image_pins.py \ + scripts/check_update_packages_workflow.py; do + python3 "$checker" +done + +for guard_test in scripts/test_*.py; do + python3 "$guard_test" +done diff --git a/scripts/workflow_yaml.py b/scripts/workflow_yaml.py new file mode 100644 index 00000000..91abc154 --- /dev/null +++ b/scripts/workflow_yaml.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +"""Shared YAML scanning for the GitHub Actions guard scripts. + +The guards deliberately avoid a YAML dependency, so the scalar plumbing and the +`uses:` scanner live here once rather than being re-implemented, with differing +rigor, in every checker. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +MAPPING_RE = re.compile( + r"""^\s*(?:-\s*)?(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*:\s*(?P.*?)\s*$""" +) +EXPLICIT_KEY_RE = re.compile( + r"""^\s*(?:-\s*)?\?\s*(?Puses|'(?:''|[^'])*'|"(?:\\.|[^"\\])*")\s*$""" +) +EXPLICIT_VALUE_RE = re.compile(r"^\s*:\s*(?P.*?)\s*$") +BLOCK_SCALAR_RE = re.compile(r":\s*[|>](?:[1-9][+-]?|[+-][1-9]?)?\s*(?:#.*)?$") +BLOCK_SCALAR_VALUE_RE = re.compile(r"^[|>](?:[1-9][+-]?|[+-][1-9]?)?$") +YAML_DOUBLE_ESCAPES = { + "0": "\0", + "a": "\a", + "b": "\b", + "t": "\t", + "\t": "\t", + "n": "\n", + "v": "\v", + "f": "\f", + "r": "\r", + "e": "\x1b", + " ": " ", + '"': '"', + "/": "/", + "\\": "\\", + "N": "\u0085", + "_": "\u00a0", + "L": "\u2028", + "P": "\u2029", +} + + +def iter_workflow_files(directory: Path): + yield from sorted((*directory.glob("*.yml"), *directory.glob("*.yaml"))) + + +def job_block(text: str, name: str) -> str: + """Return the YAML block of a top-level job, or "" when it is absent.""" + match = re.search( + rf"(?ms)^ {re.escape(name)}:\n(.*?)(?=^ [a-zA-Z0-9_-]+:\n|\Z)", text + ) + return match.group(0) if match else "" + + +def strip_comment(value: str) -> str: + quote = None + escaped = False + for index, char in enumerate(value): + if escaped: + escaped = False + continue + if char == "\\" and quote == '"': + escaped = True + continue + if char in ("'", '"'): + if quote is None: + quote = char + elif quote == char: + quote = None + continue + if char == "#" and quote is None and (index == 0 or value[index - 1].isspace()): + return value[:index].rstrip() + return value.rstrip() + + +def _decode_quoted_yaml_string(value: str) -> str | None: + if len(value) < 2 or value[0] != value[-1] or value[0] not in ("'", '"'): + return None + if value[0] == "'": + return value[1:-1].replace("''", "'") + + decoded = [] + index = 1 + end = len(value) - 1 + while index < end: + char = value[index] + if char != "\\": + decoded.append(char) + index += 1 + continue + index += 1 + if index >= end: + return None + escape = value[index] + if escape in YAML_DOUBLE_ESCAPES: + decoded.append(YAML_DOUBLE_ESCAPES[escape]) + index += 1 + continue + width = {"x": 2, "u": 4, "U": 8}.get(escape) + if width is None or index + width >= end: + return None + digits = value[index + 1 : index + 1 + width] + if not re.fullmatch(rf"[0-9a-fA-F]{{{width}}}", digits): + return None + try: + decoded.append(chr(int(digits, 16))) + except ValueError: + return None + index += width + 1 + return "".join(decoded) + + +def unquote(value: str) -> str: + decoded = _decode_quoted_yaml_string(value) + return value if decoded is None else decoded + + +def scalar(value: str) -> str: + """Read a single-line YAML scalar: drop an inline comment and its quotes.""" + return unquote(strip_comment(value).strip()) + + +def _flow_value(line: str, start: int, mapping_depth: int) -> str: + index = start + quote = None + escaped = False + depth = mapping_depth + while index < len(line): + char = line[index] + if escaped: + escaped = False + elif char == "\\" and quote == '"': + escaped = True + elif quote is not None: + if char == quote: + quote = None + elif char in ("'", '"'): + quote = char + elif char in ("{", "["): + depth += 1 + elif char in ("}", "]"): + if depth == mapping_depth: + break + depth -= 1 + elif char == "," and depth == mapping_depth: + break + index += 1 + return unquote(line[start:index].strip()) + + +def _has_unsupported_block_mapping_key(line: str) -> bool: + candidate = line.lstrip() + if candidate.startswith("-") and not candidate.startswith("---"): + candidate = candidate[1:].lstrip() + if not candidate: + return False + if candidate[0] in "!&*": + return True + if candidate[0] not in ("'", '"'): + return False + + quote = candidate[0] + escaped = False + index = 1 + while index < len(candidate): + char = candidate[index] + if quote == "'" and char == "'" and index + 1 < len(candidate) and candidate[index + 1] == "'": + index += 2 + continue + if escaped: + escaped = False + elif quote == '"' and char == "\\": + escaped = True + elif char == quote: + return False + index += 1 + return True + + +def _flow_uses_references(line: str, initial_depth: int) -> tuple[list[str], int]: + references = [] + depth = initial_depth + index = 0 + while index < len(line): + char = line[index] + if char in ("'", '"'): + quote = char + escaped = False + end = index + 1 + while end < len(line): + quoted_char = line[end] + if escaped: + escaped = False + elif quoted_char == "\\" and quote == '"': + escaped = True + elif quoted_char == quote: + break + end += 1 + if end >= len(line): + if depth > 0: + references.append("") + return references, depth + key = _decode_quoted_yaml_string(line[index : end + 1]) + after_key = end + 1 + while after_key < len(line) and line[after_key].isspace(): + after_key += 1 + if depth > 0 and after_key < len(line) and line[after_key] == ":": + if key == "uses": + references.append(_flow_value(line, after_key + 1, depth)) + elif key is None: + references.append("") + index = end + 1 + continue + if line.startswith("${{", index): + expression_end = line.find("}}", index + 3) + if expression_end < 0: + references.append("") + return references, depth + index = expression_end + 2 + continue + if char in ("{", "["): + depth += 1 + index += 1 + continue + if char in ("}", "]"): + depth = max(0, depth - 1) + index += 1 + continue + if depth > 0 and char == "?": + references.append("") + index += 1 + continue + if depth > 0 and char in "!&*": + references.append("") + index += 1 + continue + if depth > 0 and (char.isalpha() or char == "_"): + end = index + 1 + while end < len(line) and (line[end].isalnum() or line[end] in "_-"): + end += 1 + after_key = end + while after_key < len(line) and line[after_key].isspace(): + after_key += 1 + if line[index:end] == "uses" and after_key < len(line) and line[after_key] == ":": + references.append(_flow_value(line, after_key + 1, depth)) + index = end + continue + index += 1 + return references, depth + + +def iter_uses_references(text: str): + """Yield (line number, reference) for every `uses:` value in a workflow. + + Constructs the scanner cannot resolve are yielded as `<...>` placeholders so + that callers fail closed rather than silently skipping an unpinned action. + """ + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content: list[str] = [] + explicit_uses_line = None + flow_start_line = None + flow_depth = 0 + for line_number, raw_line in enumerate(text.splitlines(), 1): + stripped = raw_line.lstrip() + indent = len(raw_line) - len(stripped) + if block_parent_indent is not None: + if not stripped: + if block_uses_line is not None: + block_uses_content.append("") + continue + if indent <= block_parent_indent: + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content = [] + elif block_content_indent is None: + block_content_indent = indent + if block_uses_line is not None: + block_uses_content.append(raw_line[block_content_indent:]) + continue + elif indent >= block_content_indent: + if block_uses_line is not None: + block_uses_content.append(raw_line[block_content_indent:]) + continue + else: + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() + block_parent_indent = None + block_content_indent = None + block_uses_line = None + block_uses_content = [] + if stripped.startswith("#") or not stripped: + continue + active_line = strip_comment(raw_line) + if explicit_uses_line is not None: + explicit_value = EXPLICIT_VALUE_RE.match(active_line) + if explicit_value is None: + yield explicit_uses_line, "" + else: + value = explicit_value.group("value").strip() + if BLOCK_SCALAR_VALUE_RE.fullmatch(value): + block_parent_indent = indent + block_content_indent = None + block_uses_line = explicit_uses_line + block_uses_content = [] + else: + yield explicit_uses_line, unquote(value) + explicit_uses_line = None + continue + explicit_uses_line = None + explicit_key = EXPLICIT_KEY_RE.match(active_line) + if explicit_key: + if unquote(explicit_key.group("key")) == "uses": + explicit_uses_line = line_number + continue + if re.match(r"^\s*(?:-\s*)?\?", active_line): + yield line_number, "" + continue + if _has_unsupported_block_mapping_key(active_line): + yield line_number, "" + continue + match = MAPPING_RE.match(active_line) if flow_depth == 0 else None + if match: + key = unquote(match.group("key")) + value = match.group("value").strip() + if BLOCK_SCALAR_VALUE_RE.fullmatch(value): + block_parent_indent = indent + block_content_indent = None + if key == "uses": + block_uses_line = line_number + block_uses_content = [] + continue + if key == "uses": + yield line_number, unquote(value) + if BLOCK_SCALAR_RE.search(raw_line): + block_parent_indent = indent + block_content_indent = None + continue + previous_flow_depth = flow_depth + flow_references, flow_depth = _flow_uses_references(active_line, flow_depth) + for reference in flow_references: + yield line_number, reference + if previous_flow_depth == 0 and flow_depth > 0: + flow_start_line = line_number + elif flow_depth == 0: + flow_start_line = None + if explicit_uses_line is not None: + yield explicit_uses_line, "" + if flow_depth > 0: + yield flow_start_line or 1, "" + if block_uses_line is not None: + yield block_uses_line, "\n".join(block_uses_content).strip() diff --git a/server/artifact_store.go b/server/artifact_store.go new file mode 100644 index 00000000..1c11f33f --- /dev/null +++ b/server/artifact_store.go @@ -0,0 +1,434 @@ +package main + +import ( + "crypto/rand" + "errors" + "io/fs" + "log" + "math/big" + "os" + "path/filepath" + "strings" + "sync" + "syscall" + "time" +) + +// --- Artifact store --- +// +// Uploaded logs and posters are the same on-disk artifact store: a flat +// directory of `` files whose mtime carries the creation time, written +// through a temp file, capped by a quota and swept for expiry. Files that +// cannot be deleted become pending debt so a failed removal never silently +// frees quota. artifactStore implements all of that once; the two flavours +// differ only in the policy fields below. + +type artifactRemovalError struct { + err error +} + +func (e *artifactRemovalError) Error() string { + return "artifact removal failed" +} + +func (e *artifactRemovalError) Unwrap() error { + return e.err +} + +var errArtifactOutsideStore = errors.New("artifact path outside store") + +func classifyRemovalError(err error) error { + if err == nil || errors.Is(err, fs.ErrNotExist) { + return nil + } + return err +} + +func removeArtifact(removeFile func(string) error, root, path string) error { + err := classifyRemovalError(removeFile(path)) + if err == nil { + return nil + } + if !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) { + return &artifactRemovalError{err: err} + } + if err := removeConfinedDirectory(root, path); err != nil { + return &artifactRemovalError{err: err} + } + return nil +} + +func removeConfinedDirectory(root, path string) error { + info, err := os.Lstat(path) + if err != nil { + return classifyRemovalError(err) + } + if !info.IsDir() { + return syscall.ENOTDIR + } + + rootPath, err := filepath.Abs(root) + if err != nil { + return errArtifactOutsideStore + } + rootPath, err = filepath.EvalSymlinks(rootPath) + if err != nil { + return errArtifactOutsideStore + } + artifactPath, err := filepath.Abs(path) + if err != nil { + return errArtifactOutsideStore + } + artifactPath, err = filepath.EvalSymlinks(artifactPath) + if err != nil { + return errArtifactOutsideStore + } + relative, err := filepath.Rel(rootPath, artifactPath) + if err != nil || + relative == "." || + relative == ".." || + filepath.IsAbs(relative) || + strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return errArtifactOutsideStore + } + return os.RemoveAll(artifactPath) +} + +const idChars = "abcdefghijklmnopqrstuvwxyz0123456789" + +func generateID(length int) string { + b := make([]byte, length) + for i := range b { + n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(idChars)))) + b[i] = idChars[n.Int64()] + } + return string(b) +} + +func validID(id string, length int) bool { + if len(id) != length { + return false + } + for _, ch := range id { + if !strings.ContainsRune(idChars, ch) { + return false + } + } + return true +} + +// pendingRemoval is an artifact file that could not be deleted yet. Its size is +// unknown when the file could not be stat'ed or is not a regular file. +type pendingRemoval struct { + size int64 + sizeKnown bool +} + +type artifactEntry struct { + Filename string + Size int64 + ContentType string + CreatedAt time.Time + ExpiresAt time.Time +} + +type artifactStore struct { + entries map[string]artifactEntry + pendingRemovals map[string]pendingRemoval + dir string + name string // log prefix, e.g. "logs" + maxAge time.Duration + removeFile func(string) error + + // Policy. generateID is a field so tests can force ID collisions. + generateID func() string + idFromFilename func(filename string) (string, bool) + // acceptLoaded reports whether a file found on disk is a usable artifact + // and returns the content type recorded for it. + acceptLoaded func(filename string, size int64) (string, bool) + // limit caps accountedLocked, measured in the units cost returns: + // one per artifact for logs, bytes for posters. + limit int64 + cost func(size int64) int64 + pendingCost func(pending pendingRemoval) int64 + // evictToFit admits a new artifact by evicting the oldest live ones; + // stores that leave it false reject the upload with errFull instead. + evictToFit bool + // retryKnownDebtOnPut retries only debt whose size is accounted, leaving + // unknown debt to periodic cleanup. + retryKnownDebtOnPut bool + errFull error + + used int64 // accounted cost of live entries + pendingDebt int64 // accounted cost of pending removals + unknownPending int // pending removals kept out of the quota + startupErr error + mu sync.RWMutex +} + +func (as *artifactStore) filePath(filename string) string { + return filepath.Join(as.dir, filename) +} + +func (as *artifactStore) accountedLocked() int64 { + return as.used + as.pendingDebt +} + +func (as *artifactStore) loadExisting(now time.Time) error { + as.mu.Lock() + defer as.mu.Unlock() + + files, err := os.ReadDir(as.dir) + if err != nil { + log.Printf("%s: failed to read dir %s: %v", as.name, as.dir, err) + return nil + } + var removalErr error + drop := func(file fs.DirEntry) { + size, sizeKnown := dirEntrySize(file) + removalErr = errors.Join(removalErr, as.removeUntrackedLocked(file.Name(), size, sizeKnown)) + } + for _, file := range files { + filename := file.Name() + if file.IsDir() || strings.HasSuffix(filename, ".tmp") { + drop(file) + continue + } + id, ok := as.idFromFilename(filename) + if !ok { + drop(file) + continue + } + info, infoErr := file.Info() + if infoErr != nil || !info.Mode().IsRegular() { + drop(file) + continue + } + contentType, ok := as.acceptLoaded(filename, info.Size()) + if !ok { + drop(file) + continue + } + // Several extensions can map to one id; keep the first and drop the rest. + if _, duplicate := as.entries[id]; duplicate { + drop(file) + continue + } + createdAt := info.ModTime() + as.entries[id] = artifactEntry{ + Filename: filename, + Size: info.Size(), + ContentType: contentType, + CreatedAt: createdAt, + ExpiresAt: createdAt.Add(as.maxAge), + } + as.used += as.cost(info.Size()) + } + removalErr = errors.Join(removalErr, as.cleanupExpiredLocked(now)) + removalErr = errors.Join(removalErr, as.evictOldestLocked(0)) + return removalErr +} + +// put writes data as `` once the quota allows it. +func (as *artifactStore) put(data []byte, ext, contentType string, now time.Time) (string, artifactEntry, error) { + as.mu.Lock() + defer as.mu.Unlock() + + size := int64(len(data)) + cost := as.cost(size) + // Reclaim what the quota can get back without touching live entries. + // Removal failures stay accounted as debt instead of blocking the write. + _ = as.retryPendingLocked(as.retryKnownDebtOnPut) + _ = as.cleanupExpiredLocked(now) + var headroom int64 + if as.evictToFit { + headroom = cost + } + if err := as.evictOldestLocked(headroom); err != nil { + return "", artifactEntry{}, err + } + if as.accountedLocked()+cost > as.limit { + return "", artifactEntry{}, as.errFull + } + + id := as.generateID() + for { + if _, exists := as.entries[id]; !exists { + if _, err := os.Stat(as.filePath(id + ext)); errors.Is(err, fs.ErrNotExist) { + break + } + } + id = as.generateID() + } + + filename := id + ext + path := as.filePath(filename) + tmpPath := path + ".tmp" + if err := os.WriteFile(tmpPath, data, 0644); err != nil { + as.cleanupFailedTempLocked(tmpPath) + return "", artifactEntry{}, err + } + if err := os.Rename(tmpPath, path); err != nil { + as.cleanupFailedTempLocked(tmpPath) + return "", artifactEntry{}, err + } + _ = os.Chtimes(path, now, now) + + entry := artifactEntry{ + Filename: filename, + Size: size, + ContentType: contentType, + CreatedAt: now, + ExpiresAt: now.Add(as.maxAge), + } + as.entries[id] = entry + as.used += cost + return id, entry, nil +} + +// lookupEntry returns the live entry for id, dropping it when it has expired. +// match, when set, rejects entries the caller did not ask for before expiry is +// considered, so a mismatched request never triggers a removal. +func (as *artifactStore) lookupEntry( + id string, + now time.Time, + match func(artifactEntry) bool, +) (artifactEntry, bool, error) { + as.mu.Lock() + defer as.mu.Unlock() + entry, ok := as.entries[id] + if !ok || (match != nil && !match(entry)) { + return artifactEntry{}, false, nil + } + if !now.Before(entry.ExpiresAt) { + if err := as.deleteEntryLocked(id); err != nil { + return artifactEntry{}, false, err + } + return artifactEntry{}, false, nil + } + return entry, true, nil +} + +func (as *artifactStore) cleanup(now time.Time) error { + as.mu.Lock() + defer as.mu.Unlock() + return as.cleanupLocked(now) +} + +func (as *artifactStore) cleanupLocked(now time.Time) error { + removalErr := as.retryPendingLocked(false) + removalErr = errors.Join(removalErr, as.cleanupExpiredLocked(now)) + return errors.Join(removalErr, as.evictOldestLocked(0)) +} + +func (as *artifactStore) cleanupExpiredLocked(now time.Time) error { + var removalErr error + for id, entry := range as.entries { + if !now.Before(entry.ExpiresAt) { + removalErr = errors.Join(removalErr, as.deleteEntryLocked(id)) + } + } + return removalErr +} + +// evictOldestLocked deletes oldest-first until headroom more cost units fit. +func (as *artifactStore) evictOldestLocked(headroom int64) error { + for as.accountedLocked()+headroom > as.limit && len(as.entries) > 0 { + var oldestID string + var oldest artifactEntry + for id, entry := range as.entries { + if oldestID == "" || entry.CreatedAt.Before(oldest.CreatedAt) { + oldestID = id + oldest = entry + } + } + if err := as.deleteEntryLocked(oldestID); err != nil { + return err + } + } + return nil +} + +func (as *artifactStore) deleteEntryLocked(id string) error { + entry, ok := as.entries[id] + if !ok { + return nil + } + if err := removeArtifact(as.removeFile, as.dir, as.filePath(entry.Filename)); err != nil { + return err + } + delete(as.entries, id) + as.used -= as.cost(entry.Size) + return nil +} + +// removeUntrackedLocked deletes a file the index does not own, recording it as +// pending debt when the removal fails. +func (as *artifactStore) removeUntrackedLocked(filename string, size int64, sizeKnown bool) error { + if err := removeArtifact(as.removeFile, as.dir, as.filePath(filename)); err != nil { + as.addPendingLocked(filename, size, sizeKnown) + return err + } + as.dropPendingLocked(filename) + return nil +} + +func (as *artifactStore) retryPendingLocked(knownDebtOnly bool) error { + var removalErr error + for filename, pending := range as.pendingRemovals { + if knownDebtOnly && !pending.sizeKnown { + continue + } + if err := removeArtifact(as.removeFile, as.dir, as.filePath(filename)); err != nil { + removalErr = errors.Join(removalErr, err) + continue + } + as.dropPendingLocked(filename) + } + return removalErr +} + +func (as *artifactStore) cleanupFailedTempLocked(tmpPath string) { + size, sizeKnown := fileSize(tmpPath) + _ = as.removeUntrackedLocked(filepath.Base(tmpPath), size, sizeKnown) +} + +func (as *artifactStore) addPendingLocked(filename string, size int64, sizeKnown bool) { + if _, exists := as.pendingRemovals[filename]; exists { + return + } + pending := pendingRemoval{size: size, sizeKnown: sizeKnown} + as.pendingRemovals[filename] = pending + as.pendingDebt += as.pendingCost(pending) + if !sizeKnown { + as.unknownPending++ + } +} + +func (as *artifactStore) dropPendingLocked(filename string) { + pending, exists := as.pendingRemovals[filename] + if !exists { + return + } + delete(as.pendingRemovals, filename) + as.pendingDebt -= as.pendingCost(pending) + if !pending.sizeKnown { + as.unknownPending-- + } +} + +func dirEntrySize(file fs.DirEntry) (int64, bool) { + info, err := file.Info() + if err != nil || !info.Mode().IsRegular() { + return 0, false + } + return info.Size(), true +} + +func fileSize(path string) (int64, bool) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return 0, false + } + return info.Size(), true +} diff --git a/server/main.go b/server/main.go index 8ad3c545..c2a0acb7 100644 --- a/server/main.go +++ b/server/main.go @@ -13,7 +13,6 @@ import ( "io" "io/fs" "log" - "math/big" "net" "net/http" "os" @@ -383,100 +382,17 @@ func (r *Room) sendFrom(senderID string, sender *Client, targetID string, msg se } // --- Log store --- -type artifactRemovalError struct { - err error -} -func (e *artifactRemovalError) Error() string { - return "artifact removal failed" -} - -func (e *artifactRemovalError) Unwrap() error { - return e.err -} - -var errArtifactOutsideStore = errors.New("artifact path outside store") - -func classifyRemovalError(err error) error { - if err == nil || errors.Is(err, fs.ErrNotExist) { - return nil - } - return err -} - -func removeArtifact(removeFile func(string) error, root, path string) error { - err := classifyRemovalError(removeFile(path)) - if err == nil { - return nil - } - if !errors.Is(err, syscall.ENOTEMPTY) && !errors.Is(err, syscall.EEXIST) { - return &artifactRemovalError{err: err} - } - if err := removeConfinedDirectory(root, path); err != nil { - return &artifactRemovalError{err: err} - } - return nil -} - -func removeConfinedDirectory(root, path string) error { - info, err := os.Lstat(path) - if err != nil { - return classifyRemovalError(err) - } - if !info.IsDir() { - return syscall.ENOTDIR - } - - rootPath, err := filepath.Abs(root) - if err != nil { - return errArtifactOutsideStore - } - rootPath, err = filepath.EvalSymlinks(rootPath) - if err != nil { - return errArtifactOutsideStore - } - artifactPath, err := filepath.Abs(path) - if err != nil { - return errArtifactOutsideStore - } - artifactPath, err = filepath.EvalSymlinks(artifactPath) - if err != nil { - return errArtifactOutsideStore - } - relative, err := filepath.Rel(rootPath, artifactPath) - if err != nil || - relative == "." || - relative == ".." || - filepath.IsAbs(relative) || - strings.HasPrefix(relative, ".."+string(filepath.Separator)) { - return errArtifactOutsideStore - } - return os.RemoveAll(artifactPath) -} - -type pendingRemoval struct { - size int64 - sizeKnown bool -} - -type logEntry struct { - Size int - CreatedAt time.Time - ExpiresAt time.Time -} +const logFileExt = ".log" var errLogStoreFull = errors.New("log store full") +// logStore keeps diagnostic uploads capped by artifact count; a full store +// rejects new uploads rather than evicting logs someone may still be reading. type logStore struct { - entries map[string]logEntry - pendingRemovals map[string]pendingRemoval + artifactStore rateLimit map[string]time.Time // IP -> last upload time failedLookupRate map[string]*rateLimiter - dir string - generateID func() string - removeFile func(string) error - startupErr error - mu sync.RWMutex } func newLogStore(dir string) *logStore { @@ -488,31 +404,32 @@ func newLogStoreWithRemover(dir string, removeFile func(string) error) *logStore log.Fatalf("failed to create log dir %s: %v", dir, err) } ls := &logStore{ - entries: make(map[string]logEntry), - pendingRemovals: make(map[string]pendingRemoval), + artifactStore: artifactStore{ + entries: make(map[string]artifactEntry), + pendingRemovals: make(map[string]pendingRemoval), + dir: dir, + name: "logs", + maxAge: logMaxAge, + removeFile: removeFile, + generateID: generateLogID, + idFromFilename: logIDFromFilename, + acceptLoaded: func(_ string, size int64) (string, bool) { + return "", size > 0 && size <= maxLogSize + }, + limit: maxLogEntries, + cost: func(int64) int64 { return 1 }, + pendingCost: func(pendingRemoval) int64 { return 1 }, + errFull: errLogStoreFull, + }, rateLimit: make(map[string]time.Time), failedLookupRate: make(map[string]*rateLimiter), - dir: dir, - generateID: generateLogID, - removeFile: removeFile, } ls.startupErr = ls.loadExisting(time.Now()) return ls } func (ls *logStore) filePath(id string) string { - return filepath.Join(ls.dir, id+".log") -} - -const idChars = "abcdefghijklmnopqrstuvwxyz0123456789" - -func generateID(length int) string { - b := make([]byte, length) - for i := range b { - n, _ := rand.Int(rand.Reader, big.NewInt(int64(len(idChars)))) - b[i] = idChars[n.Int64()] - } - return string(b) + return ls.artifactStore.filePath(id + logFileExt) } func generateLogID() string { @@ -520,149 +437,28 @@ func generateLogID() string { } func logIDFromFilename(filename string) (string, bool) { - if filepath.Ext(filename) != ".log" { + if filepath.Ext(filename) != logFileExt { return "", false } - id := strings.TrimSuffix(filename, ".log") + id := strings.TrimSuffix(filename, logFileExt) return id, validID(id, logIDLength) } -func (ls *logStore) loadExisting(now time.Time) error { - ls.mu.Lock() - defer ls.mu.Unlock() - - files, err := os.ReadDir(ls.dir) - if err != nil { - log.Printf("logs: failed to read dir %s: %v", ls.dir, err) - return nil - } - var removalErr error - for _, file := range files { - filename := file.Name() - if file.IsDir() || strings.HasSuffix(filename, ".tmp") { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - id, ok := logIDFromFilename(filename) - if !ok { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - info, infoErr := file.Info() - if infoErr != nil || !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > maxLogSize { - removalErr = errors.Join(removalErr, ls.removeUntrackedLocked(filename)) - continue - } - createdAt := info.ModTime() - ls.entries[id] = logEntry{ - Size: int(info.Size()), - CreatedAt: createdAt, - ExpiresAt: createdAt.Add(logMaxAge), - } - } - removalErr = errors.Join(removalErr, ls.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ls.evictOldestLocked(maxLogEntries)) - return removalErr -} - -func (ls *logStore) removeUntrackedLocked(filename string) error { - if err := removeArtifact(ls.removeFile, ls.dir, filepath.Join(ls.dir, filename)); err != nil { - if _, exists := ls.pendingRemovals[filename]; !exists { - ls.pendingRemovals[filename] = pendingRemoval{} - } - return err - } - delete(ls.pendingRemovals, filename) - return nil -} - -func (ls *logStore) retryPendingLocked() error { - var removalErr error - for filename := range ls.pendingRemovals { - if err := removeArtifact(ls.removeFile, ls.dir, filepath.Join(ls.dir, filename)); err != nil { - removalErr = errors.Join(removalErr, err) - continue - } - delete(ls.pendingRemovals, filename) - } - return removalErr -} - -func (ls *logStore) cleanupFailedTempLocked(tmpPath string) { - _ = ls.removeUntrackedLocked(filepath.Base(tmpPath)) -} - -func (ls *logStore) artifactCountLocked() int { - return len(ls.entries) + len(ls.pendingRemovals) -} - -func (ls *logStore) store(data []byte, now time.Time) (string, logEntry, error) { +func (ls *logStore) store(data []byte, now time.Time) (string, artifactEntry, error) { if len(data) == 0 { - return "", logEntry{}, errors.New("empty log") + return "", artifactEntry{}, errors.New("empty log") } if len(data) > maxLogSize { - return "", logEntry{}, errors.New("log too large") + return "", artifactEntry{}, errors.New("log too large") } - - ls.mu.Lock() - defer ls.mu.Unlock() - _ = ls.retryPendingLocked() - _ = ls.cleanupExpiredLocked(now) - if err := ls.evictOldestLocked(maxLogEntries); err != nil { - return "", logEntry{}, err - } - if ls.artifactCountLocked() >= maxLogEntries { - return "", logEntry{}, errLogStoreFull - } - - id := ls.generateID() - for { - if _, exists := ls.entries[id]; !exists { - if _, err := os.Stat(ls.filePath(id)); errors.Is(err, fs.ErrNotExist) { - break - } - } - id = ls.generateID() - } - - path := ls.filePath(id) - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - ls.cleanupFailedTempLocked(tmpPath) - return "", logEntry{}, err - } - if err := os.Rename(tmpPath, path); err != nil { - ls.cleanupFailedTempLocked(tmpPath) - return "", logEntry{}, err - } - _ = os.Chtimes(path, now, now) - - entry := logEntry{ - Size: len(data), - CreatedAt: now, - ExpiresAt: now.Add(logMaxAge), - } - ls.entries[id] = entry - return id, entry, nil + return ls.put(data, logFileExt, "", now) } -func (ls *logStore) lookup(id string, now time.Time) (logEntry, bool, error) { +func (ls *logStore) lookup(id string, now time.Time) (artifactEntry, bool, error) { if !validID(id, logIDLength) { - return logEntry{}, false, nil + return artifactEntry{}, false, nil } - ls.mu.Lock() - defer ls.mu.Unlock() - entry, ok := ls.entries[id] - if !ok { - return logEntry{}, false, nil - } - if !now.Before(entry.ExpiresAt) { - if err := ls.deleteEntryLocked(id); err != nil { - return logEntry{}, false, err - } - return logEntry{}, false, nil - } - return entry, true, nil + return ls.lookupEntry(id, now, nil) } func (ls *logStore) allowFailedLookup(source string, now time.Time) bool { @@ -680,53 +476,10 @@ func (ls *logStore) allowFailedLookup(source string, now time.Time) bool { return limiter.allowAt(now) } -func (ls *logStore) cleanupExpiredLocked(now time.Time) error { - var removalErr error - for id, entry := range ls.entries { - if !now.Before(entry.ExpiresAt) { - removalErr = errors.Join(removalErr, ls.deleteEntryLocked(id)) - } - } - return removalErr -} - -func (ls *logStore) evictOldestLocked(limit int) error { - for ls.artifactCountLocked() > limit { - var oldestID string - var oldest logEntry - for id, entry := range ls.entries { - if oldestID == "" || entry.CreatedAt.Before(oldest.CreatedAt) { - oldestID = id - oldest = entry - } - } - if oldestID == "" { - return nil - } - if err := ls.deleteEntryLocked(oldestID); err != nil { - return err - } - } - return nil -} - -func (ls *logStore) deleteEntryLocked(id string) error { - if _, ok := ls.entries[id]; !ok { - return nil - } - if err := removeArtifact(ls.removeFile, ls.dir, ls.filePath(id)); err != nil { - return err - } - delete(ls.entries, id) - return nil -} - func (ls *logStore) cleanup(now time.Time) error { ls.mu.Lock() defer ls.mu.Unlock() - removalErr := ls.retryPendingLocked() - removalErr = errors.Join(removalErr, ls.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ls.evictOldestLocked(maxLogEntries)) + removalErr := ls.cleanupLocked(now) cleanupRateWindows(ls.rateLimit, now, logRateInterval) cleanupRateLimiters(ls.failedLookupRate, now, nil) return removalErr @@ -734,26 +487,12 @@ func (ls *logStore) cleanup(now time.Time) error { // --- Poster store --- -type posterEntry struct { - Filename string - Size int64 - ContentType string - CreatedAt time.Time - ExpiresAt time.Time -} +var errPosterStoreFull = errors.New("poster store full") +// posterStore caps shared posters by accounted bytes and evicts the oldest to +// admit a new upload. type posterStore struct { - entries map[string]posterEntry - pendingRemovals map[string]pendingRemoval - dir string - maxBytes int64 - maxAge time.Duration - totalBytes int64 - pendingBytes int64 - unknownPending int - removeFile func(string) error - startupErr error - mu sync.RWMutex + artifactStore } func newPosterStore(dir string, maxBytes int64, maxAge time.Duration) *posterStore { @@ -769,22 +508,37 @@ func newPosterStoreWithRemover( if err := os.MkdirAll(dir, 0755); err != nil { log.Fatalf("failed to create poster dir %s: %v", dir, err) } - ps := &posterStore{ - entries: make(map[string]posterEntry), + ps := &posterStore{artifactStore{ + entries: make(map[string]artifactEntry), pendingRemovals: make(map[string]pendingRemoval), dir: dir, - maxBytes: maxBytes, + name: "posters", maxAge: maxAge, removeFile: removeFile, - } + generateID: generatePosterID, + idFromFilename: posterIDFromFilename, + acceptLoaded: func(filename string, _ int64) (string, bool) { + return posterContentTypeForExt(filepath.Ext(filename)) + }, + limit: maxBytes, + cost: func(size int64) int64 { return size }, + pendingCost: func(pending pendingRemoval) int64 { + // Unknown debt cannot be sized safely, so it is kept out of the + // quota: a permanent directory or stat failure must not deny + // otherwise capacity-safe uploads. + if !pending.sizeKnown { + return 0 + } + return pending.size + }, + evictToFit: true, + retryKnownDebtOnPut: true, + errFull: errPosterStoreFull, + }} ps.startupErr = ps.loadExisting(time.Now()) return ps } -func (ps *posterStore) filePath(filename string) string { - return filepath.Join(ps.dir, filename) -} - func generatePosterID() string { return generateID(posterIDLength) } @@ -819,18 +573,6 @@ func posterContentTypeForExt(ext string) (string, bool) { } } -func validID(id string, length int) bool { - if len(id) != length { - return false - } - for _, ch := range id { - if !strings.ContainsRune(idChars, ch) { - return false - } - } - return true -} - func posterIDFromFilename(filename string) (string, bool) { if filename == "" || strings.ContainsAny(filename, `/\\`) { return "", false @@ -846,269 +588,29 @@ func posterIDFromFilename(filename string) (string, bool) { return id, true } -func (ps *posterStore) loadExisting(now time.Time) error { - ps.mu.Lock() - defer ps.mu.Unlock() - - files, err := os.ReadDir(ps.dir) - if err != nil { - log.Printf("posters: failed to read dir %s: %v", ps.dir, err) - return nil - } - var removalErr error - for _, file := range files { - filename := file.Name() - if file.IsDir() || strings.HasSuffix(filename, ".tmp") { - size, known := posterArtifactSize(file) - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, size, known), - ) - continue - } - id, ok := posterIDFromFilename(filename) - if !ok { - size, known := posterArtifactSize(file) - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, size, known), - ) - continue - } - info, infoErr := file.Info() - if infoErr != nil || !info.Mode().IsRegular() { - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, 0, false), - ) - continue - } - if _, duplicate := ps.entries[id]; duplicate { - removalErr = errors.Join( - removalErr, - ps.removeUntrackedLocked(filename, info.Size(), true), - ) - continue - } - createdAt := info.ModTime() - contentType, _ := posterContentTypeForExt(filepath.Ext(filename)) - entry := posterEntry{ - Filename: filename, - Size: info.Size(), - ContentType: contentType, - CreatedAt: createdAt, - ExpiresAt: createdAt.Add(ps.maxAge), - } - ps.entries[id] = entry - ps.totalBytes += entry.Size - } - removalErr = errors.Join(removalErr, ps.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ps.evictOldestLocked(0)) - return removalErr -} - -func posterArtifactSize(file fs.DirEntry) (int64, bool) { - info, err := file.Info() - if err != nil || !info.Mode().IsRegular() { - return 0, false - } - return info.Size(), true -} - -func (ps *posterStore) addPendingLocked(filename string, size int64, known bool) { - if _, exists := ps.pendingRemovals[filename]; exists { - return - } - ps.pendingRemovals[filename] = pendingRemoval{size: size, sizeKnown: known} - if known { - ps.pendingBytes += size - } else { - ps.unknownPending++ - } -} - -func (ps *posterStore) removeUntrackedLocked(filename string, size int64, known bool) error { - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(filename)); err != nil { - ps.addPendingLocked(filename, size, known) - return err - } - return nil -} - -func (ps *posterStore) retryPendingLocked(knownOnly bool) error { - var removalErr error - for filename, pending := range ps.pendingRemovals { - if knownOnly && !pending.sizeKnown { - continue - } - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(filename)); err != nil { - removalErr = errors.Join(removalErr, err) - continue - } - delete(ps.pendingRemovals, filename) - if pending.sizeKnown { - ps.pendingBytes -= pending.size - } else { - ps.unknownPending-- - } - } - return removalErr -} - -func (ps *posterStore) cleanupFailedTempLocked(tmpPath string) { - if err := removeArtifact(ps.removeFile, ps.dir, tmpPath); err == nil { - return - } - info, statErr := os.Stat(tmpPath) - known := statErr == nil && info.Mode().IsRegular() - var size int64 - if known { - size = info.Size() - } - ps.addPendingLocked(filepath.Base(tmpPath), size, known) -} - -func (ps *posterStore) accountedBytesLocked() int64 { - return ps.totalBytes + ps.pendingBytes -} - -func (ps *posterStore) store(data []byte, contentType string, now time.Time) (string, posterEntry, error) { +func (ps *posterStore) store(data []byte, contentType string, now time.Time) (string, artifactEntry, error) { entrySize := int64(len(data)) if entrySize <= 0 { - return "", posterEntry{}, errors.New("empty poster") + return "", artifactEntry{}, errors.New("empty poster") } - if entrySize > ps.maxBytes { - return "", posterEntry{}, errors.New("poster exceeds store size") + if entrySize > ps.limit { + return "", artifactEntry{}, errors.New("poster exceeds store size") } ext, ok := posterExtForContentType(contentType) if !ok { - return "", posterEntry{}, errors.New("unsupported poster type") + return "", artifactEntry{}, errors.New("unsupported poster type") } - - ps.mu.Lock() - defer ps.mu.Unlock() - - // Known regular-file debt counts against quota and is retried on demand. - // Unknown artifacts are left to periodic cleanup: their size cannot be - // accounted safely, and a permanent directory or stat failure must not - // deny otherwise capacity-safe uploads. - _ = ps.retryPendingLocked(true) - _ = ps.cleanupExpiredLocked(now) - if err := ps.evictOldestLocked(entrySize); err != nil { - return "", posterEntry{}, err - } - if ps.accountedBytesLocked()+entrySize > ps.maxBytes { - return "", posterEntry{}, errors.New("poster store full") - } - - id := generatePosterID() - for { - if _, exists := ps.entries[id]; !exists { - if _, err := os.Stat(ps.filePath(id + ext)); errors.Is(err, fs.ErrNotExist) { - break - } - } - id = generatePosterID() - } - - filename := id + ext - path := ps.filePath(filename) - tmpPath := path + ".tmp" - if err := os.WriteFile(tmpPath, data, 0644); err != nil { - ps.cleanupFailedTempLocked(tmpPath) - return "", posterEntry{}, err - } - if err := os.Rename(tmpPath, path); err != nil { - ps.cleanupFailedTempLocked(tmpPath) - return "", posterEntry{}, err - } - _ = os.Chtimes(path, now, now) - - entry := posterEntry{ - Filename: filename, - Size: entrySize, - ContentType: strings.ToLower(strings.SplitN(contentType, ";", 2)[0]), - CreatedAt: now, - ExpiresAt: now.Add(ps.maxAge), - } - ps.entries[id] = entry - ps.totalBytes += entry.Size - return id, entry, nil + return ps.put(data, ext, strings.ToLower(strings.SplitN(contentType, ";", 2)[0]), now) } -func (ps *posterStore) lookup(filename string, now time.Time) (posterEntry, bool, error) { +func (ps *posterStore) lookup(filename string, now time.Time) (artifactEntry, bool, error) { id, ok := posterIDFromFilename(filename) if !ok { - return posterEntry{}, false, nil + return artifactEntry{}, false, nil } - - ps.mu.Lock() - defer ps.mu.Unlock() - entry, ok := ps.entries[id] - if !ok || entry.Filename != filename { - return posterEntry{}, false, nil - } - if !now.Before(entry.ExpiresAt) { - if err := ps.deleteEntryLocked(id); err != nil { - return posterEntry{}, false, err - } - return posterEntry{}, false, nil - } - return entry, true, nil -} - -func (ps *posterStore) cleanup(now time.Time) error { - ps.mu.Lock() - defer ps.mu.Unlock() - removalErr := ps.retryPendingLocked(false) - removalErr = errors.Join(removalErr, ps.cleanupExpiredLocked(now)) - removalErr = errors.Join(removalErr, ps.evictOldestLocked(0)) - return removalErr -} - -func (ps *posterStore) cleanupExpiredLocked(now time.Time) error { - var removalErr error - for id, entry := range ps.entries { - if !now.Before(entry.ExpiresAt) { - removalErr = errors.Join(removalErr, ps.deleteEntryLocked(id)) - } - } - return removalErr -} - -func (ps *posterStore) evictOldestLocked(extraBytes int64) error { - for ps.accountedBytesLocked()+extraBytes > ps.maxBytes && len(ps.entries) > 0 { - var oldestID string - var oldest posterEntry - first := true - for id, entry := range ps.entries { - if first || entry.CreatedAt.Before(oldest.CreatedAt) { - oldestID = id - oldest = entry - first = false - } - } - if oldestID == "" { - return nil - } - if err := ps.deleteEntryLocked(oldestID); err != nil { - return err - } - } - return nil -} - -func (ps *posterStore) deleteEntryLocked(id string) error { - entry, ok := ps.entries[id] - if !ok { - return nil - } - if err := removeArtifact(ps.removeFile, ps.dir, ps.filePath(entry.Filename)); err != nil { - return err - } - delete(ps.entries, id) - ps.totalBytes -= entry.Size - return nil + return ps.lookupEntry(id, now, func(entry artifactEntry) bool { + return entry.Filename == filename + }) } // --- Snapshotter (single-writer, debounced, atomic disk persistence) --- @@ -1626,7 +1128,7 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { } type lookupResult struct { - entry logEntry + entry artifactEntry data []byte status int message string @@ -1688,7 +1190,7 @@ func (s *Server) handleGetLogs(w http.ResponseWriter, r *http.Request) { } w.Header().Set("Content-Type", "text/plain; charset=utf-8") - w.Header().Set("Content-Length", strconv.Itoa(lookup.entry.Size)) + w.Header().Set("Content-Length", strconv.FormatInt(lookup.entry.Size, 10)) if written, err := w.Write(lookup.data); err != nil || written != len(lookup.data) { log.Printf("logs: response write failed") } diff --git a/server/main_test.go b/server/main_test.go index af7d5e42..657a6bd3 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -4316,7 +4316,7 @@ func snapshotPosterStore(t *testing.T, store *posterStore) (int, int64, []string t.Helper() store.mu.RLock() entryCount := len(store.entries) - totalBytes := store.totalBytes + totalBytes := store.used store.mu.RUnlock() files, err := os.ReadDir(store.dir) if err != nil { @@ -4701,7 +4701,7 @@ func TestPosterStoreEvictsOldestOverQuota(t *testing.T) { ps.mu.RLock() _, hasFirst := ps.entries[id1] _, hasSecond := ps.entries[id2] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if hasFirst { @@ -4711,7 +4711,7 @@ func TestPosterStoreEvictsOldestOverQuota(t *testing.T) { t.Fatal("newest poster should remain") } if total != int64(len(payload)) { - t.Fatalf("totalBytes=%d want %d", total, len(payload)) + t.Fatalf("stored bytes=%d want %d", total, len(payload)) } if _, err := os.Stat(ps.filePath(entry1.Filename)); !os.IsNotExist(err) { t.Fatalf("oldest file still exists or stat failed unexpectedly: %v", err) @@ -4735,13 +4735,13 @@ func TestPosterStoreCleanupExpiresOldPosters(t *testing.T) { ps.mu.RLock() _, exists := ps.entries[id] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if exists { t.Fatal("expired poster should have been removed") } if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } if _, err := os.Stat(ps.filePath(entry.Filename)); !os.IsNotExist(err) { t.Fatalf("expired file still exists or stat failed unexpectedly: %v", err) @@ -4790,7 +4790,7 @@ func TestLogStoreRemovalFailureRetainsEntryUntilRetry(t *testing.T) { } ls.mu.RLock() _, indexed := ls.entries[id] - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !indexed || artifacts != 1 { t.Fatalf("failed removal changed metadata: indexed=%v artifacts=%d", indexed, artifacts) @@ -4927,7 +4927,7 @@ func TestLogStoreTracksFailedTempCleanup(t *testing.T) { } ls.mu.RLock() _, pending := ls.pendingRemovals[filepath.Base(tmpPath)] - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !pending || artifacts != 1 { t.Fatalf("temp cleanup not tracked: pending=%v artifacts=%d", pending, artifacts) @@ -4974,7 +4974,7 @@ func TestLogStoreStartupReconcilesLiveAndPendingRemovals(t *testing.T) { ls.mu.RLock() _, live := ls.entries[expiredID] pending := len(ls.pendingRemovals) - artifacts := ls.artifactCountLocked() + artifacts := ls.accountedLocked() ls.mu.RUnlock() if !live || pending != 2 || artifacts != 3 { t.Fatalf("startup accounting: live=%v pending=%d artifacts=%d", live, pending, artifacts) @@ -4992,7 +4992,7 @@ func TestLogStoreStartupReconcilesLiveAndPendingRemovals(t *testing.T) { } restarted := newLogStore(dir) restarted.mu.RLock() - restartedArtifacts := restarted.artifactCountLocked() + restartedArtifacts := restarted.accountedLocked() _, newLogRestored := restarted.entries[newID] restarted.mu.RUnlock() if restartedArtifacts != 1 || !newLogRestored { @@ -5084,14 +5084,14 @@ func TestPosterQuotaRemovalFailureDoesNotReclaimAccounting(t *testing.T) { if !errors.Is(err, fs.ErrPermission) { t.Fatalf("quota store error=%v want permission error", err) } - if newID != "" || newEntry != (posterEntry{}) { + if newID != "" || newEntry != (artifactEntry{}) { t.Fatalf("failed store returned success values: id=%q entry=%+v", newID, newEntry) } ps.mu.RLock() _, retained := ps.entries[oldID] - total := ps.totalBytes - pending := ps.pendingBytes - accounted := ps.accountedBytesLocked() + total := ps.used + pending := ps.pendingDebt + accounted := ps.accountedLocked() ps.mu.RUnlock() if !retained || total != int64(len(payload)) || pending != 0 { t.Fatalf("failed eviction accounting: retained=%v total=%d pending=%d", retained, total, pending) @@ -5112,8 +5112,8 @@ func TestPosterQuotaRemovalFailureDoesNotReclaimAccounting(t *testing.T) { t.Fatalf("old poster remove calls=%d want 2", remover.callCount(oldPath)) } ps.mu.RLock() - accounted = ps.accountedBytesLocked() - total = ps.totalBytes + accounted = ps.accountedLocked() + total = ps.used ps.mu.RUnlock() if total != int64(len(payload)) || regularFileBytes(t, dir) != accounted { t.Fatalf("retry accounting: total=%d accounted=%d physical=%d", total, accounted, regularFileBytes(t, dir)) @@ -5142,7 +5142,7 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { } ps.mu.RLock() _, retained := ps.entries[id] - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if !retained || total != entry.Size { t.Fatalf("failed expiry accounting: retained=%v total=%d", retained, total) @@ -5159,10 +5159,10 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { t.Fatalf("remove calls=%d want 2", remover.callCount(path)) } ps.mu.RLock() - total = ps.totalBytes + total = ps.used ps.mu.RUnlock() if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } }) @@ -5194,10 +5194,10 @@ func TestPosterExpiredRemovalFailureAndErrNotExistAreExactOnce(t *testing.T) { t.Fatalf("remove calls=%d want 1", remover.callCount(path)) } ps.mu.RLock() - total := ps.totalBytes + total := ps.used ps.mu.RUnlock() if total != 0 { - t.Fatalf("totalBytes=%d want 0", total) + t.Fatalf("stored bytes=%d want 0", total) } }) } @@ -5219,8 +5219,8 @@ func TestPosterStoreKnownCleanupDebtConsumesCapacityAndRetries(t *testing.T) { t.Fatal("upload exceeded capacity after known stale bytes were accounted") } ps.mu.RLock() - pendingBytes := ps.pendingBytes - accountedBytes := ps.accountedBytesLocked() + pendingBytes := ps.pendingDebt + accountedBytes := ps.accountedLocked() ps.mu.RUnlock() if pendingBytes != 4 || accountedBytes != 4 { t.Fatalf("known debt accounting: pending=%d accounted=%d, want 4", pendingBytes, accountedBytes) @@ -5236,7 +5236,7 @@ func TestPosterStoreKnownCleanupDebtConsumesCapacityAndRetries(t *testing.T) { t.Fatalf("stored entry size=%d, want 2", entry.Size) } ps.mu.RLock() - pendingBytes = ps.pendingBytes + pendingBytes = ps.pendingDebt ps.mu.RUnlock() if pendingBytes != 0 { t.Fatalf("known debt remained after successful retry: %d bytes", pendingBytes) @@ -5389,7 +5389,7 @@ func TestPosterHandlerRejectsUploadWhenQuotaRemovalFails(t *testing.T) { } posters.mu.RLock() _, retained := posters.entries[oldID] - total := posters.totalBytes + total := posters.used posters.mu.RUnlock() if !retained || total != int64(len(payload)) { t.Fatalf("failed upload changed old poster: retained=%v total=%d", retained, total) diff --git a/shared/mpv/mpv_player_common.h b/shared/mpv/mpv_player_common.h index 257cd345..9dd6125c 100644 --- a/shared/mpv/mpv_player_common.h +++ b/shared/mpv/mpv_player_common.h @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -105,6 +106,210 @@ class AsyncRequestRegistry { std::mutex mutex_; }; +// Every libmpv async submission follows the same shape: register the callback, +// hand the request to mpv, and roll back when the submission fails. A negative +// result means the request never reached mpv, so nothing will ever complete it +// and the callback has to be taken back and failed inline. The uninitialized +// handle guard stays with the callers: they own the lifecycle flags and the +// error code they report when the handle is gone. +inline void SubmitCommandAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::vector& args, StatusCallback callback) { + std::vector c_args; + c_args.reserve(args.size() + 1); + for (const auto& arg : args) { + c_args.push_back(arg.c_str()); + } + c_args.push_back(nullptr); + + const uint64_t request_id = callback ? requests.RegisterStatus(std::move(callback)) : 0; + // mpv_command_async returns immediately. + const int result = mpv_command_async(mpv, request_id, c_args.data()); + if (result < 0) { + auto pending = requests.TakeStatus(request_id); + if (pending) pending(result); + } +} + +inline void SubmitSetPropertyAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::string& name, const std::string& value, + StatusCallback callback) { + const uint64_t request_id = callback ? requests.RegisterStatus(std::move(callback)) : 0; + char* property_value = const_cast(value.c_str()); + const int result = mpv_set_property_async(mpv, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); + if (result < 0) { + auto pending = requests.TakeStatus(request_id); + if (pending) pending(result); + } +} + +inline void SubmitGetPropertyAsync( + mpv_handle* mpv, AsyncRequestRegistry& requests, const std::string& name, GetPropertyCallback callback) { + const uint64_t request_id = requests.RegisterProperty(std::move(callback)); + const int result = mpv_get_property_async(mpv, request_id, name.c_str(), MPV_FORMAT_STRING); + if (result < 0) { + auto pending = requests.TakeProperty(request_id); + if (pending) pending(result, ""); + } +} + +// Completes a COMMAND_REPLY / SET_PROPERTY_REPLY / GET_PROPERTY_REPLY against +// the pending-request registry. `sanitize` converts mpv's payload (not +// guaranteed to be valid UTF-8) into the string handed to the callback. +// Returns true when the event was a reply event and has been fully handled. +template +inline bool DispatchReplyEvent(AsyncRequestRegistry& requests, const mpv_event* event, const Sanitizer& sanitize) { + switch (event->event_id) { + case MPV_EVENT_COMMAND_REPLY: + case MPV_EVENT_SET_PROPERTY_REPLY: { + StatusCallback callback = requests.TakeStatus(event->reply_userdata); + if (callback) { + callback(event->error); + } + return true; + } + case MPV_EVENT_GET_PROPERTY_REPLY: { + GetPropertyCallback callback = requests.TakeProperty(event->reply_userdata); + if (callback) { + std::string value; + if (event->error >= 0) { + auto* prop = static_cast(event->data); + if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { + auto c_value = *static_cast(prop->data); + if (c_value) value = sanitize(c_value); + } + } + callback(event->error, value); + } + return true; + } + default: + return false; + } +} + +// Copies a PROPERTY_CHANGE payload into a node the platform marshallers can +// convert. mpv owns the storage, so the node only borrows it for the lifetime +// of the event. +inline mpv_node ExtractPropertyNode(const mpv_event_property* prop) { + mpv_node node{}; + node.format = prop ? prop->format : MPV_FORMAT_NONE; + if (!prop) return node; + + switch (prop->format) { + case MPV_FORMAT_STRING: + node.u.string = prop->data ? *static_cast(prop->data) : nullptr; + break; + case MPV_FORMAT_FLAG: + node.u.flag = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_INT64: + node.u.int64 = prop->data ? *static_cast(prop->data) : 0; + break; + case MPV_FORMAT_DOUBLE: + node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; + break; + case MPV_FORMAT_NODE: + if (prop->data) { + node = *static_cast(prop->data); + } else { + node.format = MPV_FORMAT_NONE; + } + break; + default: + node.format = MPV_FORMAT_NONE; + break; + } + return node; +} + +// mpv node payloads are untrusted input: a property can nest arbitrarily +// deeply, carry a list as long as mpv claims, and hold strings that are +// neither length-bounded nor valid UTF-8. Every runner walks the same trees +// into its own Flutter value type, so the walk and its bounds live here once. +static constexpr size_t kMaxNodeDepth = 32; +static constexpr int kMaxNodeEntries = 16384; + +struct NodeConversionBudget { + size_t remaining_entries = static_cast(kMaxNodeEntries); + size_t remaining_bytes = 16 * 1024 * 1024; +}; + +// Measures an mpv string without ever reading past the remaining byte budget, +// and charges it to that budget. Returns false for a missing string or one +// that would exceed what is left, which is how the walk rejects a payload. +inline bool ClaimNodeString(const char* input, NodeConversionBudget* budget, size_t* length) { + if (!input || !budget || !length) return false; + const size_t measured = strnlen(input, budget->remaining_bytes + 1); + if (measured > budget->remaining_bytes) return false; + budget->remaining_bytes -= measured; + *length = measured; + return true; +} + +// `Builder` adapts the walk to a platform value type and keeps that platform's +// UTF-8 sanitizer out of this header. It supplies the types `Value`, +// `ListBuilder` and `MapBuilder`, the leaves `Null`, `Bool`, `Int`, `Double` +// and `String(data, length)`, and the containers `NewList`/`Append`/ +// `FinishList` plus `NewMap`/`Insert`/`FinishMap`. A value passed to +// `Append`/`Insert` belongs to the builder from then on, and `AbandonMap` +// releases a partially built map whose key was rejected. +template +typename Builder::Value ConvertNode(const mpv_node* node, size_t depth, NodeConversionBudget* budget) { + if (!node || !budget || depth >= kMaxNodeDepth || budget->remaining_entries == 0) { + return Builder::Null(); + } + --budget->remaining_entries; + + switch (node->format) { + case MPV_FORMAT_STRING: { + size_t length = 0; + if (!ClaimNodeString(node->u.string, budget, &length)) return Builder::Null(); + return Builder::String(node->u.string, length); + } + case MPV_FORMAT_FLAG: + return Builder::Bool(node->u.flag != 0); + case MPV_FORMAT_INT64: + return Builder::Int(node->u.int64); + case MPV_FORMAT_DOUBLE: + return Builder::Double(node->u.double_); + case MPV_FORMAT_NODE_ARRAY: { + const mpv_node_list* list = node->u.list; + if (!list || list->num < 0 || list->num > kMaxNodeEntries || (list->num > 0 && !list->values)) { + return Builder::Null(); + } + typename Builder::ListBuilder result = Builder::NewList(); + for (int i = 0; i < list->num; i++) { + Builder::Append(result, ConvertNode(&list->values[i], depth + 1, budget)); + } + return Builder::FinishList(std::move(result)); + } + case MPV_FORMAT_NODE_MAP: { + const mpv_node_list* map = node->u.list; + if (!map || map->num < 0 || map->num > kMaxNodeEntries || (map->num > 0 && (!map->keys || !map->values))) { + return Builder::Null(); + } + typename Builder::MapBuilder result = Builder::NewMap(); + for (int i = 0; i < map->num; i++) { + size_t key_length = 0; + if (!ClaimNodeString(map->keys[i], budget, &key_length)) { + Builder::AbandonMap(result); + return Builder::Null(); + } + Builder::Insert(result, map->keys[i], key_length, ConvertNode(&map->values[i], depth + 1, budget)); + } + return Builder::FinishMap(std::move(result)); + } + default: + return Builder::Null(); + } +} + +template +typename Builder::Value ConvertNode(const mpv_node* node) { + NodeConversionBudget budget; + return ConvertNode(node, 0, &budget); +} + inline mpv_format ParsePropertyFormat(const std::string& format) { if (format == "string") return MPV_FORMAT_STRING; if (format == "flag" || format == "bool") return MPV_FORMAT_FLAG; @@ -305,6 +510,42 @@ class AudioRecoveryState { mutable std::mutex mutex_; }; +struct AudioRecoveryNotice { + // What to log, or nullptr when the property changed nothing of interest. + const char* message = nullptr; + // True when the state machine now has a reload queued, which platforms that + // drive recovery from a timer rather than an event-loop tick must wake for. + bool scheduled_work = false; +}; + +// Feeds the audio-related properties of a PROPERTY_CHANGE event into the +// recovery state machine, leaving the caller only the platform reporting. +inline AudioRecoveryNotice ObserveAudioRecoveryProperty( + AudioRecoveryState& state, const mpv_event* event, const mpv_event_property* prop) { + if (!event || !prop || !prop->name) return {}; + + if (std::strcmp(prop->name, "current-ao") == 0) { + const char* current_ao = nullptr; + if (prop->format == MPV_FORMAT_STRING && prop->data) { + current_ao = *static_cast(prop->data); + } + const bool is_null = current_ao && std::strcmp(current_ao, "null") == 0; + const auto transition = state.SetCurrentAudioOutputNull(is_null, AudioRecoveryState::Clock::now()); + if (transition == AudioOutputTransition::kFellBackToNull) { + return {"current-ao fell back to null; starting recovery", true}; + } + if (transition == AudioOutputTransition::kRecovered) { + return {"audio recovered (current-ao no longer null)", false}; + } + return {}; + } + if (std::strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && + state.OnAudioDeviceListChanged(AudioRecoveryState::Clock::now())) { + return {"audio-device-list changed while ao=null; rescheduling ao-reload", true}; + } + return {}; +} + } // namespace mpv_common } // namespace plezy diff --git a/shared/mpv/mpv_player_common_test.cpp b/shared/mpv/mpv_player_common_test.cpp index ac0100b7..baf3c03d 100644 --- a/shared/mpv/mpv_player_common_test.cpp +++ b/shared/mpv/mpv_player_common_test.cpp @@ -332,6 +332,96 @@ void TestStaleReloadCompletionCannotClearCurrentRequest() { assert(state.CompleteReload(current_request.request_generation)); } +// Renders the shared node walk into text so its bounds can be asserted on +// every platform, without a platform value type in the way. +struct TextNodeBuilder { + using Value = std::string; + using ListBuilder = std::string; + using MapBuilder = std::string; + + static Value Null() { return "null"; } + static Value Bool(bool value) { return value ? "true" : "false"; } + static Value Int(int64_t value) { return std::to_string(value); } + static Value Double(double value) { return std::to_string(value); } + static Value String(const char* value, size_t length) { return "'" + std::string(value, length) + "'"; } + + static ListBuilder NewList() { return std::string("["); } + static void Append(ListBuilder& list, Value value) { list += value + ","; } + static Value FinishList(ListBuilder list) { return list + "]"; } + + static MapBuilder NewMap() { return std::string("{"); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + map += std::string(key, key_length) + ":" + value + ","; + } + static Value FinishMap(MapBuilder map) { return map + "}"; } + static void AbandonMap(MapBuilder& map) { map += ""; } +}; + +void TestNodeConversionBounds() { + using plezy::mpv_common::ConvertNode; + using plezy::mpv_common::NodeConversionBudget; + + char value[] = "hello"; + mpv_node text{}; + text.format = MPV_FORMAT_STRING; + text.u.string = value; + assert(ConvertNode(&text) == "'hello'"); + + // Missing storage is never trusted: no node, no string, no list. + assert(ConvertNode(nullptr) == "null"); + text.u.string = nullptr; + assert(ConvertNode(&text) == "null"); + + mpv_node array{}; + array.format = MPV_FORMAT_NODE_ARRAY; + array.u.list = nullptr; + assert(ConvertNode(&array) == "null"); + + // Neither a negative nor an implausible length reaches the builder. + mpv_node entry{}; + entry.format = MPV_FORMAT_INT64; + entry.u.int64 = 7; + mpv_node_list negative{-1, &entry, nullptr}; + array.u.list = &negative; + assert(ConvertNode(&array) == "null"); + mpv_node_list oversized{plezy::mpv_common::kMaxNodeEntries + 1, &entry, nullptr}; + array.u.list = &oversized; + assert(ConvertNode(&array) == "null"); + + mpv_node_list single{1, &entry, nullptr}; + array.u.list = &single; + assert(ConvertNode(&array) == "[7,]"); + + // A map with a null key is voided rather than half-converted. + char* missing_key[] = {nullptr}; + mpv_node_list keyless{1, &entry, missing_key}; + mpv_node map{}; + map.format = MPV_FORMAT_NODE_MAP; + map.u.list = &keyless; + assert(ConvertNode(&map) == "null"); + + // Depth, entry, and byte budgets each stop the walk. + std::vector chain(plezy::mpv_common::kMaxNodeDepth + 1); + std::vector links(chain.size()); + chain.back() = entry; + for (size_t i = chain.size() - 1; i > 0; --i) { + links[i - 1] = mpv_node_list{1, &chain[i], nullptr}; + chain[i - 1].format = MPV_FORMAT_NODE_ARRAY; + chain[i - 1].u.list = &links[i - 1]; + } + assert(ConvertNode(&chain[0]).find('7') == std::string::npos); + + NodeConversionBudget entries{2, 1024}; + assert(ConvertNode(&array, 0, &entries) == "[7,]"); + assert(entries.remaining_entries == 0); + assert(ConvertNode(&array, 0, &entries) == "null"); + + NodeConversionBudget bytes{8, 4}; + text.u.string = value; + assert(ConvertNode(&text, 0, &bytes) == "null"); + assert(bytes.remaining_bytes == 4); +} + void TestHdrHelpers() { assert(plezy::mpv_common::ParseEnabledFlag("yes")); assert(plezy::mpv_common::ParseEnabledFlag("true")); @@ -355,6 +445,7 @@ int main() { TestFileBoundaryRestartsNullRecoveryOnlyAfterLoad(); TestUnloadedResumeIsConsumed(); TestStaleReloadCompletionCannotClearCurrentRequest(); + TestNodeConversionBounds(); TestHdrHelpers(); return 0; } diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index 7bd93b58..e5594875 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -20,6 +20,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plugin_platform_interface/plugin_platform_interface.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/prefs.dart'; void main() { @@ -68,8 +69,8 @@ class _AppDatabaseTestSuite { // v20 dropped the profile_id FK so virtual Plex Home profiles can // persist join rows without a parent `profiles` row. Profile deletion // instead cleans up join rows explicitly (via the teardown flow's - // removeAllProfileConnectionsAndCleanup) before deleting the profile, - // so the cascade isn't needed. + // ProfileConnectionCleanup.removeAllProfileConnections) before deleting + // the profile, so the cascade isn't needed. final now = DateTime.now().millisecondsSinceEpoch; await db .into(db.connections) diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index 3e3e9a3a..df52b558 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -8,6 +8,8 @@ import 'package:plezy/database/app_database.dart'; import 'package:plezy/database/download_operations.dart'; import 'package:plezy/models/download_models.dart'; +import '../test_helpers/download_fixtures.dart'; + void main() { late AppDatabase db; @@ -19,115 +21,6 @@ void main() { await db.close(); }); - // ============================================================ - // insertDownload - // ============================================================ - - group('insertDownload', () { - test('inserts a movie row with defaults', () async { - await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: '100', - globalKey: 'srv:100', - type: 'movie', - status: DownloadStatus.queued.index, - ); - - final rows = await db.select(db.downloadedMedia).get(); - expect(rows, hasLength(1)); - final r = rows.first; - expect(r.serverId, 'srv'); - expect(r.ratingKey, '100'); - expect(r.globalKey, 'srv:100'); - expect(r.type, 'movie'); - expect(r.status, DownloadStatus.queued.index); - expect(r.parentRatingKey, isNull); - expect(r.grandparentRatingKey, isNull); - expect(r.mediaIndex, 0); - }); - - test('inserts an episode with parent and grandparent keys', () async { - await db.insertDownload( - serverId: ServerId('srv'), - ratingKey: 'ep1', - globalKey: 'srv:ep1', - type: 'episode', - parentRatingKey: 'season1', - grandparentRatingKey: 'show1', - status: DownloadStatus.queued.index, - mediaIndex: 7, - ); - - final row = (await db.select(db.downloadedMedia).get()).single; - expect(row.parentRatingKey, 'season1'); - expect(row.grandparentRatingKey, 'show1'); - expect(row.mediaIndex, 7); - }); - - test('atomically updates metadata and attempt state while preserving the row and physical fields', () async { - await db.insertDownload( - serverId: ServerId('srv'), - clientScopeId: 'scope-old', - ratingKey: '100', - globalKey: 'srv:100', - type: 'movie', - status: DownloadStatus.queued.index, - mediaIndex: 1, - mediaSourceId: 'source-old', - ); - final original = (await db.getDownloadedMedia('srv:100'))!; - await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write( - const DownloadedMediaCompanion( - progress: Value(50), - downloadedBytes: Value(500), - totalBytes: Value(1000), - videoFilePath: Value('downloads/video.mkv'), - safRootUri: Value('content://downloads'), - thumbPath: Value('downloads/thumb.jpg'), - downloadedAt: Value(1234), - errorMessage: Value('old error'), - retryCount: Value(2), - bgTaskId: Value('current-task'), - ), - ); - - await db.insertDownload( - serverId: ServerId('srv-new'), - clientScopeId: 'scope-new', - ratingKey: '100-new', - globalKey: 'srv:100', - type: 'episode', - parentRatingKey: 'season-new', - grandparentRatingKey: 'show-new', - status: DownloadStatus.failed.index, - mediaIndex: 3, - mediaSourceId: 'source-new', - ); - - final row = (await db.select(db.downloadedMedia).get()).single; - expect(row.id, original.id); - expect(row.serverId, 'srv-new'); - expect(row.clientScopeId, 'scope-new'); - expect(row.ratingKey, '100-new'); - expect(row.type, 'episode'); - expect(row.parentRatingKey, 'season-new'); - expect(row.grandparentRatingKey, 'show-new'); - expect(row.status, DownloadStatus.failed.index); - expect(row.mediaIndex, 3); - expect(row.mediaSourceId, 'source-new'); - expect(row.progress, 0); - expect(row.downloadedBytes, 0); - expect(row.totalBytes, isNull); - expect(row.errorMessage, isNull); - expect(row.retryCount, 0); - expect(row.videoFilePath, 'downloads/video.mkv'); - expect(row.safRootUri, 'content://downloads'); - expect(row.thumbPath, 'downloads/thumb.jpg'); - expect(row.downloadedAt, 1234); - expect(row.bgTaskId, 'current-task'); - }); - }); - group('insertQueuedDownload', () { test('atomically persists media identity, scope, policy, and queue state', () async { final tempDir = await Directory.systemTemp.createTemp('plezy_atomic_queue_'); diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index 01cf7af1..a4fb4358 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -7,6 +7,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/mixins/paginated_item_loader.dart'; +import 'package:plezy/mixins/standard_paginated_view.dart'; import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/exceptions/media_server_exceptions.dart'; import '../test_helpers/media_items.dart'; @@ -27,10 +28,20 @@ class _PaginatedProbe extends StatefulWidget { State<_PaginatedProbe> createState() => _PaginatedProbeState(); } -class _PaginatedProbeState extends State<_PaginatedProbe> with PaginatedItemLoader { +class _PaginatedProbeState extends State<_PaginatedProbe> + with PaginatedItemLoader, StandardPaginatedView { int fetchCalls = 0; final List<({int start, int size})> fetchArgs = []; + /// View fields required by [StandardPaginatedView], mirroring the + /// `items`/`isLoading`/`errorMessage` trio the real screens expose. + @override + List items = []; + @override + bool isLoading = false; + @override + String? errorMessage; + @override Future> fetchPage(int start, int size, AbortController? abort) { fetchCalls++; @@ -109,10 +120,8 @@ void main() { expect(hooked, [(0, 5)]); }); - testWidgets('loadInitialPaginatedItems applies reset, data, and success callback', (tester) async { + testWidgets('loadStandardPaginatedItems resets view state, publishes items, and fires onLoaded', (tester) async { late _PaginatedProbeState state; - var reset = false; - List? applied; (int, int)? counts; await tester.pumpWidget( _PaginatedProbe( @@ -121,25 +130,26 @@ void main() { ), ); - final succeeded = await state.loadInitialPaginatedItems( + // Stale view state from a previous load must be cleared by the reset. + state.items = [_meta(99)]; + state.errorMessage = 'stale error'; + + await state.loadStandardPaginatedItems( pageSize: 3, - resetViewState: () => reset = true, - applyLoadedItems: (items) => applied = items, - applyError: (error, stackTrace) => fail('unexpected error: $error'), + errorMessageFor: (error, stackTrace) => fail('unexpected error: $error'), onLoaded: (loaded, total) => counts = (loaded, total), ); await tester.pump(); - expect(succeeded, isTrue); - expect(reset, isTrue); - expect(applied?.map((item) => item.id), ['k0', 'k1', 'k2']); + expect(state.items.map((item) => item.id), ['k0', 'k1', 'k2']); + expect(state.isLoading, isFalse); + expect(state.errorMessage, isNull); expect(counts, (3, 7)); }); - testWidgets('loadInitialPaginatedItems applies one error transaction', (tester) async { + testWidgets('loadStandardPaginatedItems applies one error transaction', (tester) async { late _PaginatedProbeState state; - Object? appliedError; - Object? loggedError; + Object? reportedError; await tester.pumpWidget( _PaginatedProbe( onState: (s) => state = s, @@ -147,18 +157,20 @@ void main() { ), ); - final succeeded = await state.loadInitialPaginatedItems( + await state.loadStandardPaginatedItems( pageSize: 3, - resetViewState: () {}, - applyLoadedItems: (_) => fail('items must not be applied'), - applyError: (error, stackTrace) => appliedError = error, - onError: (error, stackTrace) => loggedError = error, + errorMessageFor: (error, stackTrace) { + reportedError = error; + return 'could not load'; + }, + onLoaded: (_, _) => fail('items must not be applied'), ); await tester.pump(); - expect(succeeded, isFalse); - expect(appliedError, isA()); - expect(loggedError, same(appliedError)); + expect(reportedError, isA()); + expect(state.errorMessage, 'could not load'); + expect(state.isLoading, isFalse); + expect(state.items, isEmpty); }); testWidgets('totalSize == 0 means no more pages — ensureRangeLoaded is a no-op', (tester) async { diff --git a/test/profiles/profile_connection_cleanup_test.dart b/test/profiles/profile_connection_cleanup_test.dart index 56559f1c..5164747e 100644 --- a/test/profiles/profile_connection_cleanup_test.dart +++ b/test/profiles/profile_connection_cleanup_test.dart @@ -83,6 +83,7 @@ void main() { late ConnectionRegistry connections; late ProfileConnectionRegistry profileConnections; late StorageService storage; + late ProfileConnectionCleanup cleanup; setUp(() async { resetSharedPreferencesForTest(); @@ -90,6 +91,11 @@ void main() { connections = ConnectionRegistry(db); profileConnections = ProfileConnectionRegistry(db); storage = await StorageService.getInstance(); + cleanup = ProfileConnectionCleanup( + profileConnections: profileConnections, + connections: connections, + storage: storage, + ); }); tearDown(() async { @@ -112,13 +118,7 @@ void main() { await storage.saveHiddenLibraries({'jf-machine:movies'}); await storage.saveLibraryOrder(['jf-machine:movies']); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await profileConnections.listForConnection(conn.id), isEmpty); expect(await connections.get(conn.id), isNull); @@ -151,13 +151,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'jf-machine:movies'}); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await connections.get(conn.id), isNotNull); final remaining = await profileConnections.listForConnection(conn.id); @@ -177,11 +171,7 @@ void main() { await storage.saveHiddenLibraries({'jf-machine:movies'}); await storage.saveLibrarySort('jf-machine:movies', 'titleSort'); - final removed = await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removed = await cleanup.pruneUnreferencedJellyfinConnections(); expect(removed, 1); expect(await connections.get(conn.id), isNull); @@ -205,11 +195,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'jf-machine:movies'}); - final removed = await pruneUnreferencedJellyfinConnections( - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removed = await cleanup.pruneUnreferencedJellyfinConnections(); expect(removed, 1); expect(await connections.get(orphan.id), isNull); @@ -241,13 +227,7 @@ void main() { expect(await connections.get(acct.id), isNotNull); expect(await profileConnections.listAll(), hasLength(2)); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - plannedRemoval: plannedRemoval, - ); + final removal = await cleanup.removePlexAccountConnection(acct, plannedRemoval: plannedRemoval); expect(removal.removedVirtualProfileIds, {vProfile}); expect(removal.borrowerProfileIds, isEmpty); @@ -270,12 +250,7 @@ void main() { await profileConnections.upsert(_row(vProfile, jf)); await profileConnections.upsert(_row('local-1', jf)); - await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removePlexAccountConnection(acct); expect(await connections.get(jf.id), isNotNull); final remaining = await profileConnections.listAll(); @@ -293,12 +268,7 @@ void main() { await profileConnections.upsert(_row('local-1', acct)); await profileConnections.upsert(_row('local-1', jf)); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removal = await cleanup.removePlexAccountConnection(acct); expect(removal.removedVirtualProfileIds, isEmpty); expect(removal.borrowerProfileIds, {'local-1'}); @@ -323,12 +293,7 @@ void main() { await profileConnections.upsert(_row(v2, acct2, userIdentifier: uuid2)); await storage.savePlexHomeUsersCache(acct2.id, [_homeUser(uuid2).toJson()]); - final removal = await removePlexAccountConnectionAndCleanup( - account: acct1, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + final removal = await cleanup.removePlexAccountConnection(acct1); expect(removal.removedVirtualProfileIds, {v1}); expect(await connections.get(acct2.id), isNotNull); @@ -348,12 +313,7 @@ void main() { await profileConnections.upsert(_row(vProfile, acct, userIdentifier: uuid)); await profileConnections.upsert(_row(vProfile, jf)); - Future run() => removePlexAccountConnectionAndCleanup( - account: acct, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + Future run() => cleanup.removePlexAccountConnection(acct); await run(); final second = await run(); @@ -375,13 +335,7 @@ void main() { await storage.setActiveProfileId('p2'); await storage.saveHiddenLibraries({'plex-machine:movies'}); - await removeProfileConnectionAndCleanup( - profileId: 'p1', - connection: conn, - profileConnections: profileConnections, - connections: connections, - storage: storage, - ); + await cleanup.removeProfileConnection(profileId: 'p1', connection: conn); expect(await connections.get(conn.id), isNotNull); expect(await profileConnections.listForConnection(conn.id), isEmpty); @@ -402,13 +356,7 @@ void main() { Future<({PostRemovalRoute route, List profiles})> resolve({ Map> plexHomeUsers = const {}, }) { - return resolvePostRemovalState( - profileRegistry: profileRegistry, - profileConnections: profileConnections, - connections: connections, - plexHomeUsers: plexHomeUsers, - storage: storage, - ); + return cleanup.resolvePostRemovalState(profileRegistry: profileRegistry, plexHomeUsers: plexHomeUsers); } Profile local(String id) => diff --git a/test/providers/download_provider_test.dart b/test/providers/download_provider_test.dart index 2a1a4629..326aa2e9 100644 --- a/test/providers/download_provider_test.dart +++ b/test/providers/download_provider_test.dart @@ -21,6 +21,7 @@ import 'package:plezy/services/plex_api_cache.dart'; import 'package:plezy/utils/deletion_notifier.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import 'package:plezy/utils/active_client_scope.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/media_items.dart'; /// Implements only [fetchPlayableDescendants], the surface [collectEpisodes] diff --git a/test/providers/watch_state_store_test.dart b/test/providers/watch_state_store_test.dart index 960b1c91..612a3f5a 100644 --- a/test/providers/watch_state_store_test.dart +++ b/test/providers/watch_state_store_test.dart @@ -4,6 +4,7 @@ import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/watch_state_store.dart'; +import 'package:plezy/services/watch_state_resolver.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/media_items.dart'; @@ -190,13 +191,13 @@ void main() { store.setHydratedPatches(const [ HydratedWatchStatePatch( globalKey: 'jf-machine:show-1', - patch: WatchStatePatch(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), + patch: WatchStateSnapshot(isWatched: true, hasViewOffsetMs: true, viewOffsetMs: 0), updatedAt: 100, order: 1, ), HydratedWatchStatePatch( globalKey: 'jf-machine:episode-1', - patch: WatchStatePatch(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), + patch: WatchStateSnapshot(isWatched: false, hasViewOffsetMs: true, viewOffsetMs: 0), updatedAt: 200, order: 2, ), @@ -213,13 +214,13 @@ void main() { store.setHydratedPatches(const [ HydratedWatchStatePatch( globalKey: 'jf-machine/user-a:show-1', - patch: WatchStatePatch(isWatched: true), + patch: WatchStateSnapshot(isWatched: true), updatedAt: 100, order: 1, ), HydratedWatchStatePatch( globalKey: 'jf-machine/user-b:show-1', - patch: WatchStatePatch(isWatched: false), + patch: WatchStateSnapshot(isWatched: false), updatedAt: 100, order: 2, ), diff --git a/test/services/download_manager_service_test.dart b/test/services/download_manager_service_test.dart index f19af6d0..2b592554 100644 --- a/test/services/download_manager_service_test.dart +++ b/test/services/download_manager_service_test.dart @@ -33,6 +33,7 @@ import 'package:plezy/utils/media_server_http_client.dart'; import 'package:plezy/utils/active_client_scope.dart'; import 'package:saf_util/saf_util_platform_interface.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/io_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; diff --git a/test/screens/video_player/media_control_router_test.dart b/test/services/media_control_router_test.dart similarity index 94% rename from test/screens/video_player/media_control_router_test.dart rename to test/services/media_control_router_test.dart index 8d2f20fd..dd5bcffd 100644 --- a/test/screens/video_player/media_control_router_test.dart +++ b/test/services/media_control_router_test.dart @@ -1,6 +1,6 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:os_media_controls/os_media_controls.dart'; -import 'package:plezy/screens/video_player/media_control_router.dart'; +import 'package:plezy/services/media_control_router.dart'; void main() { test('denied playback and media-item commands are consumed without mutation', () { @@ -47,12 +47,12 @@ void main() { }); } -VideoPlayerMediaControlRouter _router({ +MediaControlRouter _router({ required bool Function() canControl, required bool Function() canNavigate, required List calls, }) { - return VideoPlayerMediaControlRouter( + return MediaControlRouter( canControlPlayback: canControl, canNavigateMediaItems: canNavigate, onPlay: () => calls.add('play'), diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 8c65fad9..ee649b1b 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -22,6 +22,7 @@ import 'package:plezy/utils/active_client_scope.dart'; import 'package:plezy/utils/watch_state_notifier.dart'; import '../test_helpers/backend_client_fixtures.dart'; +import '../test_helpers/download_fixtures.dart'; import '../test_helpers/playback_report_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; diff --git a/test/startup_bootstrap_test.dart b/test/startup_bootstrap_test.dart index 64ba2a40..f19addce 100644 --- a/test/startup_bootstrap_test.dart +++ b/test/startup_bootstrap_test.dart @@ -11,6 +11,8 @@ import 'package:plezy/main.dart'; import 'package:plezy/media/ids.dart'; import 'package:plezy/models/download_models.dart'; +import 'test_helpers/download_fixtures.dart'; + void main() { testWidgets('renders a Flutter frame before starting the initialization gate', (tester) async { final completion = Completer(); diff --git a/test/test_helpers/download_fixtures.dart b/test/test_helpers/download_fixtures.dart new file mode 100644 index 00000000..f880ee19 --- /dev/null +++ b/test/test_helpers/download_fixtures.dart @@ -0,0 +1,70 @@ +import 'package:drift/drift.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/media/ids.dart'; + +/// Seeds `downloaded_media` rows at an arbitrary [status] so tests can start +/// from completed, downloading, or paused state. +/// +/// Production never writes rows this way — it goes through +/// `insertQueuedDownload`, which only ever admits `queued` rows and guards on +/// the existing status. This fixture deliberately keeps neither restriction, +/// which is why it lives in `test/` instead of `lib/`. +extension DownloadFixtures on AppDatabase { + Future 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(serverId), + Variable(clientScopeId), + Variable(ratingKey), + Variable(globalKey), + Variable(type), + Variable(parentRatingKey), + Variable(grandparentRatingKey), + Variable(status), + Variable(mediaIndex), + Variable(mediaSourceId), + ], + updates: {downloadedMedia}, + ); + } +} diff --git a/test/test_helpers/download_fixtures_test.dart b/test/test_helpers/download_fixtures_test.dart new file mode 100644 index 00000000..cc6bc344 --- /dev/null +++ b/test/test_helpers/download_fixtures_test.dart @@ -0,0 +1,126 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/database/app_database.dart'; +import 'package:plezy/database/download_operations.dart'; +import 'package:plezy/media/ids.dart'; +import 'package:plezy/models/download_models.dart'; + +import 'download_fixtures.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + group('insertDownload', () { + test('inserts a movie row with defaults', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + ); + + final rows = await db.select(db.downloadedMedia).get(); + expect(rows, hasLength(1)); + final r = rows.first; + expect(r.serverId, 'srv'); + expect(r.ratingKey, '100'); + expect(r.globalKey, 'srv:100'); + expect(r.type, 'movie'); + expect(r.status, DownloadStatus.queued.index); + expect(r.parentRatingKey, isNull); + expect(r.grandparentRatingKey, isNull); + expect(r.mediaIndex, 0); + }); + + test('inserts an episode with parent and grandparent keys', () async { + await db.insertDownload( + serverId: ServerId('srv'), + ratingKey: 'ep1', + globalKey: 'srv:ep1', + type: 'episode', + parentRatingKey: 'season1', + grandparentRatingKey: 'show1', + status: DownloadStatus.queued.index, + mediaIndex: 7, + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.parentRatingKey, 'season1'); + expect(row.grandparentRatingKey, 'show1'); + expect(row.mediaIndex, 7); + }); + + test('atomically updates metadata and attempt state while preserving the row and physical fields', () async { + await db.insertDownload( + serverId: ServerId('srv'), + clientScopeId: 'scope-old', + ratingKey: '100', + globalKey: 'srv:100', + type: 'movie', + status: DownloadStatus.queued.index, + mediaIndex: 1, + mediaSourceId: 'source-old', + ); + final original = (await db.getDownloadedMedia('srv:100'))!; + await (db.update(db.downloadedMedia)..where((row) => row.globalKey.equals('srv:100'))).write( + const DownloadedMediaCompanion( + progress: Value(50), + downloadedBytes: Value(500), + totalBytes: Value(1000), + videoFilePath: Value('downloads/video.mkv'), + safRootUri: Value('content://downloads'), + thumbPath: Value('downloads/thumb.jpg'), + downloadedAt: Value(1234), + errorMessage: Value('old error'), + retryCount: Value(2), + bgTaskId: Value('current-task'), + ), + ); + + await db.insertDownload( + serverId: ServerId('srv-new'), + clientScopeId: 'scope-new', + ratingKey: '100-new', + globalKey: 'srv:100', + type: 'episode', + parentRatingKey: 'season-new', + grandparentRatingKey: 'show-new', + status: DownloadStatus.failed.index, + mediaIndex: 3, + mediaSourceId: 'source-new', + ); + + final row = (await db.select(db.downloadedMedia).get()).single; + expect(row.id, original.id); + expect(row.serverId, 'srv-new'); + expect(row.clientScopeId, 'scope-new'); + expect(row.ratingKey, '100-new'); + expect(row.type, 'episode'); + expect(row.parentRatingKey, 'season-new'); + expect(row.grandparentRatingKey, 'show-new'); + expect(row.status, DownloadStatus.failed.index); + expect(row.mediaIndex, 3); + expect(row.mediaSourceId, 'source-new'); + expect(row.progress, 0); + expect(row.downloadedBytes, 0); + expect(row.totalBytes, isNull); + expect(row.errorMessage, isNull); + expect(row.retryCount, 0); + expect(row.videoFilePath, 'downloads/video.mkv'); + expect(row.safRootUri, 'content://downloads'); + expect(row.thumbPath, 'downloads/thumb.jpg'); + expect(row.downloadedAt, 1234); + expect(row.bgTaskId, 'current-task'); + }); + }); +} diff --git a/test/widgets/video_settings_sheet_test.dart b/test/widgets/video_settings_sheet_test.dart index babdd7e1..3fa92daf 100644 --- a/test/widgets/video_settings_sheet_test.dart +++ b/test/widgets/video_settings_sheet_test.dart @@ -10,6 +10,7 @@ import 'package:plezy/mpv/player/player_streams.dart'; import 'package:plezy/screens/settings/subtitle_styling_screen.dart'; import 'package:plezy/services/sleep_timer_service.dart'; import 'package:plezy/services/settings_service.dart'; +import 'package:plezy/widgets/video_controls/models/track_controls_state.dart'; import 'package:plezy/widgets/video_controls/sheets/video_settings_sheet.dart'; import '../test_helpers/prefs.dart'; @@ -147,10 +148,8 @@ Future _pumpSheet( height: 700, child: VideoSettingsSheet( player: player ?? _FakeSettingsPlayer(), - audioSyncOffset: 0, - subtitleSyncOffset: 0, - canControl: canControl, supportsHdrControl: supportsHdrControl, + trackControlsState: TrackControlsState(canControl: canControl), ), ), ), diff --git a/website/src/lib/components/DownloadButtons.svelte b/website/src/lib/components/DownloadButtons.svelte index 460d3741..0f77b76d 100644 --- a/website/src/lib/components/DownloadButtons.svelte +++ b/website/src/lib/components/DownloadButtons.svelte @@ -5,7 +5,7 @@ import AmazonIcon from "~icons/cib/amazon"; import ChevronDownIcon from "~icons/heroicons/chevron-down-solid"; import WindowsIcon from "./WindowsIcon.svelte"; - import { linuxArchitectures } from "$lib/content/downloads"; + import { AMAZON_URL, linuxArchitectures, releaseAsset, storeOptions } from "$lib/content/downloads"; const componentId = $props.id(); const linuxPanelId = `${componentId}-linux-downloads`; @@ -34,7 +34,7 @@
@@ -75,7 +75,7 @@ diff --git a/website/src/lib/components/FAQ.svelte b/website/src/lib/components/FAQ.svelte index e880fcd0..f1b30d73 100644 --- a/website/src/lib/components/FAQ.svelte +++ b/website/src/lib/components/FAQ.svelte @@ -4,6 +4,7 @@ import MinusIcon from '~icons/heroicons/minus'; import PlusIcon from '~icons/heroicons/plus'; import ScrollReveal from "./ScrollReveal.svelte"; + import SectionHeader from "./SectionHeader.svelte"; const hash = $derived(page.url.hash.slice(1)); const hashIndex = $derived(faqs.findIndex((f) => f.id === hash)); @@ -24,12 +25,12 @@ } -
- - -

Common questions

-

Everything you need to know about Plezy.

-
+
+
{#each faqs as faq, i} @@ -71,43 +72,6 @@
diff --git a/website/src/lib/content/downloads.ts b/website/src/lib/content/downloads.ts index 6a79e63b..f7f4e901 100644 --- a/website/src/lib/content/downloads.ts +++ b/website/src/lib/content/downloads.ts @@ -12,16 +12,23 @@ export type StoreOption = { url: string; }; +export const APP_STORE_ID = '6754315964'; +export const ANDROID_PACKAGE = 'com.edde746.plezy'; +export const AMAZON_URL = 'https://www.amazon.com/gp/product/B0GK65CVS1'; + +export const releaseAsset = (name: string) => + `https://github.com/edde746/plezy/releases/latest/download/${name}`; + export const storeOptions = { ios: { id: 'app-store', label: 'App Store', - url: 'https://apps.apple.com/us/app/id6754315964', + url: `https://apps.apple.com/us/app/id${APP_STORE_ID}`, }, android: { id: 'play-store', label: 'Google Play', - url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', + url: `https://play.google.com/store/apps/details?id=${ANDROID_PACKAGE}`, }, } as const satisfies Record<'ios' | 'android', StoreOption>; @@ -45,23 +52,21 @@ export function storeOptionsForPlatform(platform: MobileStorePlatform): readonly return [storeOptions.ios, storeOptions.android]; } +// Every architecture ships the same formats, so the menu is their cross product. +const LINUX_FORMATS = [ + { label: '.deb (Debian/Ubuntu)', extension: 'deb' }, + { label: '.rpm (Fedora/RHEL)', extension: 'rpm' }, + { label: '.pkg.tar.zst (Arch)', extension: 'pkg.tar.zst' }, + { label: '.tar.gz (Portable)', extension: 'tar.gz' }, +]; + export const linuxArchitectures = [ - { - label: 'x64 (Intel/AMD)', - formats: [ - { label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.deb' }, - { label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.rpm' }, - { label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.pkg.tar.zst' }, - { label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-x64.tar.gz' }, - ], - }, - { - label: 'ARM64', - formats: [ - { label: '.deb (Debian/Ubuntu)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.deb' }, - { label: '.rpm (Fedora/RHEL)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.rpm' }, - { label: '.pkg.tar.zst (Arch)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.pkg.tar.zst' }, - { label: '.tar.gz (Portable)', url: 'https://github.com/edde746/plezy/releases/latest/download/plezy-linux-arm64.tar.gz' }, - ], - }, -] as const; + { slug: 'x64', label: 'x64 (Intel/AMD)' }, + { slug: 'arm64', label: 'ARM64' }, +].map(({ slug, label }) => ({ + label, + formats: LINUX_FORMATS.map((format) => ({ + label: format.label, + url: releaseAsset(`plezy-linux-${slug}.${format.extension}`), + })), +})); diff --git a/website/src/lib/content/software_app_offers.ts b/website/src/lib/content/software_app_offers.ts index 24415648..b2234bc7 100644 --- a/website/src/lib/content/software_app_offers.ts +++ b/website/src/lib/content/software_app_offers.ts @@ -1,3 +1,5 @@ +import { AMAZON_URL, storeOptions } from './downloads'; + export type StorePrices = { appStorePrice: string | null; playStorePrice: string | null; @@ -23,7 +25,7 @@ export function buildSoftwareApplicationOffers({ return [ { '@type': 'Offer', - url: 'https://apps.apple.com/us/app/id6754315964', + url: storeOptions.ios.url, category: 'App Store', ...(appStorePrice === null ? {} @@ -34,7 +36,7 @@ export function buildSoftwareApplicationOffers({ }, { '@type': 'Offer', - url: 'https://play.google.com/store/apps/details?id=com.edde746.plezy', + url: storeOptions.android.url, category: 'Google Play', ...(playStorePrice === null ? {} @@ -45,7 +47,7 @@ export function buildSoftwareApplicationOffers({ }, { '@type': 'Offer', - url: 'https://www.amazon.com/gp/product/B0GK65CVS1', + url: AMAZON_URL, category: 'Amazon Appstore', }, { diff --git a/website/src/routes/+error.svelte b/website/src/routes/+error.svelte index 1853cb81..c5e690c1 100644 --- a/website/src/routes/+error.svelte +++ b/website/src/routes/+error.svelte @@ -8,16 +8,16 @@ -
-
- +
+ diff --git a/website/src/routes/+page.server.ts b/website/src/routes/+page.server.ts index 16c820b6..3d165092 100644 --- a/website/src/routes/+page.server.ts +++ b/website/src/routes/+page.server.ts @@ -1,4 +1,5 @@ import type { PageServerLoad } from './$types'; +import { ANDROID_PACKAGE, APP_STORE_ID } from '$lib/content/downloads'; import { normalizeUsdStorePrice } from '$lib/content/software_app_offers'; export const load: PageServerLoad = async ({ fetch }) => { @@ -8,7 +9,7 @@ export const load: PageServerLoad = async ({ fetch }) => { let playStorePrice: string | null = null; try { - const res = await fetch('https://itunes.apple.com/lookup?id=6754315964'); + const res = await fetch(`https://itunes.apple.com/lookup?id=${APP_STORE_ID}`); if (!res.ok) throw new Error(`App Store lookup failed: HTTP ${res.status}`); const data = await res.json(); const app = data.results?.[0]; @@ -27,7 +28,7 @@ export const load: PageServerLoad = async ({ fetch }) => { // Module initialization is optional external data and must stay inside this failure boundary. const { default: gplay } = await import('google-play-scraper'); const app = await gplay.app({ - appId: 'com.edde746.plezy', + appId: ANDROID_PACKAGE, country: 'us', lang: 'en' }); diff --git a/website/src/routes/layout.css b/website/src/routes/layout.css index 0065db6d..840c124b 100644 --- a/website/src/routes/layout.css +++ b/website/src/routes/layout.css @@ -138,6 +138,87 @@ ul { background: var(--color-surface); } +/* Standalone page holding a single centered card (/scan, +error). */ +.centered-page { + display: flex; + min-height: 100dvh; + align-items: center; + justify-content: center; + padding: var(--page-gutter); +} + +.centered-card { + display: flex; + width: min(100%, 32rem); + flex-direction: column; + align-items: center; + border-radius: var(--radius-xl); + padding: clamp(2rem, 8vw, 4rem); + background: var(--color-surface); + text-align: center; +} + +.card-logo { + display: flex; + width: 5rem; + height: 5rem; + align-items: center; + justify-content: center; + margin-bottom: 2rem; + border-radius: var(--radius-lg); + background: var(--color-surface-highest); +} + +.card-logo img { + width: 2.5rem; + height: 2.5rem; +} + +/* Filled pill action; add .btn-pill--icon when it leads with an icon. */ +.btn-pill { + display: inline-flex; + min-height: 3rem; + align-items: center; + border-radius: var(--radius-pill); + padding-inline: 1.25rem; + color: var(--color-on-primary); + background: var(--color-text); + font-size: 0.8125rem; + font-weight: 700; + transition: + border-radius var(--motion-expressive) var(--ease-standard), + background-color var(--motion-fast) var(--ease-standard); +} + +.btn-pill:hover, +.btn-pill:focus-visible { + border-radius: var(--radius-md); + background: #fff; + outline: none; +} + +.btn-pill--icon { + gap: 0.625rem; +} + +.btn-pill--icon svg { + width: 1.125rem; + height: 1.125rem; +} + +/* Landing section that stays inside the page grid. */ +.page-section { + width: min(100%, var(--page-width)); + margin-inline: auto; + padding: clamp(4rem, 9vw, 8rem) var(--page-gutter); +} + +/* Landing section whose content bleeds past the page grid. */ +.bleed-section { + overflow: hidden; + padding-block: clamp(4rem, 9vw, 8rem); +} + @keyframes fade-in-up { from { opacity: 0; diff --git a/website/src/routes/scan/+page.svelte b/website/src/routes/scan/+page.svelte index 76646559..c4737a5e 100644 --- a/website/src/routes/scan/+page.svelte +++ b/website/src/routes/scan/+page.svelte @@ -40,9 +40,9 @@ -
-
- +
+
+

Scan in Plezy

To use this feature, scan this QR code with the Plezy app.

@@ -53,7 +53,7 @@ href={store.url} target="_blank" rel="noopener noreferrer" - class="store-button" + class="btn-pill btn-pill--icon" > {#if store.id === "app-store"} @@ -68,41 +68,6 @@
diff --git a/windows/runner/mpv/mpv_player.cpp b/windows/runner/mpv/mpv_player.cpp index 07cf262c..bb129833 100644 --- a/windows/runner/mpv/mpv_player.cpp +++ b/windows/runner/mpv/mpv_player.cpp @@ -23,44 +23,32 @@ struct InnerWindowSubclassState { namespace { -flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { - if (!node) return flutter::EncodableValue(); +// Adapts the shared, bounded mpv_node walk onto Flutter's encodable values. +struct EncodableNodeBuilder { + using Value = flutter::EncodableValue; + using ListBuilder = flutter::EncodableList; + using MapBuilder = flutter::EncodableMap; - switch (node->format) { - case MPV_FORMAT_STRING: - return flutter::EncodableValue(SanitizeUtf8(node->u.string)); - case MPV_FORMAT_FLAG: - return flutter::EncodableValue(node->u.flag != 0); - case MPV_FORMAT_INT64: - return flutter::EncodableValue(node->u.int64); - case MPV_FORMAT_DOUBLE: - return flutter::EncodableValue(node->u.double_); - case MPV_FORMAT_NODE_ARRAY: { - const mpv_node_list* node_list = node->u.list; - if (!node_list || node_list->num < 0 || (node_list->num > 0 && !node_list->values)) { - return flutter::EncodableValue(); - } - flutter::EncodableList list; - list.reserve(static_cast(node_list->num)); - for (int i = 0; i < node_list->num; ++i) { - list.push_back(NodeToEncodableValue(&node_list->values[i])); - } - return flutter::EncodableValue(list); - } - case MPV_FORMAT_NODE_MAP: { - const mpv_node_list* node_list = node->u.list; - if (!node_list || node_list->num < 0 || (node_list->num > 0 && (!node_list->values || !node_list->keys))) { - return flutter::EncodableValue(); - } - flutter::EncodableMap map; - for (int i = 0; i < node_list->num; ++i) { - map[flutter::EncodableValue(SanitizeUtf8(node_list->keys[i]))] = NodeToEncodableValue(&node_list->values[i]); - } - return flutter::EncodableValue(map); - } - default: - return flutter::EncodableValue(); + static Value Null() { return flutter::EncodableValue(); } + static Value Bool(bool value) { return flutter::EncodableValue(value); } + static Value Int(int64_t value) { return flutter::EncodableValue(value); } + static Value Double(double value) { return flutter::EncodableValue(value); } + static Value String(const char* value, size_t length) { return flutter::EncodableValue(SanitizeUtf8(value, length)); } + + static ListBuilder NewList() { return flutter::EncodableList(); } + static void Append(ListBuilder& list, Value value) { list.push_back(std::move(value)); } + static Value FinishList(ListBuilder list) { return flutter::EncodableValue(std::move(list)); } + + static MapBuilder NewMap() { return flutter::EncodableMap(); } + static void Insert(MapBuilder& map, const char* key, size_t key_length, Value value) { + map[flutter::EncodableValue(SanitizeUtf8(key, key_length))] = std::move(value); } + static Value FinishMap(MapBuilder map) { return flutter::EncodableValue(std::move(map)); } + static void AbandonMap(MapBuilder&) {} +}; + +flutter::EncodableValue NodeToEncodableValue(const mpv_node* node) { + return plezy::mpv_common::ConvertNode(node); } // DComp-mode input forwarding. mpv's inner window lives on mpv's own thread @@ -585,21 +573,7 @@ void MpvPlayer::CommandAsync(const std::vector& args, CommandCallba return; } - std::vector c_args; - c_args.reserve(args.size() + 1); - for (const auto& arg : args) { - c_args.push_back(arg.c_str()); - } - c_args.push_back(nullptr); - - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - // mpv_command_async returns immediately - int result = mpv_command_async(mpv_, request_id, c_args.data()); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitCommandAsync(mpv_, pending_requests_, args, std::move(callback)); } void MpvPlayer::SetProperty(const std::string& name, const std::string& value) { @@ -618,14 +592,7 @@ void MpvPlayer::SetPropertyAsync(const std::string& name, const std::string& val return; } - uint64_t request_id = callback ? pending_requests_.RegisterStatus(std::move(callback)) : 0; - - char* property_value = const_cast(value.c_str()); - int result = mpv_set_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING, &property_value); - if (result < 0) { - auto cb = pending_requests_.TakeStatus(request_id); - if (cb) cb(result); - } + plezy::mpv_common::SubmitSetPropertyAsync(mpv_, pending_requests_, name, value, std::move(callback)); } void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback callback) { @@ -634,13 +601,7 @@ void MpvPlayer::GetPropertyAsync(const std::string& name, GetPropertyCallback ca return; } - uint64_t request_id = pending_requests_.RegisterProperty(std::move(callback)); - - int result = mpv_get_property_async(mpv_, request_id, name.c_str(), MPV_FORMAT_STRING); - if (result < 0) { - auto cb = pending_requests_.TakeProperty(request_id); - if (cb) cb(result, ""); - } + plezy::mpv_common::SubmitGetPropertyAsync(mpv_, pending_requests_, name, std::move(callback)); } void MpvPlayer::ObserveProperty(const std::string& name, const std::string& format, int id) { @@ -756,32 +717,12 @@ void MpvPlayer::EventLoop() { } void MpvPlayer::HandleMpvEvent(mpv_event* event) { + if (plezy::mpv_common::DispatchReplyEvent( + pending_requests_, event, [](const char* value) { return SanitizeUtf8(value); })) { + return; + } + switch (event->event_id) { - case MPV_EVENT_COMMAND_REPLY: - case MPV_EVENT_SET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - StatusCallback callback = pending_requests_.TakeStatus(request_id); - if (callback) { - callback(event->error); - } - break; - } - case MPV_EVENT_GET_PROPERTY_REPLY: { - uint64_t request_id = event->reply_userdata; - GetPropertyCallback callback = pending_requests_.TakeProperty(request_id); - if (callback) { - std::string value; - if (event->error >= 0) { - auto* prop = static_cast(event->data); - if (prop && prop->format == MPV_FORMAT_STRING && prop->data) { - auto c_value = *static_cast(prop->data); - if (c_value) value = SanitizeUtf8(c_value); - } - } - callback(event->error, value); - } - break; - } case MPV_EVENT_LOG_MESSAGE: { auto* msg = static_cast(event->data); char log_msg[512]; @@ -797,50 +738,11 @@ void MpvPlayer::HandleMpvEvent(mpv_event* event) { } case MPV_EVENT_PROPERTY_CHANGE: { auto* prop = static_cast(event->data); - mpv_node node{}; - node.format = prop->format; + mpv_node node = plezy::mpv_common::ExtractPropertyNode(prop); - switch (prop->format) { - case MPV_FORMAT_STRING: - node.u.string = prop->data ? *static_cast(prop->data) : nullptr; - break; - case MPV_FORMAT_FLAG: - node.u.flag = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_INT64: - node.u.int64 = prop->data ? *static_cast(prop->data) : 0; - break; - case MPV_FORMAT_DOUBLE: - node.u.double_ = prop->data ? *static_cast(prop->data) : 0.0; - break; - case MPV_FORMAT_NODE: - if (prop->data) { - node = *static_cast(prop->data); - } - break; - default: - node.format = MPV_FORMAT_NONE; - break; - } - - if (strcmp(prop->name, "current-ao") == 0) { - const char* current_ao = nullptr; - if (prop->format == MPV_FORMAT_STRING && prop->data) { - current_ao = *static_cast(prop->data); - } - const bool is_null = current_ao && strcmp(current_ao, "null") == 0; - const auto transition = - audio_recovery_.SetCurrentAudioOutputNull(is_null, plezy::mpv_common::AudioRecoveryState::Clock::now()); - if (transition == plezy::mpv_common::AudioOutputTransition::kFellBackToNull) { - LogRecovery("current-ao fell back to null; starting recovery"); - } else if (transition == plezy::mpv_common::AudioOutputTransition::kRecovered) { - LogRecovery("audio recovered (current-ao no longer null)"); - } - } - if (strcmp(prop->name, "audio-device-list") == 0 && event->reply_userdata == 0 && - audio_recovery_.OnAudioDeviceListChanged(plezy::mpv_common::AudioRecoveryState::Clock::now())) { - LogRecovery("audio-device-list changed while ao=null; rescheduling ao-reload"); - } + // The 100ms mpv_wait_event tick already polls for scheduled reloads. + const auto notice = plezy::mpv_common::ObserveAudioRecoveryProperty(audio_recovery_, event, prop); + if (notice.message) LogRecovery(notice.message); SendPropertyChange(prop->name, &node); break;