From 69fadc220d68cbe84cbceef66900aa62651a8fac Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Mon, 10 Aug 2026 17:14:53 +0200 Subject: [PATCH] chore: clean up code comments --- .github/workflows/build.yml | 2 - .gitignore | 4 +- .../regression_flows/03_tv_library_focus.yaml | 9 +- .../regression_flows/04_tv_player_keys.yaml | 22 ++-- .../05_tv_next_episode_back.yaml | 17 +-- .../06_playback_recovery.yaml | 6 +- .../07_sheet_back_dismiss.yaml | 27 +---- ...08_track_choice_survives_pending_pass.yaml | 18 +-- .../09_language_picker_locales.yaml | 17 +-- .../10_tv_settings_navigation.yaml | 20 +-- .maestro/subflows/ensure_onboarded.yaml | 18 +-- .maestro/subflows/onboard_jellyfin.yaml | 6 +- .../subflows/onboard_jellyfin_tv_device.yaml | 14 +-- .maestro/subflows/open_codec_sample.yaml | 6 +- .maestro/subflows/open_settings.yaml | 8 +- analysis_options.yaml | 9 +- android/app/build.gradle.kts | 2 - android/app/src/main/AndroidManifest.xml | 13 -- .../kotlin/com/edde746/plezy/MainActivity.kt | 1 - .../plezy/exoplayer/TrueHdCarrierSink.kt | 2 - .../com/edde746/plezy/mpv/MpvPlayerCore.kt | 3 - .../com/edde746/plezy/mpv/MpvPlayerPlugin.kt | 4 - .../exoplayer/BufferingStallPolicyTest.kt | 7 -- .../exoplayer/DvBitstreamSanitizerTest.kt | 4 - .../plezy/exoplayer/EndOfStreamPolicyTest.kt | 2 - .../plezy/exoplayer/LoadControlPolicyTest.kt | 4 - .../exoplayer/RawPositionAudioOutputTest.kt | 4 - .../plezy/exoplayer/ResumeStallPolicyTest.kt | 2 - .../plezy/exoplayer/TrueHdCarrierSinkTest.kt | 4 - android/fastlane/Fastfile | 3 - assets/rating_icons/rt_fresh.svg | 1 - assets/rating_icons/rt_spilled.svg | 1 - ios/Podfile | 1 - ios/Runner/AppDelegate.swift | 6 +- ios/Runner/MpvPlayer/MpvPipController.swift | 7 -- ios/Runner/MpvPlayer/MpvPlayerPlugin.swift | 4 - ios/fastlane/Fastfile | 3 - lib/database/app_database.dart | 1 - lib/focus/dpad_reorder_mixin.dart | 2 - lib/focus/focus_memory_tracker.dart | 3 - lib/focus/focusable_button.dart | 1 - lib/focus/focusable_wrapper.dart | 25 +--- lib/focus/input_mode_tracker.dart | 2 - lib/providers/download_provider.dart | 13 -- lib/providers/libraries_provider.dart | 3 - lib/providers/offline_mode_provider.dart | 1 - lib/providers/offline_watch_provider.dart | 6 - lib/screens/discover_screen.dart | 11 -- lib/screens/downloads/downloads_screen.dart | 7 -- lib/screens/hub_detail_screen.dart | 6 - lib/screens/libraries/alpha_jump_helper.dart | 3 - .../libraries/content_state_builder.dart | 4 - lib/screens/libraries/libraries_screen.dart | 21 ---- lib/screens/libraries/sort_bottom_sheet.dart | 1 - lib/screens/playlist/playlist_item_card.dart | 17 +-- lib/screens/settings/about_screen.dart | 2 - lib/screens/settings/licenses_screen.dart | 2 - lib/services/ambient_lighting_service.dart | 2 - lib/services/discord_rpc_service.dart | 8 -- lib/services/gamepad_service.dart | 5 - lib/services/keyboard_shortcuts_service.dart | 5 - lib/utils/content_utils.dart | 2 - lib/widgets/bottom_sheet_header.dart | 1 - lib/widgets/download_tree_view.dart | 33 ----- lib/widgets/hub_section.dart | 1 - lib/widgets/library_management_sheet.dart | 4 - lib/widgets/media_card.dart | 8 -- lib/widgets/media_context_menu.dart | 2 +- .../desktop_video_controls.dart | 11 -- .../video_controls/mobile_video_controls.dart | 2 - .../video_controls/parts/track_controls.dart | 2 - .../sheets/video_settings_sheet.dart | 7 -- .../video_controls/video_control_button.dart | 7 +- .../performance_stats.dart | 14 +-- .../widgets/track_chapter_controls.dart | 11 -- linux/CMakeLists.txt | 5 - linux/packaging/after-install.sh | 3 - linux/packaging/after-remove.sh | 2 - linux/packaging/build-packages.py | 5 - linux/runner/CMakeLists.txt | 18 +-- macos/Runner/Configs/AppInfo.xcconfig | 8 -- macos/Runner/MainFlutterWindow.swift | 8 +- macos/Runner/MpvPlayer/MpvPipController.swift | 5 - macos/Runner/MpvPlayer/MpvPlayerPlugin.swift | 18 +-- macos/Runner/WindowDelegate.swift | 4 - macos/Runner/WindowUtilsPlugin.swift | 13 +- .../fluttercavalry/saf_util/SafUtilPlugin.kt | 13 +- pubspec.yaml | 27 ++--- scripts/check_build_workflow.py | 15 +-- scripts/check_hardcoded_strings.py | 22 +--- scripts/check_linux_package_deps.py | 82 +++---------- scripts/check_shrinker_rules.py | 12 +- scripts/check_windows_installer.py | 11 +- scripts/check_windows_msix.py | 43 ++----- scripts/ci_checks.sh | 18 +-- scripts/ci_guard_checks.sh | 15 +-- scripts/generate_android_icons.sh | 26 +--- scripts/generate_windows_msix_assets.ps1 | 26 +--- scripts/run_maestro.py | 10 +- scripts/run_maestro_ci.py | 25 +--- scripts/run_tests.sh | 40 ++---- server/artifact_store.go | 41 ++----- server/main.go | 107 ++++++---------- server/main_test.go | 114 +++++------------- server/oauth.go | 67 ++++------ server/oauth_test.go | 31 ++--- server/rate_limit.go | 18 +-- slang.yaml | 5 +- .../connection/connection_bootstrap_test.dart | 1 - test/connection/connection_models_test.dart | 7 +- test/connection/connection_registry_test.dart | 7 -- test/database/app_database_test.dart | 27 ----- test/database/download_operations_test.dart | 27 +---- test/focus/key_event_utils_test.dart | 3 - test/media/media_playlist_test.dart | 13 +- test/mixins/paginated_item_loader_test.dart | 7 -- test/mixins/tab_navigation_mixin_test.dart | 2 - test/mixins/watch_state_aware_test.dart | 1 - ...ayer_android_subtitle_visibility_test.dart | 1 - test/mpv/player_error_http_status_test.dart | 1 - .../hidden_libraries_provider_test.dart | 3 - .../providers/multi_server_provider_test.dart | 4 - test/providers/shader_provider_test.dart | 3 - test/providers/theme_provider_test.dart | 3 - .../music/album_detail_screen_test.dart | 2 - test/screens/music/queue_sheet_test.dart | 1 - .../base_shared_preferences_service_test.dart | 11 +- test/services/bif_thumbnail_service_test.dart | 34 +----- .../data_aggregation_bridge_test.dart | 7 +- .../download_storage_service_test.dart | 36 ------ .../episode_navigation_service_test.dart | 4 - .../jellyfin_client_failures_test.dart | 9 +- test/services/jellyfin_client_urls_test.dart | 7 +- test/services/jellyfin_mappers_test.dart | 2 - test/services/jellyfin_media_info_test.dart | 2 - test/services/jellyfin_music_mapper_test.dart | 1 - .../services/local_playback_history_test.dart | 1 - test/services/multi_server_manager_test.dart | 29 ----- .../offline_watch_sync_service_test.dart | 50 +------- test/services/play_queue_launcher_test.dart | 8 -- .../playback_progress_tracker_test.dart | 33 +---- test/services/plex_api_cache_test.dart | 20 --- test/services/plex_mappers_test.dart | 2 - test/services/sleep_timer_service_test.dart | 32 ----- test/services/storage_service_test.dart | 44 ------- test/services/track_manager_test.dart | 32 ----- .../track_selection_service_test.dart | 75 +----------- tvos/Runner/AppDelegate.swift | 8 +- tvos/scripts/set_tvos_target_pods.sh | 1 - tvos/scripts/set_tvos_target_runner.sh | 1 - website/src/app.d.ts | 7 -- .../src/lib/components/DownloadButtons.svelte | 3 - website/src/lib/components/Features.svelte | 9 -- website/src/lib/components/Screenshots.svelte | 6 +- .../src/lib/components/SectionHeader.svelte | 4 +- website/src/lib/content/downloads.ts | 16 +-- website/src/lib/index.ts | 1 - .../src/lib/server/homepage_store_metadata.ts | 4 +- website/src/routes/+page.server.ts | 3 +- website/src/routes/layout.css | 9 +- website/svelte.config.js | 3 - website/tsconfig.json | 6 +- windows/CMakeLists.txt | 8 -- windows/build-installer.ps1 | 10 -- windows/runner/CMakeLists.txt | 14 --- windows/runner/flutter_window.cpp | 14 +-- windows/runner/flutter_window.h | 6 - windows/runner/main.cpp | 4 +- windows/runner/mpv/display_mode_manager.cpp | 3 - windows/runner/mpv/mpv_plugin.cpp | 3 - 170 files changed, 324 insertions(+), 1765 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ebf19a46..d423681d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -271,13 +271,11 @@ jobs: MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} run: | - # Create temporary keychain KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH - # Import certificate to keychain CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign diff --git a/.gitignore b/.gitignore index 99f47ebb..9b6ba5b0 100644 --- a/.gitignore +++ b/.gitignore @@ -18,9 +18,7 @@ migrate_working_dir/ *.iws .idea/ -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. +# Keep .vscode/ available for contributors' local configuration. #.vscode/ # Flutter/Dart/Pub related diff --git a/.maestro/regression_flows/03_tv_library_focus.yaml b/.maestro/regression_flows/03_tv_library_focus.yaml index 4951eb75..15c0b4c5 100644 --- a/.maestro/regression_flows/03_tv_library_focus.yaml +++ b/.maestro/regression_flows/03_tv_library_focus.yaml @@ -22,8 +22,8 @@ tags: - pressKey: "Remote Dpad Up" - pressKey: "Remote Dpad Right" - pressKey: "Remote Dpad Right" -# The grid row must be matchable before the sort sheet opens, otherwise the -# occlusion assertion below would pass for the wrong reason. +# Wait for the row before opening the sheet so occlusion is tested against the +# grid, not an absent or stale match. - extendedWaitUntil: visible: "(?s).*Alpha Archive.*" timeout: 15000 @@ -31,9 +31,8 @@ tags: - extendedWaitUntil: visible: "Title" timeout: 10000 -# The host renders the grid, the barrier, and the sheet in one Stack and never -# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics -# an occluded row still read as visible to accessibility and to Maestro. +# The grid remains mounted under the Stack; BlockSemantics is required for +# occluded rows to disappear from Maestro's accessibility tree. - assertNotVisible: "(?s).*Alpha Archive.*" - pressKey: "Remote Dpad Center" - pressKey: "Remote Dpad Left" diff --git a/.maestro/regression_flows/04_tv_player_keys.yaml b/.maestro/regression_flows/04_tv_player_keys.yaml index c9335d46..f6a99b17 100644 --- a/.maestro/regression_flows/04_tv_player_keys.yaml +++ b/.maestro/regression_flows/04_tv_player_keys.yaml @@ -16,13 +16,9 @@ tags: visible: "(?s)^(Play|Resume).*$" commands: - pressKey: "Remote Dpad Center" -# The TV player opens with its chrome down so the OSD and timebar never sit -# over the opening seconds of the picture (#1765), so the Pause button can no -# longer stand in for "the player has finished loading". Wait for the detail -# screen to go first: the labelled spinner is necessarily up the moment the -# player route owns the frame, and it only clears once playback reports its -# first frame or gives up, so this can never advance before the media opens. -# A give-up lands on the transport assertions below, which is where it belongs. +# TV playback starts with chrome hidden (#1765), so wait for its loading label +# rather than treating the Pause button as readiness. A failed open reaches the +# transport assertions below. - extendedWaitUntil: notVisible: "Overview" timeout: 30000 @@ -30,18 +26,14 @@ tags: notVisible: "Loading video" timeout: 30000 - assertNotVisible: "(?s)^(Play|Pause)$" -# Hardware transport keys drive playback without raising the chrome (#1676): -# the player answers with a centred disc (pause) or a side readout (seek), never the -# full chrome, so subtitles stay readable. -# Pause first — it also freezes the clip, so the seek assertions below cannot -# race the end of a short fixture item. +# Transport keys keep chrome hidden (#1676); pause first so seeks cannot race +# the end of this short fixture. - pressKey: "Remote Media Play Pause" - extendedWaitUntil: visible: "Paused" timeout: 10000 - assertNotVisible: "(?s)^Play$" -# D-pad seeking reports through the skip badge, never the scrub bar, and -# consecutive presses in one direction stack into a running total. +# D-pad seeks use the cumulative skip badge, not the scrub bar. - pressKey: "Remote Dpad Right" - extendedWaitUntil: visible: "(?s)^Seek forward 10 seconds$" @@ -56,7 +48,7 @@ tags: visible: "Playing" timeout: 10000 - assertNotVisible: "(?s)^Pause$" -# Select stays the deliberate way to bring the chrome back. +# Select is the explicit way to restore the chrome. - pressKey: "Remote Dpad Center" - extendedWaitUntil: visible: "(?s)^(Play|Pause)$" diff --git a/.maestro/regression_flows/05_tv_next_episode_back.yaml b/.maestro/regression_flows/05_tv_next_episode_back.yaml index 1069c8c5..8ae56194 100644 --- a/.maestro/regression_flows/05_tv_next_episode_back.yaml +++ b/.maestro/regression_flows/05_tv_next_episode_back.yaml @@ -7,10 +7,8 @@ tags: - playback --- - runFlow: ../subflows/onboard_jellyfin_tv.yaml -# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A -# tap here flips InputModeTracker into pointer mode, which collapses the rail -# to icons and leaves no "Search" label to hit — and a percentage coordinate -# does not survive the jump from a phone in forced TV mode to a 4K television. +# Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label, +# while percentage coordinates are not portable across TV sizes. - pressKey: "Remote Dpad Left" - extendedWaitUntil: visible: "Search" @@ -38,11 +36,8 @@ tags: - waitForAnimationToEnd: timeout: 5000 - tapOn: "(?s)^Play S1E1$" -# The TV player opens with its chrome down (#1765), so the Pause button can no -# longer stand in for "the player has finished loading". Wait for the detail -# screen to go first: the labelled spinner is necessarily up the moment the -# player route owns the frame, and it only clears once playback reports its -# first frame or gives up, so this can never advance before the media opens. +# The player starts with chrome hidden (#1765); wait for its loading label +# rather than using Pause as readiness. - extendedWaitUntil: notVisible: "(?s)^Play S1E1$" timeout: 30000 @@ -51,9 +46,7 @@ tags: timeout: 30000 - pressKey: "Remote Media Fast Forward" - pressKey: "Remote Media Fast Forward" -# Gate on the prompt's own Cancel action, the label the iOS branch below taps. -# "Next Episode" is the credits skip button at this point in the episode, so -# waiting on it would let Back fire before the prompt ever opened. +# Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back. - extendedWaitUntil: visible: "(?s)^Cancel$" timeout: 20000 diff --git a/.maestro/regression_flows/06_playback_recovery.yaml b/.maestro/regression_flows/06_playback_recovery.yaml index 8cbc5fcb..cbcb3ba7 100644 --- a/.maestro/regression_flows/06_playback_recovery.yaml +++ b/.maestro/regression_flows/06_playback_recovery.yaml @@ -8,10 +8,8 @@ tags: --- - runFlow: ../subflows/onboard_jellyfin.yaml - tapOn: "(?s)^Libraries.*" -# Every seeded alphabet title carries the same dateadded, so which of them the -# "Recently Added" rail returns is not stable across scans. This flow only -# needs some playable movie, so accept any of them rather than pinning one and -# failing whenever the tie-break lands elsewhere. +# All seeded titles share dateadded, so accept any playable Recently Added movie +# instead of relying on an unstable tie-break. - extendedWaitUntil: visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*" timeout: 15000 diff --git a/.maestro/regression_flows/07_sheet_back_dismiss.yaml b/.maestro/regression_flows/07_sheet_back_dismiss.yaml index d9c09782..4612a881 100644 --- a/.maestro/regression_flows/07_sheet_back_dismiss.yaml +++ b/.maestro/regression_flows/07_sheet_back_dismiss.yaml @@ -7,20 +7,9 @@ tags: - settings - sheets --- -# Guards 8e1904dd and c48cbf70 together. -# -# On a phone, Settings is a pushed route. Before c48cbf70 it carried no -# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a -# single Back tore down the sheet *and* Settings. Before 8e1904dd the host -# answered the platform pop with a TV-only dedup marker that could already be -# spent, stranding the sheet open with no way out. Either defect fails the -# "Manage Libraries is still there after one Back" assertion below. -# -# The same commit blocks semantics behind the barrier, so the occlusion -# assertions pin that an open sheet actually hides the rows underneath. -# Pristine state on purpose. The TV regressions in the same CI group leave -# "Force TV mode" enabled, and this flow asserts the handheld Settings route, -# so it must not inherit whichever layout the previous flow left behind. +# Guards 8e1904dd and c48cbf70: phone Settings must host the sheet so one Back +# dismisses only the sheet, while BlockSemantics hides rows behind the barrier. +# Start clean because preceding TV flows may leave Force TV mode enabled. - runFlow: ../subflows/onboard_jellyfin.yaml - runFlow: ../subflows/open_settings.yaml - assertVisible: "(?s)^Services.*" @@ -29,10 +18,8 @@ tags: - extendedWaitUntil: visible: "Maestro Movies" timeout: 15000 -# The barrier swallows every pointer event, so the rows behind it must leave -# the semantics tree too. Without BlockSemantics an occluded row still reads as -# visible and a tap on it lands on the barrier instead. "Manage Libraries" is -# excluded on purpose: it is also the sheet's own title. +# The barrier removes covered rows from semantics; the sheet title remains +# visible because it belongs to the sheet. - assertNotVisible: "(?s)^Services.*" - assertNotVisible: "(?s)^Video Playback.*" - assertNotVisible: "(?s)^Appearance.*" @@ -41,15 +28,13 @@ tags: - waitForAnimationToEnd: timeout: 3000 - assertNotVisible: "Maestro Movies" -# One Back closed only the sheet: Settings is still the current route. - extendedWaitUntil: visible: "(?s)^Manage Libraries.*" timeout: 10000 - assertVisible: "(?s)^Services.*" - assertVisible: "(?s)^Video Playback.*" - assertNotVisible: "Discover" -# Reopening proves the host did not strand the one-shot dedup marker: a second -# dismissal has to work exactly like the first. +# Reopening verifies the one-shot pop deduplication marker was not stranded. - tapOn: "(?s)^Manage Libraries.*" - extendedWaitUntil: visible: "Maestro Movies" diff --git a/.maestro/regression_flows/08_track_choice_survives_pending_pass.yaml b/.maestro/regression_flows/08_track_choice_survives_pending_pass.yaml index 79ce2958..f5820fc2 100644 --- a/.maestro/regression_flows/08_track_choice_survives_pending_pass.yaml +++ b/.maestro/regression_flows/08_track_choice_survives_pending_pass.yaml @@ -7,20 +7,10 @@ tags: - playback - subtitles --- -# Guards 468d6804. When a source advertises tracks the native list has not -# produced yet, TrackManager arms an automatic selection pass with a 5s first -# attempt and a 25s deadline. A user pick made inside that window used to only -# persist the preference, leaving the pass armed, so the choice snapped back to -# the server/profile preferred track up to ~30s later. The pick now retires the -# pending pass, and the wait below is long enough to outlive both timers. -# -# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams -# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle -# streams, so both picks below are genuine changes away from the automatic one. -# Pristine state on purpose, for two reasons: the TV regressions in the same -# CI group leave "Force TV mode" enabled, and a remembered track selection -# from an earlier run would pre-select the very rows this flow picks, making -# every assertion below vacuous. +# Guards 468d6804: a user pick must retire TrackManager's pending automatic +# selection pass, or the server preference can overwrite it up to 30s later. +# The codec fixture supplies non-default audio and subtitle choices, and clean +# state prevents remembered selections from making the assertions vacuous. - runFlow: ../subflows/onboard_jellyfin.yaml - runFlow: file: ../subflows/open_codec_sample.yaml diff --git a/.maestro/regression_flows/09_language_picker_locales.yaml b/.maestro/regression_flows/09_language_picker_locales.yaml index ff2fd96b..0db1d4bf 100644 --- a/.maestro/regression_flows/09_language_picker_locales.yaml +++ b/.maestro/regression_flows/09_language_picker_locales.yaml @@ -7,16 +7,9 @@ tags: - settings - i18n --- -# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in -# appearance_settings_screen.dart, so a missing arm is a compile error, but a -# locale whose JSON never reached the generator ships a picker entry that falls -# back to English. Selecting Turkish and reading root-level navigation labels -# proves the generated locale is wired end to end. -# -# This flow mutates a persisted pref, and the app language decides every -# selector after the tap, so it starts from a cleared install rather than -# ensure_onboarded: a run that dies mid-flow must not leave the next flow -# hunting for English strings in a Turkish tree. +# Guards 7677d159 and 100d7729: selecting Turkish and reading root navigation +# labels proves the generated locale is wired end to end. Start clean because +# the language preference persists and affects every later selector. - runFlow: ../subflows/onboard_jellyfin.yaml - runFlow: ../subflows/open_settings.yaml - tapOn: "(?s)^Appearance.*" @@ -54,9 +47,7 @@ tags: - assertVisible: "Keşfet" - assertNotVisible: "(?s)^Libraries.*" - assertNotVisible: "Discover" -# Walk back through the Turkish tree and restore English. The pref outlives a -# plain launchApp, so leaving the device in Turkish would break every later -# flow in the suite. +# Restore English before leaving the device; the locale preference outlives launchApp. - repeat: times: 3 while: diff --git a/.maestro/regression_flows/10_tv_settings_navigation.yaml b/.maestro/regression_flows/10_tv_settings_navigation.yaml index 1b1f532d..5c450de3 100644 --- a/.maestro/regression_flows/10_tv_settings_navigation.yaml +++ b/.maestro/regression_flows/10_tv_settings_navigation.yaml @@ -7,21 +7,11 @@ tags: - tv - settings --- -# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV -# settings navigation generally. -# -# It deliberately does NOT guard the 15b54e2e density change. That commit took -# every settings row from ~80dp to ~61dp, but the rows below the fold stay in -# the semantics tree either way, so this flow passes identically on both sides -# of it — measured, not assumed. A real guard would need a pixel `height` -# assertion, and Maestro has no DPR-relative form, so pinning one here would -# only hold for the box it was written on. Row geometry belongs to -# test/widgets/setting_tile_test.dart. -# -# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer -# mode on the first touch event and the TV number spinner only builds in -# keyboard mode, so a single tapOn anywhere above would silently swap the -# spinner for a plain text field. +# Guards ef310459 and the Apple-only gates touched by 6c14049e. +# This flow intentionally does not pin 15b54e2e's row density: semantics stay +# stable across that change, and Maestro has no DPR-relative height assertion. +# Use D-pad only; a tap switches InputModeTracker to pointer mode and removes +# the TV number spinner. - runFlow: ../subflows/onboard_jellyfin_tv_device.yaml - pressKey: "Remote Dpad Left" - extendedWaitUntil: diff --git a/.maestro/subflows/ensure_onboarded.yaml b/.maestro/subflows/ensure_onboarded.yaml index 5eb9c0c8..3b210a65 100644 --- a/.maestro/subflows/ensure_onboarded.yaml +++ b/.maestro/subflows/ensure_onboarded.yaml @@ -1,20 +1,10 @@ appId: com.edde746.plezy --- -# Reach a signed-in Home without paying for onboarding that already happened. -# -# `onboard_jellyfin.yaml` clears app state and retypes the server URL and -# credentials, which measures ~59s per flow on a Pixel 7 and dominates every -# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored -# data and cold-starts straight back to Home, which measures ~16s. -# -# The guard keeps each flow runnable on its own: when no session is stored -- -# a fresh install, or a run after the logout flow -- "Discover" is absent and -# this falls through to the real onboarding. Flows that must prove onboarding -# itself, or that need pristine state, keep calling onboard_jellyfin.yaml. +# Reach a signed-in Home without repeating onboarding. A plain launch preserves +# stored state (~16s versus ~59s for retyping credentials); fall back to full +# onboarding when no session exists so each flow remains runnable alone. - launchApp -# Settle on a known screen first. Branching straight off `launchApp` races the -# splash: "Discover" has not rendered yet, the guard reads it as absent, and -# every flow pays for a full re-onboarding it did not need. +# Wait for the splash to resolve before deciding whether onboarding is needed. - extendedWaitUntil: visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*" timeout: 30000 diff --git a/.maestro/subflows/onboard_jellyfin.yaml b/.maestro/subflows/onboard_jellyfin.yaml index 62810d19..cd87bbd2 100644 --- a/.maestro/subflows/onboard_jellyfin.yaml +++ b/.maestro/subflows/onboard_jellyfin.yaml @@ -10,10 +10,8 @@ appId: com.edde746.plezy - extendedWaitUntil: visible: "(?s)^(?:Connect to Jellyfin|Wait).*" timeout: 30000 - # A miss on `tapOn ... optional: true` still runs Maestro's full element - # search before giving up, which measured 3s here and 7.8s for "Sign in" - # on every onboarding. These taps normally find nothing, so gate them on - # a single visibility check instead. +# Optional taps still pay Maestro's full search cost when they miss; guard them +# with one visibility check instead. - runFlow: when: visible: "Wait" diff --git a/.maestro/subflows/onboard_jellyfin_tv_device.yaml b/.maestro/subflows/onboard_jellyfin_tv_device.yaml index b61da160..b4317cb9 100644 --- a/.maestro/subflows/onboard_jellyfin_tv_device.yaml +++ b/.maestro/subflows/onboard_jellyfin_tv_device.yaml @@ -1,12 +1,7 @@ appId: com.edde746.plezy --- -# Onboarding for a device that already reports itself as a TV. -# -# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it -# onboards through the handheld layout and then walks Settings to flip -# "Force TV mode". A real Android TV box takes the TV layout from the first -# frame, so that walk has no handheld chrome to tap and the toggle would only -# re-assert what PlatformDetector already reports. +# Onboard a device that reports itself as a TV; unlike the phone flow, it starts +# in TV layout and needs no Force TV mode walk. - retry: maxRetries: 1 commands: @@ -32,9 +27,8 @@ appId: com.edde746.plezy - tapOn: "(?s)Server URLs.*" - inputText: ${JELLYFIN_URL} - tapOn: "Find server" -# A TV-sized layout offers Quick Connect first because typing on a remote is -# slow. The code path needs a second device to approve, so fall back to the -# username form the phone flow already uses. +# Quick Connect is awkward on a remote and needs another device, so use the +# username form as fallback. - extendedWaitUntil: visible: "(?s)^(?:Username|Cancel)$" timeout: 30000 diff --git a/.maestro/subflows/open_codec_sample.yaml b/.maestro/subflows/open_codec_sample.yaml index 7bae2124..fafd1e49 100644 --- a/.maestro/subflows/open_codec_sample.yaml +++ b/.maestro/subflows/open_codec_sample.yaml @@ -17,10 +17,8 @@ appId: com.edde746.plezy platform: Android commands: - hideKeyboard -# A card announces its watch state three ways, and the codec samples keep a -# resume position on the server once any earlier flow has played them, so the -# partial form has to be accepted for this subflow to be re-runnable against a -# fixture container that is not thrown away between suites. +# Codec cards retain watch state and resume position between flows, so accept +# any valid watch-state label to keep this subflow rerunnable. - extendedWaitUntil: visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$" timeout: 15000 diff --git a/.maestro/subflows/open_settings.yaml b/.maestro/subflows/open_settings.yaml index 00bc0449..249ce78a 100644 --- a/.maestro/subflows/open_settings.yaml +++ b/.maestro/subflows/open_settings.yaml @@ -1,11 +1,7 @@ appId: com.edde746.plezy --- -# Reach Settings from a signed-in Home on a phone layout. -# -# The profile menu is itself an overlay sheet on the MainScreen host, so a tap -# issued while the previous route is still settling opens nothing. The guarded -# repeat re-opens the menu instead of failing the flow, matching the pattern -# already used by flows/07_profiles_settings.yaml. +# Reach phone Settings from signed-in Home. The profile menu is an overlay +# sheet; retry while the previous route settles. - extendedWaitUntil: visible: "Discover" timeout: 30000 diff --git a/analysis_options.yaml b/analysis_options.yaml index 57755a26..c6b3ff7d 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -15,8 +15,7 @@ linter: prefer_final_in_for_each: true avoid_print: true -# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes -# on Linux. CI invokes the supported check-unused commands explicitly. +# DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly. dart_code_linter: rules-exclude: - "test/**" @@ -27,7 +26,7 @@ dart_code_linter: - package:dart_code_linter/presets/recommended.yaml rules: - # --- Flutter rules (on top of recommended) --- + # Flutter-specific rules. - avoid-border-all - avoid-shrink-wrap-in-lists - avoid-expanded-as-spacer @@ -37,7 +36,7 @@ dart_code_linter: - prefer-define-hero-tag - use-setstate-synchronously - # --- Additional useful Dart rules --- + # Additional Dart rules. - avoid-cascade-after-if-null - avoid-collection-methods-with-unrelated-types - avoid-unnecessary-type-assertions @@ -48,7 +47,7 @@ dart_code_linter: - prefer-enums-by-name - prefer-commenting-analyzer-ignores - # --- Disable noisy rules from recommended preset --- + # Disabled noisy rules. - no-magic-number: false - avoid-dynamic: false - format-comment: false diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index c7ba7ea5..5b33b40f 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -336,8 +336,6 @@ android { defaultConfig { applicationId = "com.edde746.plezy" - // You can update the following values to match your application needs. - // For more information, see: https://flutter.dev/to/review-gradle-config. minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26 targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0f1db247..22ab1946 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -9,17 +9,14 @@ - - - @@ -30,15 +27,11 @@ - - - - - - @@ -88,7 +76,6 @@ - - diff --git a/assets/rating_icons/rt_spilled.svg b/assets/rating_icons/rt_spilled.svg index af36063b..b5dd8b1e 100644 --- a/assets/rating_icons/rt_spilled.svg +++ b/assets/rating_icons/rt_spilled.svg @@ -1,5 +1,4 @@ - diff --git a/ios/Podfile b/ios/Podfile index a154672b..e8c641fc 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,4 +1,3 @@ -# Uncomment this line to define a global platform for your project platform :ios, '15.5' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 60276834..53f19f8f 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -14,9 +14,7 @@ import MediaPlayer _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Configure audio session for media playback. Not activated here: the - // session is non-mixing, so activation stops other apps' audio — it is - // claimed when playback actually starts. + // Configure the non-mixing session; activate it only when playback starts. do { let session = AVAudioSession.sharedInstance() try session.setCategory(.playback, mode: .default) @@ -32,12 +30,10 @@ import MediaPlayer func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) - // Register MPV player plugin if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") { MpvPlayerPlugin.register(with: registrar) } - // Register the audio-only MPV player plugin (music playback) if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") { MpvAudioPlayerPlugin.register(with: registrar) } diff --git a/ios/Runner/MpvPlayer/MpvPipController.swift b/ios/Runner/MpvPlayer/MpvPipController.swift index 1923ecf0..9db0086d 100644 --- a/ios/Runner/MpvPlayer/MpvPipController.swift +++ b/ios/Runner/MpvPlayer/MpvPipController.swift @@ -39,19 +39,13 @@ import UIKit /// Delegate to notify the plugin of PiP lifecycle events protocol MpvPipDelegate: AnyObject { - /// Called when PiP is about to start (system or app-initiated) func pipWillStart() func pipDidStart() - /// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI). func pipDidStop(restored: Bool) func pipDidFailToStart(error: Error?) - /// Forward play/pause commands from PiP overlay to mpv func pipSetPlaying(_ playing: Bool) - /// Forward skip forward/backward commands from PiP overlay to mpv func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) - /// Query whether mpv is currently playing var isPipPlaying: Bool { get } - /// Get total duration in seconds var pipDuration: Double { get } } protocol MpvPictureInPictureControlling: AnyObject { @@ -151,7 +145,6 @@ import UIKit createPipController() } - /// Helper that conforms to the iOS 15+ delegate protocols private var delegateHelper: AnyObject? private func createPipController() { diff --git a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift index 566cd587..8be3d1be 100644 --- a/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/ios/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -12,12 +12,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS private weak var registrar: FlutterPluginRegistrar? var nameToId: [String: Int] = [:] - // MpvPluginShared conformance var coreBase: MpvPlayerCoreBase? { playerCore } func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) } func updatePlayerFrame() { playerCore?.updateFrame() } - // PiP private var pipController: MpvPipController? private var pipChannel: FlutterMethodChannel? private var autoPipEnabled = false @@ -207,7 +205,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS return } pip.setAutoStart(true) - // Warm the layer so the system considers PiP possible if let pc = self.playerCore { pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) } @@ -287,7 +284,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS } } - /// Unified cleanup for all PiP exit paths private func cleanupPip(notify: Bool, pause: Bool = false) { playerCore?.setPipSubtitleCompositing(false) playerCore?.isPipStarting = false diff --git a/ios/fastlane/Fastfile b/ios/fastlane/Fastfile index 31f110e1..88355145 100644 --- a/ios/fastlane/Fastfile +++ b/ios/fastlane/Fastfile @@ -13,13 +13,10 @@ platform :ios do lane :deploy_appstore do git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip - # Build the Flutter app sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios") - # Upload debug symbols sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios") - # Upload to App Store upload_to_app_store( ipa: "../build/ios/ipa/Plezy.ipa", force: true, diff --git a/lib/database/app_database.dart b/lib/database/app_database.dart index bf1a5539..cab80814 100644 --- a/lib/database/app_database.dart +++ b/lib/database/app_database.dart @@ -119,7 +119,6 @@ class AppDatabase extends _$AppDatabase { // migrations while failures are still covered by this close/rethrow // boundary and the caller's startup download-recovery decision. await database.customSelect('SELECT 1').get(); - // It deliberately does not claim capacity for a later write. } final outcome = await _tvosRecoveryQueue.run( () => store.reconcile( diff --git a/lib/focus/dpad_reorder_mixin.dart b/lib/focus/dpad_reorder_mixin.dart index 38e07333..58a8c7b5 100644 --- a/lib/focus/dpad_reorder_mixin.dart +++ b/lib/focus/dpad_reorder_mixin.dart @@ -74,7 +74,6 @@ mixin DpadReorderListMixin on State { 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( @@ -104,7 +103,6 @@ mixin DpadReorderListMixin on State { final backResult = handleBackKeyAction(event, () { if (movingIndex != null) { - // Cancel move - restore original position setState(() { final originalOrder = _originalOrder; if (originalOrder != null) { diff --git a/lib/focus/focus_memory_tracker.dart b/lib/focus/focus_memory_tracker.dart index 88356243..8bf9014a 100644 --- a/lib/focus/focus_memory_tracker.dart +++ b/lib/focus/focus_memory_tracker.dart @@ -42,7 +42,6 @@ class FocusMemoryTracker { /// Restore focus to the last focused item, or fallback if provided /// Returns true if focus was successfully restored bool restoreFocus({String? fallbackKey}) { - // Try to restore last focused item if (_lastFocusedKey != null) { final node = _nodes[_lastFocusedKey]; if (node != null) { @@ -50,7 +49,6 @@ class FocusMemoryTracker { return true; } } - // Fallback: focus the provided key if available if (fallbackKey != null) { final node = _nodes[fallbackKey]; if (node != null) { @@ -69,7 +67,6 @@ class FocusMemoryTracker { _nodes.remove(key); _focused.remove(key); } - // Clear last focused if it was pruned if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) { _lastFocusedKey = null; } diff --git a/lib/focus/focusable_button.dart b/lib/focus/focusable_button.dart index f6173003..da69fc53 100644 --- a/lib/focus/focusable_button.dart +++ b/lib/focus/focusable_button.dart @@ -51,7 +51,6 @@ class _FocusableButtonState extends State { final showFocus = _isFocused && isKeyboard; final duration = FocusTheme.getAnimationDuration(context); final enabled = widget.onPressed != null; - // In dpad mode: focused = full opacity, unfocused = dimmed final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0; return FocusableWrapper( diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index 1179f3e1..651e3f51 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox { /// A wrapper widget that makes its child focusable with D-pad navigation support. /// -/// Provides: -/// - Visual focus indicator (border + scale animation) -/// - Keyboard/D-pad event handling (Enter/Select to activate) -/// - Optional auto-scroll to keep focused item visible -/// - Long-press detection for SELECT key -/// - Navigation callbacks (UP, BACK) class FocusableWrapper extends StatefulWidget { /// The child widget to wrap. final Widget child; @@ -287,12 +281,10 @@ class _FocusableWrapperState extends State with SingleTickerPr void didUpdateWidget(FocusableWrapper oldWidget) { super.didUpdateWidget(oldWidget); - // Handle focusNode changes if (widget.focusNode != oldWidget.focusNode) { _bindFocusNode(); } - // Update canRequestFocus if (widget.canRequestFocus != oldWidget.canRequestFocus) { _focusNode.canRequestFocus = widget.canRequestFocus; } @@ -368,7 +360,6 @@ class _FocusableWrapperState extends State with SingleTickerPr final viewport = scrollable.context.findRenderObject() as RenderBox?; if (viewport == null) return; - // Get item's position relative to viewport final itemBox = renderObject as RenderBox; final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport); @@ -376,17 +367,14 @@ class _FocusableWrapperState extends State with SingleTickerPr final itemHeight = itemBox.size.height; final itemVerticalCenter = itemPosition.dy + itemHeight / 2; - // Account for focus decoration when checking item visibility final itemTop = itemPosition.dy - _focusDecorationPadding; final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding; if (widget.useComfortableZone) { - // Define comfortable zone - if item (including focus decoration) is within middle 60% of viewport, don't scroll final comfortZoneTop = viewportHeight * 0.2; final comfortZoneBottom = viewportHeight * 0.8; if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) { - // Item is in comfortable zone, no need to scroll return; } } else { @@ -394,22 +382,17 @@ class _FocusableWrapperState extends State with SingleTickerPr // close to target position (prevents jitter when navigating horizontally) final targetY = viewportHeight * widget.scrollAlignment; final distance = (itemVerticalCenter - targetY).abs(); - // Skip scroll if within half the item height of target if (distance < itemHeight / 2) { return; } } - // Calculate target scroll offset for the immediate scrollable only. - // This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables, - // which can cause issues with nested scroll views (e.g., chips bar scrolling - // out of view when focusing grid items in library browse tab). + // Avoid Scrollable.ensureVisible, which scrolls all ancestor scrollables and + // can move nested views (e.g. the chips bar) out of view when focusing grid items. final position = scrollable.position; final currentOffset = position.pixels; - - // Target: item center should be at scrollAlignment of viewport - // Add padding to ensure focus decoration is fully visible final targetViewportY = viewportHeight * widget.scrollAlignment; + var scrollDelta = itemVerticalCenter - targetViewportY; // If item would be near the top edge, add extra scroll to show focus decoration @@ -471,7 +454,6 @@ class _FocusableWrapperState extends State with SingleTickerPr } } - // Handle SELECT key with optional long-press detection if (key.isSelectKey) { if (widget.enableLongPress) { final result = _selectLongPress.handleKeyEvent( @@ -547,7 +529,6 @@ class _FocusableWrapperState extends State with SingleTickerPr } else { final duration = FocusTheme.getAnimationDuration(context); final controller = _ensureAnimationController(); - // Update animation duration if theme changes if (controller.duration != duration) { controller.duration = duration; } diff --git a/lib/focus/input_mode_tracker.dart b/lib/focus/input_mode_tracker.dart index 5a8beb5f..17335936 100644 --- a/lib/focus/input_mode_tracker.dart +++ b/lib/focus/input_mode_tracker.dart @@ -85,9 +85,7 @@ class _InputModeTrackerState extends State { // is identity-guarded — otherwise startup's bootstrap→app swap would leave // the live registration cleared. InputModeTracker._instance = this; - // Initialize focus highlight strategy based on starting mode _updateFocusHighlightStrategy(_mode); - // Listen to hardware keyboard events globally HardwareKeyboard.instance.addHandler(_handleKeyEvent); } diff --git a/lib/providers/download_provider.dart b/lib/providers/download_provider.dart index 870bd9a9..29db49b5 100644 --- a/lib/providers/download_provider.dart +++ b/lib/providers/download_provider.dart @@ -114,13 +114,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin DownloadProvider({required this._downloadManager, required this._database}) : _syncRuleExecutor = SyncRuleExecutor(database: _database) { _metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged); - // Listen to progress updates from the download manager _progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate); - // Listen to deletion progress updates _deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate); - // Load persisted downloads from database _initFuture = _loadPersistedDownloads(); // Lets the diagnostics service score whether downloads actually advance @@ -400,7 +397,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin // Initialize artwork directory path for synchronous access await storageService.getArtworkDirectory(); - // Load all downloads from database final downloads = await _downloadManager.getAllDownloads(); // Bulk-load all pinned metadata across every backend in a single pass @@ -427,7 +423,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - // Load sync rules from database await _loadSyncRules(); // Apply queued offline watch actions on top of the server-time metadata @@ -844,7 +839,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin return null; } - // Calculate aggregate statistics int completedCount = 0; int downloadingCount = 0; int queuedCount = 0; @@ -867,7 +861,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin } } - // Determine overall status final DownloadStatus overallStatus; if (completedCount == totalEpisodes) { overallStatus = DownloadStatus.completed; @@ -912,22 +905,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin /// For shows/seasons, returns aggregate progress of all child episodes /// For episodes/movies, returns direct progress DownloadProgress? getProgress(String globalKey) { - // First check if we have direct progress (for episodes/movies) final directProgress = _downloads[globalKey]; if (directProgress != null) { if (!_ownsDownloadKey(globalKey)) return null; return directProgress; } - // If no direct progress, check if this is a show or season - // and calculate aggregate progress from episodes final parsed = parseGlobalKey(globalKey); if (parsed == null) return null; final serverId = parsed.serverId; final ratingKey = parsed.ratingKey; - // Try to get metadata to determine type final meta = _metadata[globalKey]; if (meta == null) { // No metadata stored yet, might be a container (show/season/artist/ @@ -1294,11 +1283,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin await _claimDownloadForProfile(globalKey, ownership, client); if (!_isQueueOwnershipCurrent(ownership)) return false; - // Update local state immediately for UI feedback _downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued); safeNotifyListeners(); - // Actually trigger download via DownloadManagerService if (!_isQueueOwnershipCurrent(ownership)) return false; await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex); return true; diff --git a/lib/providers/libraries_provider.dart b/lib/providers/libraries_provider.dart index cf5c0757..dd418961 100644 --- a/lib/providers/libraries_provider.dart +++ b/lib/providers/libraries_provider.dart @@ -303,10 +303,8 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi return libraries; } - // Create a map for quick lookup final libraryMap = {for (final lib in libraries) lib.globalKey: lib}; - // Build ordered list based on saved order final orderedLibraries = []; for (final key in savedOrder) { final lib = libraryMap.remove(key); @@ -315,7 +313,6 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi } } - // Add any new libraries that weren't in the saved order orderedLibraries.addAll(libraryMap.values); return orderedLibraries; diff --git a/lib/providers/offline_mode_provider.dart b/lib/providers/offline_mode_provider.dart index a0416fbb..64859210 100644 --- a/lib/providers/offline_mode_provider.dart +++ b/lib/providers/offline_mode_provider.dart @@ -175,7 +175,6 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi if (_isInitialized) return; _isInitialized = true; - // Check initial connectivity await _updateConnectionFlags(); // Monitor connectivity changes — runZonedGuarded catches async errors from diff --git a/lib/providers/offline_watch_provider.dart b/lib/providers/offline_watch_provider.dart index a2914e61..313e3dae 100644 --- a/lib/providers/offline_watch_provider.dart +++ b/lib/providers/offline_watch_provider.dart @@ -26,7 +26,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM final DownloadProvider _downloadProvider; OfflineWatchProvider({required this._syncService, required this._downloadProvider}) { - // Listen to sync service changes to update UI _syncService.addListener(_onSyncServiceChanged); } @@ -49,13 +48,11 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// /// Returns true if watched, false otherwise. Future isWatched(String globalKey) async { - // First check local offline action final localStatus = await _syncService.getLocalWatchStatus(globalKey); if (localStatus != null) { return localStatus; } - // Fall back to cached metadata final metadata = _downloadProvider.getMetadata(globalKey); if (metadata != null) { return metadata.isWatched; @@ -73,7 +70,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM /// Returns null if no position is available. @visibleForTesting Future getViewOffset(String globalKey) async { - // First check local offline progress final localOffset = await _syncService.getLocalViewOffset(globalKey); if (localOffset != null) { return localOffset; @@ -82,7 +78,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM final localStatus = await _syncService.getLocalWatchStatus(globalKey); if (localStatus == true) return null; - // Fall back to cached metadata final metadata = _downloadProvider.getMetadata(globalKey); return metadata?.viewOffsetMs; } @@ -127,7 +122,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM final watchStatuses = await _resolveEpisodeWatchStatuses(episodes); - // Find first unwatched episode for (final episode in episodes) { if (!watchStatuses[episode.globalKey]!) { return episode; diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 80bf86fd..ba6cbf04 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -103,18 +103,15 @@ class _DiscoverScreenState extends State final TvSpotlightController _spotlight = TvSpotlightController(); bool _isTabVisible = true; - // Track initial load so we can focus hero when content first appears bool _initialLoadComplete = false; bool _pendingTvBrowseRailFocus = false; - // Hub navigation keys GlobalKey? _continueWatchingHubKey; final Map> _hubKeysByIdentity = {}; List> _orderedHubKeys = const []; final _tvBrowseRailKey = GlobalKey(); final _hubFocusMemory = HubFocusMemory(); - // Hero and app bar focus late FocusNode _heroFocusNode; final _actionBarKey = GlobalKey(); final _serverActivitiesButtonKey = GlobalKey(); @@ -155,7 +152,6 @@ class _DiscoverScreenState extends State _continueWatchingHubKey ??= GlobalKey(); } - /// Get all hub states (continue watching + other hubs) List> get _allHubKeys { final keys = >[]; if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) { @@ -935,7 +931,6 @@ class _DiscoverScreenState extends State if (_isLoading) LoadingIndicatorBox.sliver, if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load), if (!_isLoading && _errorMessage == null) ...[ - // On Deck / Continue Watching if (continueWatchingHub != null) SliverToBoxAdapter( child: HubSection( @@ -1259,14 +1254,11 @@ class _DiscoverScreenState extends State shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], ); - // Determine content type label for chip final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow; - // Spoiler protection final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers); final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler; - // Build semantic label for hero item final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title; return Semantics( @@ -1420,10 +1412,8 @@ class _DiscoverScreenState extends State ), ], - // On small screens: show button before summary if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], - // Summary with episode info (Apple TV style) if (heroItem.summary != null && !shouldHideSpoiler) ...[ const SizedBox(height: 12), RichText( @@ -1468,7 +1458,6 @@ class _DiscoverScreenState extends State ), ], - // On large screens: show button after summary if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)], ], ), diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index 963407ad..76003e30 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -42,7 +42,6 @@ class DownloadsScreen extends StatefulWidget { class DownloadsScreenState extends State with TickerProviderStateMixin, TabNavigationMixin, FocusableTab { - // Focus nodes for tab chips final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue'); final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows'); final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies'); @@ -60,7 +59,6 @@ class DownloadsScreenState extends State @override void initState() { super.initState(); - suppressAutoFocus = true; // Start suppressed initTabNavigation(); } @@ -92,7 +90,6 @@ class DownloadsScreenState extends State /// Focus the first item in the currently active tab void _focusCurrentTab() { - // Re-enable auto-focus since user is navigating into tab content setState(() { suppressAutoFocus = false; }); @@ -115,7 +112,6 @@ class DownloadsScreenState extends State /// Build the app bar title - either tabs on desktop or simple title on mobile Widget _buildAppBarTitle() { - // On desktop/TV with side nav, show tabs in app bar if (PlatformDetector.shouldUseSideNavigation(context)) { return TabChipStrip( children: [ @@ -130,7 +126,6 @@ class DownloadsScreenState extends State ); } - // On mobile, show simple title return Text(t.downloads.title); } @@ -177,7 +172,6 @@ class DownloadsScreenState extends State builder: (context, hasPendingDownloads, _) => BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads), ), - // Tab selector chips (only on mobile - desktop has them in app bar) if (!PlatformDetector.shouldUseSideNavigation(context)) Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), @@ -197,7 +191,6 @@ class DownloadsScreenState extends State ), ), ), - // Tab content Expanded( child: TabBarView( controller: tabController, diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index c351fca4..d9a94b74 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -202,7 +202,6 @@ class _HubDetailScreenState extends State setState(() { _filteredItems = List.from(_items); - // Apply sorting if (_selectedSort != null) { final sortKey = _selectedSort!.key; _filteredItems.sort((a, b) { @@ -540,21 +539,16 @@ class _HubDetailScreenState extends State final libraryDensity = svc.read(SettingsService.libraryDensity); final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); - // Determine hub content type for layout decisions final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode)); - // Mixed hub = has both episodes AND non-episodes final isMixedHub = hasEpisodes && hasNonEpisodes; - // Episode-only = all items are episodes with thumbnails final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; - // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) final useWideLayout = episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); - // Music hubs render square album/artist artwork final isSquareHub = _filteredItems.isNotEmpty && _filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square); diff --git a/lib/screens/libraries/alpha_jump_helper.dart b/lib/screens/libraries/alpha_jump_helper.dart index 910ab5c0..19a554d1 100644 --- a/lib/screens/libraries/alpha_jump_helper.dart +++ b/lib/screens/libraries/alpha_jump_helper.dart @@ -29,7 +29,6 @@ class AlphaJumpHelper { AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount); factory AlphaJumpHelper(List firstCharacters, {bool descending = false}) { - // Collect characters with their sizes. final entries = <({String letter, int size})>[]; final letterSizes = {}; @@ -41,13 +40,11 @@ class AlphaJumpHelper { } } - // Re-sort by DUCET collation to match the content endpoint's ICU sort order. entries.sort((a, b) => ducetCompare(a.letter, b.letter)); if (descending) { entries.setAll(0, entries.reversed.toList()); } - // Build cumulative index map in the corrected order. final letters = []; final letterToIndex = {}; int cumulative = 0; diff --git a/lib/screens/libraries/content_state_builder.dart b/lib/screens/libraries/content_state_builder.dart index 22e3e2f2..1ae4f7e6 100644 --- a/lib/screens/libraries/content_state_builder.dart +++ b/lib/screens/libraries/content_state_builder.dart @@ -176,12 +176,10 @@ class ContentStateBuilder extends StatelessWidget { @override Widget build(BuildContext context) { - // Loading state (only show loading indicator if items list is empty) if (isLoading && items.isEmpty) { return const Center(child: CircularProgressIndicator()); } - // Error state (only show error if items list is empty) if (errorMessage != null && items.isEmpty) { return ErrorStateWidget( message: errorMessage!, @@ -191,12 +189,10 @@ class ContentStateBuilder extends StatelessWidget { ); } - // Empty state if (items.isEmpty) { return EmptyStateWidget(message: emptyMessage, icon: emptyIcon); } - // Content state - delegate to builder return builder(items); } } diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index f526c866..05331d99 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -62,7 +62,6 @@ class _LibrariesScreenState extends State ItemUpdatable, TickerProviderStateMixin, TabNavigationMixin { - // GlobalKeys for tabs to enable refresh final _recommendedTabKey = GlobalKey(); final _browseTabKey = GlobalKey(); final _collectionsTabKey = GlobalKey(); @@ -84,7 +83,6 @@ class _LibrariesScreenState extends State /// Key for the library dropdown menu button. final _libraryDropdownKey = GlobalKey>(); - // Dynamic visible tabs and their focus nodes List _visibleTabs = LibraryTabType.values; List _tabFocusNodes = List.generate( LibraryTabType.values.length, @@ -94,10 +92,8 @@ class _LibrariesScreenState extends State @override List get tabChipFocusNodes => _tabFocusNodes; - // App bar action bar final _actionBarKey = GlobalKey(); - // Scroll controller for the outer CustomScrollView final ScrollController _outerScrollController = ScrollController(); /// Reveal the floating header by jumping the outer NestedScrollView back @@ -153,25 +149,20 @@ class _LibrariesScreenState extends State return; } - // Compute visible libraries for initial load final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); - // Load saved preferences final storage = await StorageService.getInstance(); final savedLibraryKey = storage.getSelectedLibraryKey(); - // Find the library by key in visible libraries String? libraryGlobalKeyToLoad; if (savedLibraryKey != null) { - // Check if saved library exists and is visible final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey); if (libraryExists) { libraryGlobalKeyToLoad = savedLibraryKey; } } - // Fallback to first visible library if saved key not found if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) { libraryGlobalKeyToLoad = visibleLibraries.first.globalKey; } @@ -183,16 +174,12 @@ class _LibrariesScreenState extends State @override void onTabChanged() { - // Save tab name when changed (but not when restoring from storage) if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) { - // Only save if this was a user-initiated tab change, not a restore if (!_isRestoringTab) { StorageService.getInstance().then((storage) { storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name); }); - // Focus first item in the current tab (only for user-initiated changes) - // But not when navigating via tab bar (suppressAutoFocus is true) if (!suppressAutoFocus) { _focusCurrentTab(); } @@ -306,15 +293,11 @@ class _LibrariesScreenState extends State /// Handle when a tab's data has finished loading void _handleTabDataLoaded(int tabIndex) { - // Track that this tab has loaded _loadedTabs.add(tabIndex); - // Don't auto-focus if suppressed (e.g., when navigating via tab bar) if (suppressAutoFocus) return; - // Only focus if this is the currently active tab if (tabController.index == tabIndex && mounted) { - // Use post-frame callback to ensure the widget tree is fully built WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && tabController.index == tabIndex && !suppressAutoFocus) { _focusCurrentTab(); @@ -352,21 +335,17 @@ class _LibrariesScreenState extends State void _updateVisibleTabs(List newTabs) { if (listEquals(_visibleTabs, newTabs)) return; - // Save current tab type before changing final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null; - // Dispose old focus nodes and controller for (final node in _tabFocusNodes) { node.dispose(); } disposeTabNavigation(); - // Build new _visibleTabs = newTabs; _tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}')); initTabNavigation(); - // Restore tab position: find current tab type in new set, default to first final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1; if (newIndex > 0) { tabController.index = newIndex; diff --git a/lib/screens/libraries/sort_bottom_sheet.dart b/lib/screens/libraries/sort_bottom_sheet.dart index 44693f7e..1a139845 100644 --- a/lib/screens/libraries/sort_bottom_sheet.dart +++ b/lib/screens/libraries/sort_bottom_sheet.dart @@ -49,7 +49,6 @@ class _SortBottomSheetState extends State { _currentDescending = widget.isSortDescending; _initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus'); - // Scroll to selected item, then handle focus final selectedIndex = widget.selectedSort != null ? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key) : -1; diff --git a/lib/screens/playlist/playlist_item_card.dart b/lib/screens/playlist/playlist_item_card.dart index fccbb8d3..a4b32bc7 100644 --- a/lib/screens/playlist/playlist_item_card.dart +++ b/lib/screens/playlist/playlist_item_card.dart @@ -23,12 +23,11 @@ class PlaylistItemCard extends StatefulWidget { final VoidCallback? onRemove; final VoidCallback? onTap; final void Function(MediaItem source)? onRefresh; - final bool canReorder; // Whether drag handle should be shown + final bool canReorder; - // Focus state for keyboard/D-pad navigation final bool isFocused; - final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button - final bool isMoving; // Whether this item is being moved/reordered + final int? focusedColumn; + final bool isMoving; const PlaylistItemCard({ super.key, @@ -56,20 +55,16 @@ class _PlaylistItemCardState extends State with ContextMenuTap final colorScheme = Theme.of(context).colorScheme; final textMuted = tokens(context).textMuted; - // Determine if row is focused (main content area) final isRowFocused = widget.isFocused && widget.focusedColumn == 0; - // Focus states for individual elements final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1; final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2; - // Determine card styling based on focus/move state Color? cardColor; ShapeBorder? cardShape; if (widget.isMoving) { cardColor = colorScheme.primaryContainer; } else if (isRowFocused) { - // Row is focused - use visible border like FocusableWrapper cardColor = colorScheme.surfaceContainerHighest; cardShape = RoundedRectangleBorder( borderRadius: const BorderRadius.all(Radius.circular(12)), @@ -126,18 +121,15 @@ class _PlaylistItemCardState extends State with ContextMenuTap ), ), - // Poster thumbnail _buildPosterImage(context, item), const SizedBox(width: 12), - // Title and metadata Expanded( child: Column( crossAxisAlignment: .start, mainAxisSize: .min, children: [ - // Title Text( item.displayTitle, style: const TextStyle(fontSize: 15, fontWeight: .w500), @@ -147,7 +139,6 @@ class _PlaylistItemCardState extends State with ContextMenuTap const SizedBox(height: 4), - // Subtitle (episode info or type) Text( _buildSubtitle(item), style: TextStyle(fontSize: 13, color: textMuted), @@ -160,13 +151,11 @@ class _PlaylistItemCardState extends State with ContextMenuTap const SizedBox(width: 12), - // Duration if (item.durationMs != null) Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)), const SizedBox(width: 8), - // Remove button Container( decoration: isRemoveButtonFocused ? BoxDecoration( diff --git a/lib/screens/settings/about_screen.dart b/lib/screens/settings/about_screen.dart index 4cbdd844..7bcb8df4 100644 --- a/lib/screens/settings/about_screen.dart +++ b/lib/screens/settings/about_screen.dart @@ -29,7 +29,6 @@ class AboutScreen extends StatelessWidget { padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildListDelegate([ - // App Icon and Name Center( child: Column( children: [ @@ -54,7 +53,6 @@ class AboutScreen extends StatelessWidget { const SizedBox(height: 40), - // Open Source Licenses SettingsGroup( margin: EdgeInsets.zero, children: [ diff --git a/lib/screens/settings/licenses_screen.dart b/lib/screens/settings/licenses_screen.dart index c092c874..0052cc13 100644 --- a/lib/screens/settings/licenses_screen.dart +++ b/lib/screens/settings/licenses_screen.dart @@ -114,7 +114,6 @@ class _LicenseDetailScreen extends StatelessWidget { padding: const EdgeInsets.all(16), sliver: SliverList( delegate: SliverChildListDelegate([ - // Package info card if (mergedLicense.allPackageNames.length > 1) Card( child: Padding( @@ -134,7 +133,6 @@ class _LicenseDetailScreen extends StatelessWidget { ), if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16), - // License cards ...licenseEntries.asMap().entries.map((entry) { final index = entry.key; final license = entry.value; diff --git a/lib/services/ambient_lighting_service.dart b/lib/services/ambient_lighting_service.dart index 16f73cee..e6bc407d 100644 --- a/lib/services/ambient_lighting_service.dart +++ b/lib/services/ambient_lighting_service.dart @@ -43,10 +43,8 @@ class AmbientLightingService { appLogger.d('AmbientLightingService: Shader path: $_shaderPath'); - // Set video-aspect-override to fill the entire output area await _player.setProperty('video-aspect-override', outputAspect.toString()); - // Append ambient lighting shader await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]); _enabled = true; diff --git a/lib/services/discord_rpc_service.dart b/lib/services/discord_rpc_service.dart index 1cdb5ae3..de596011 100644 --- a/lib/services/discord_rpc_service.dart +++ b/lib/services/discord_rpc_service.dart @@ -139,7 +139,6 @@ class DiscordRPCService { _playbackSpeed = 1.0; if (_isEnabled && _isConnected) { - // Upload thumbnail in background, don't block playback unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client)); } } @@ -148,9 +147,7 @@ class DiscordRPCService { void updatePosition(Duration position) { final isSeek = _timeline.updatePosition(position); - // Update presence if position jumped significantly (seek detected) if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) { - // Throttle updates to max once per second final now = DateTime.now(); if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) { _lastPresenceUpdate = now; @@ -172,7 +169,6 @@ class DiscordRPCService { Future resumePlayback() async { if (_currentMetadata == null) return; - // Reset start time for elapsed time display _playbackStartTime = DateTime.now(); if (_isEnabled && _isConnected) { @@ -182,7 +178,6 @@ class DiscordRPCService { /// Pause - clear timestamp but keep showing what's playing Future pausePlayback() async { - // Clear start time so Discord stops counting _playbackStartTime = null; if (_isEnabled && _isConnected) { @@ -309,12 +304,9 @@ class DiscordRPCService { Future _uploadThumbnail(MediaItem metadata, MediaServerClient client) async { try { - // Get the thumbnail path (prefer show poster for episodes) final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath; if (thumbPath == null || thumbPath.isEmpty) return null; - // Check cache first (with expiry check). Key by backend so the same - // path on Plex and Jellyfin doesn't collide. final cacheKey = '${client.backend.id}:$thumbPath'; final cached = _posterUrlCache[cacheKey]; if (cached != null && !cached.isExpired) { diff --git a/lib/services/gamepad_service.dart b/lib/services/gamepad_service.dart index 8ba1f1fe..6c56f8ca 100644 --- a/lib/services/gamepad_service.dart +++ b/lib/services/gamepad_service.dart @@ -192,25 +192,20 @@ class GamepadService with WindowListener { _tabNavigationHandlers.clear(); } - // Deadzone for analog sticks (0.0 to 1.0) static const double _stickDeadzone = 0.5; - // Auto-repeat timing for held directional inputs (D-pad / stick) static const Duration _repeatInitialDelay = Duration(milliseconds: 400); static const Duration _repeatInterval = Duration(milliseconds: 80); key_sim.KeyEventSimulatorController? _keyEventSimulator; - // Track stick state to detect deadzone crossings bool _leftStickUp = false; bool _leftStickDown = false; bool _leftStickLeft = false; bool _leftStickRight = false; - // Track button states to prevent repeated events from button holds final Set _pressedButtons = {}; final Set _suppressedButtons = {}; - // Whether the app window is currently focused — ignore gamepad input when false bool _windowFocused = true; bool _nativeKeyHandlerRegistered = false; bool _nativeTextInputFocused = false; diff --git a/lib/services/keyboard_shortcuts_service.dart b/lib/services/keyboard_shortcuts_service.dart index eca8cd91..edcb632a 100644 --- a/lib/services/keyboard_shortcuts_service.dart +++ b/lib/services/keyboard_shortcuts_service.dart @@ -250,16 +250,13 @@ class KeyboardShortcutsService extends ChangeNotifier { if (!isMetaPressed) modifiersMatch = false; break; case HotKeyModifier.capsLock: - // CapsLock is typically not used for shortcuts, ignore for now break; case HotKeyModifier.fn: - // Fn key is typically not used for shortcuts, ignore for now break; } if (!modifiersMatch) break; } - // Check that no extra modifiers are pressed if (modifiersMatch) { final hasShift = requiredModifiers.contains(HotKeyModifier.shift); final hasControl = requiredModifiers.contains(HotKeyModifier.control); @@ -372,7 +369,6 @@ class KeyboardShortcutsService extends ChangeNotifier { return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge); } - // Check if a hotkey is already assigned to another action String? getActionForHotkey(HotKey hotkey) { for (final entry in _hotkeys.entries) { final assignedHotkey = entry.value; @@ -383,7 +379,6 @@ class KeyboardShortcutsService extends ChangeNotifier { return null; } - // Helper method to compare two HotKey objects bool _hotkeyEquals(HotKey a, HotKey b) { if (a.key != b.key) return false; diff --git a/lib/utils/content_utils.dart b/lib/utils/content_utils.dart index d02b8744..0753f462 100644 --- a/lib/utils/content_utils.dart +++ b/lib/utils/content_utils.dart @@ -50,8 +50,6 @@ String formatContentRating(String? contentRating) { return ''; } - // Remove common country prefixes like "gb/", "us/", "de/", etc. - // The pattern matches: lowercase letters followed by a forward slash final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false); final match = regex.firstMatch(contentRating); diff --git a/lib/widgets/bottom_sheet_header.dart b/lib/widgets/bottom_sheet_header.dart index 9e393b58..b85433c9 100644 --- a/lib/widgets/bottom_sheet_header.dart +++ b/lib/widgets/bottom_sheet_header.dart @@ -68,7 +68,6 @@ class BottomSheetHeader extends StatelessWidget { Widget build(BuildContext context) { final usesBackButton = leading == null && onBack != null; - // Determine the leading widget based on priority: leading > onBack > icon Widget? resolvedLeading; if (leading != null) { resolvedLeading = leading; diff --git a/lib/widgets/download_tree_view.dart b/lib/widgets/download_tree_view.dart index d7b7cd96..871d9807 100644 --- a/lib/widgets/download_tree_view.dart +++ b/lib/widgets/download_tree_view.dart @@ -92,7 +92,6 @@ class _DownloadTreeViewState extends State { @override void didUpdateWidget(DownloadTreeView oldWidget) { super.didUpdateWidget(oldWidget); - // When suppressAutoFocus changes from true to false, focus the first item if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) { WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted && _firstItemFocusNode.canRequestFocus) { @@ -121,13 +120,11 @@ class _DownloadTreeViewState extends State { ); } - /// Build the download tree from flat download list List _buildTree() { final Map>> showGroups = {}; final Map>> albumGroups = {}; final List movies = []; - // Group downloads for (final entry in widget.downloads.entries) { final globalKey = entry.key; final download = entry.value; @@ -136,17 +133,14 @@ class _DownloadTreeViewState extends State { if (meta == null) continue; if (meta.isEpisode) { - // Group episodes by show final showKey = meta.grandparentId ?? 'unknown'; showGroups.putIfAbsent(showKey, () => []); showGroups[showKey]!.add(entry); } else if (meta.kind == MediaKind.track) { - // Group tracks by album (single level — no per-disc tier) final albumKey = meta.parentId ?? 'unknown'; albumGroups.putIfAbsent(albumKey, () => []); albumGroups[albumKey]!.add(entry); } else if (meta.isMovie) { - // Movies go at top level movies.add( DownloadTreeNode( key: globalKey, @@ -161,7 +155,6 @@ class _DownloadTreeViewState extends State { } } - // Build show nodes final List shows = []; for (final showEntry in showGroups.entries) { final showKey = showEntry.key; @@ -169,11 +162,9 @@ class _DownloadTreeViewState extends State { if (episodes.isEmpty) continue; - // Get show metadata from first episode final firstEpisode = widget.metadata[episodes.first.key]; final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow; - // Group episodes by season final Map>> seasonGroups = {}; for (final episode in episodes) { final meta = widget.metadata[episode.key]; @@ -184,7 +175,6 @@ class _DownloadTreeViewState extends State { seasonGroups[seasonKey]!.add(episode); } - // Build season nodes final List seasons = []; for (final seasonEntry in seasonGroups.entries) { final seasonKey = seasonEntry.key; @@ -192,7 +182,6 @@ class _DownloadTreeViewState extends State { if (seasonEpisodes.isEmpty) continue; - // Get season metadata from first episode final firstEpisode = widget.metadata[seasonEpisodes.first.key]; final seasonNumber = firstEpisode?.parentIndex; final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true @@ -201,7 +190,6 @@ class _DownloadTreeViewState extends State { ? t.common.seasonNumber(number: seasonNumber) : t.downloads.unknownSeason; - // Build episode nodes final List episodeNodes = []; for (final episodeEntry in seasonEpisodes) { final globalKey = episodeEntry.key; @@ -228,14 +216,12 @@ class _DownloadTreeViewState extends State { ); } - // Sort episodes by episode number only (not by status) episodeNodes.sort((a, b) { final aIndex = a.metadata?.index ?? 0; final bIndex = b.metadata?.index ?? 0; return aIndex.compareTo(bIndex); }); - // Calculate aggregate season progress final seasonProgress = episodeNodes.isEmpty ? 0.0 : episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length; @@ -255,14 +241,12 @@ class _DownloadTreeViewState extends State { seasons.removeWhere((s) => s.children.isEmpty); - // Sort seasons by season number seasons.sort((a, b) { final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0; final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0; return aSeasonNum.compareTo(bSeasonNum); }); - // Calculate aggregate show progress final showProgress = seasons.isEmpty ? 0.0 : seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length; @@ -280,14 +264,12 @@ class _DownloadTreeViewState extends State { ); } - // Build album nodes (album -> tracks) final List albums = []; for (final albumEntry in albumGroups.entries) { final albumKey = albumEntry.key; final tracks = albumEntry.value; if (tracks.isEmpty) continue; - // Album/artist names from any track's parent fields final firstTrack = widget.metadata[tracks.first.key]; final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum; final artistTitle = firstTrack?.albumArtistTitle; @@ -314,7 +296,6 @@ class _DownloadTreeViewState extends State { } if (trackNodes.isEmpty) continue; - // Sort tracks by disc then track number trackNodes.sort((a, b) { final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1); if (byDisc != 0) return byDisc; @@ -336,17 +317,13 @@ class _DownloadTreeViewState extends State { ); } - // Sort shows, albums, and movies by status and title _sortNodesByStatusAndTitle(shows); _sortNodesByStatusAndTitle(albums); _sortNodesByStatusAndTitle(movies); - // Combine movies, shows, and albums return [...movies, ...shows, ...albums]; } - /// Determine aggregate status from child statuses - /// Priority: downloading > queued > paused > completed > failed DownloadStatus _determineAggregateStatus(List statuses) { if (statuses.isEmpty) return DownloadStatus.queued; @@ -365,7 +342,6 @@ class _DownloadTreeViewState extends State { return DownloadStatus.completed; } - /// Compare statuses for sorting (downloading first, then queued, etc.) int _compareByStatus(DownloadStatus a, DownloadStatus b) { const statusOrder = { DownloadStatus.downloading: 0, @@ -378,7 +354,6 @@ class _DownloadTreeViewState extends State { return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99); } - /// Sort nodes by status (downloading first) then by title void _sortNodesByStatusAndTitle(List nodes) { nodes.sort((a, b) { final statusCompare = _compareByStatus(a.status, b.status); @@ -414,7 +389,6 @@ class _DownloadTreeViewState extends State { }); } - /// Build a tree item widget Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) { return _DownloadTreeItem( node: node, @@ -590,9 +564,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { return widget.node.status; } - // Focus node for row content (only created if not provided externally) FocusNode? _ownedRowFocusNode; - // Focus nodes for action buttons (up to 3 buttons max) final List _buttonFocusNodes = []; FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!; @@ -676,10 +648,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( children: [ - // Row content Expanded(child: _buildRowContent(theme, canExpand)), - // Action buttons if (actions.isNotEmpty) Row( mainAxisSize: .min, @@ -696,7 +666,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { Widget _buildRowContent(ThemeData theme, bool canExpand) { return Row( children: [ - // Expand/collapse icon if (canExpand) AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20) else @@ -704,12 +673,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> { const SizedBox(width: 8), - // Status icon DownloadStatusIcon(status: _effectiveStatus, size: 20), const SizedBox(width: 12), - // Title and info Expanded( child: Column( crossAxisAlignment: .start, diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 6d856e8e..b0570935 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -275,7 +275,6 @@ class HubSectionState extends State with MountedSetStateMixin, Skele } } - // Handle key down and repeat events if (!event.isActionable) { return KeyEventResult.ignored; } diff --git a/lib/widgets/library_management_sheet.dart b/lib/widgets/library_management_sheet.dart index 2652f02d..6d50939a 100644 --- a/lib/widgets/library_management_sheet.dart +++ b/lib/widgets/library_management_sheet.dart @@ -289,7 +289,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> final ScrollController _dialogScrollController = ScrollController(); final ScrollController _sheetScrollController = ScrollController(); - // Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button. @override List get reorderItems => _tempLibraries; @@ -468,16 +467,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet> final isHidden = hiddenLibraryKeys.contains(library.globalKey); final colorScheme = Theme.of(context).colorScheme; - // Determine background color based on state Color? tileColor; if (isMoving) { tileColor = colorScheme.primaryContainer; } else if (isFocused && focusedColumn == 0) { - // Only highlight row when row itself is focused (column 0) tileColor = colorScheme.surfaceContainerHighest; } - // Button focus states final isVisibilityButtonFocused = isFocused && focusedColumn == 1; final isOptionsButtonFocused = isFocused && focusedColumn == 2; diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 336efd65..93c0ca02 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -615,13 +615,11 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin 0) { @@ -1146,14 +1142,12 @@ class _MediaCardHelpers { } } - // For albums, show the album artist if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) { return ExcludeSemantics( child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle), ); } - // For tracks, show "Artist • duration" if (mi.kind == MediaKind.track) { final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)]; if (parts.isNotEmpty) { @@ -1163,7 +1157,6 @@ class _MediaCardHelpers { } } - // For episodes, show "S# · Episode Title" with clickable season link if (mi.isEpisode && mi.parentIndex != null) { if (enableDetailLinks && mi.parentId != null) { return _buildEpisodeSubtitleRow( @@ -1185,7 +1178,6 @@ class _MediaCardHelpers { ); } - // For other media types, show subtitle/parent/year if (mi.displaySubtitle != null) { return ExcludeSemantics( child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle), diff --git a/lib/widgets/media_context_menu.dart b/lib/widgets/media_context_menu.dart index 4a62f9ce..b8d3efec 100644 --- a/lib/widgets/media_context_menu.dart +++ b/lib/widgets/media_context_menu.dart @@ -130,7 +130,7 @@ class MediaContextMenu extends StatefulWidget { final Object item; final void Function(MediaItem source)? onRefresh; final VoidCallback? onRemoveFromContinueWatching; - final VoidCallback? onListRefresh; // For refreshing list after deletion + final VoidCallback? onListRefresh; final VoidCallback? onTap; /// Plays the item's trailer. When non-null a "Play trailer" item is added to diff --git a/lib/widgets/video_controls/desktop_video_controls.dart b/lib/widgets/video_controls/desktop_video_controls.dart index 52e1d6ad..ecddb367 100644 --- a/lib/widgets/video_controls/desktop_video_controls.dart +++ b/lib/widgets/video_controls/desktop_video_controls.dart @@ -173,7 +173,6 @@ class DesktopVideoControlsState extends State { bool get _canControl => _trackControlsState.canControl; bool get _isLive => _trackControlsState.isLive; - // Focus nodes for playback control buttons late final FocusNode _prevItemFocusNode; late final FocusNode _prevChapterFocusNode; late final FocusNode _skipBackFocusNode; @@ -184,30 +183,23 @@ class DesktopVideoControlsState extends State { late final FocusNode _goToLiveFocusNode; late final FocusNode _timelineFocusNode; - // Focus node for volume control late final FocusNode _volumeFocusNode; - // Focus nodes for track/chapter controls (max 8 buttons possible) late final List _trackControlFocusNodes; - // List of button focus nodes for horizontal navigation late final List _buttonFocusNodes; - // Progressive seek acceleration state LogicalKeyboardKey? _seekDirection; // Current direction being held int _seekRepeatCount = 0; // Consecutive key repeats for acceleration - // Preview thumbnail during sustained dpad/keyboard seeking bool _showKeyRepeatThumbnail = false; Timer? _keyRepeatThumbnailTimer; late final DebouncedSeekAccumulator _timelineSeek; static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400); - // Content strip state bool _contentStripVisible = false; final GlobalKey _contentStripKey = GlobalKey(); - // Track which button was last focused (for returning from content strip) FocusNode? _lastFocusedButtonNode; /// Whether the content strip has any content to show @@ -714,7 +706,6 @@ class DesktopVideoControlsState extends State { padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: Column( children: [ - // Row 1: Timeline (LiveTimelineBar for time-shifted live, VideoTimelineBar for VOD) if (_isLive && widget.captureBuffer != null) ...[ LiveTimelineBar( player: widget.player, @@ -748,14 +739,12 @@ class DesktopVideoControlsState extends State { previewPosition: _timelineSeek.pendingPosition, ), ], - // Row 2: Playback controls and options Focus( onFocusChange: _onButtonRowFocusChange, skipTraversal: true, child: Row( children: [ if (!_isLive) ...[ - // Previous item Opacity( opacity: _canControl ? 1.0 : 0.5, child: _buildFocusableButton( diff --git a/lib/widgets/video_controls/mobile_video_controls.dart b/lib/widgets/video_controls/mobile_video_controls.dart index 32df0e97..b7d39832 100644 --- a/lib/widgets/video_controls/mobile_video_controls.dart +++ b/lib/widgets/video_controls/mobile_video_controls.dart @@ -354,7 +354,6 @@ class _MobileVideoControlsState extends State with SingleTi mainAxisAlignment: .center, children: [ if (!widget.isLive) ...[ - // Previous episode button (greyed out when unavailable) CircularControlButton( semanticLabel: t.videoControls.previousButton, icon: Symbols.skip_previous_rounded, @@ -378,7 +377,6 @@ class _MobileVideoControlsState extends State with SingleTi ), if (!widget.isLive) ...[ const SizedBox(width: 24), - // Next episode button (greyed out when unavailable) CircularControlButton( semanticLabel: t.videoControls.nextButton, icon: Symbols.skip_next_rounded, diff --git a/lib/widgets/video_controls/parts/track_controls.dart b/lib/widgets/video_controls/parts/track_controls.dart index dc55902b..bd60701e 100644 --- a/lib/widgets/video_controls/parts/track_controls.dart +++ b/lib/widgets/video_controls/parts/track_controls.dart @@ -24,7 +24,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { } final currentTrack = widget.player.state.track.subtitle; - // Nothing to hide when no subtitle track is selected. if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return; _setSubtitleVisibility(false); @@ -51,7 +50,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState { } void _onSubtitleTrackChanged(SubtitleTrack track) { - // Reset visibility when user explicitly picks a new subtitle track if (track.id != 'no' && !_subtitlesVisible) { _setSubtitleVisibility(true); } diff --git a/lib/widgets/video_controls/sheets/video_settings_sheet.dart b/lib/widgets/video_controls/sheets/video_settings_sheet.dart index 95e5e162..6ff69db6 100644 --- a/lib/widgets/video_controls/sheets/video_settings_sheet.dart +++ b/lib/widgets/video_controls/sheets/video_settings_sheet.dart @@ -770,7 +770,6 @@ class _VideoSettingsSheetState extends State { }, ), - // Audio Sync _SettingsMenuItem( icon: Symbols.sync_rounded, title: t.videoSettings.audioSync, @@ -779,7 +778,6 @@ class _VideoSettingsSheetState extends State { onTap: () => _navigateTo(_SettingsView.audioSync), ), - // Subtitle Sync _SettingsMenuItem( icon: Symbols.subtitles_rounded, title: t.videoSettings.subtitleSync, @@ -788,7 +786,6 @@ class _VideoSettingsSheetState extends State { onTap: () => _navigateTo(_SettingsView.subtitleSync), ), - // HDR Toggle if (_supportsHdrControl) _SettingsToggleItem( pref: SettingsService.enableHDR, @@ -808,14 +805,12 @@ class _VideoSettingsSheetState extends State { onTap: () => _navigateTo(_SettingsView.hdrToneMapping), ), - // Auto-Play Next Episode Toggle _SettingsToggleItem( pref: SettingsService.autoPlayNextEpisode, icon: Symbols.skip_next_rounded, title: t.videoControls.autoPlayNext, ), - // Audio Output Device (Desktop only) if (isDesktop) StreamBuilder( stream: widget.player.streams.audioDevice, @@ -845,7 +840,6 @@ class _VideoSettingsSheetState extends State { // "not Dolby" when the system reports notApplicable. if (PlatformDetector.isAppleTV()) _AudioRenderingModeItem(player: widget.player), - // Audio Normalization _SettingsToggleItem( pref: SettingsService.audioNormalization, icon: Symbols.graphic_eq_rounded, @@ -853,7 +847,6 @@ class _VideoSettingsSheetState extends State { onAfterWrite: widget.player.setAudioNormalization, ), - // Stereo Downmix _SettingsToggleItem( pref: SettingsService.audioDownmix, icon: Symbols.headphones_rounded, diff --git a/lib/widgets/video_controls/video_control_button.dart b/lib/widgets/video_controls/video_control_button.dart index 7c9402f2..9d144e42 100644 --- a/lib/widgets/video_controls/video_control_button.dart +++ b/lib/widgets/video_controls/video_control_button.dart @@ -62,7 +62,6 @@ class VideoControlButton extends StatelessWidget { @override Widget build(BuildContext context) { - // Determine the effective color: explicit color > active amber > default white final effectiveColor = color ?? (isActive ? Colors.amber : Colors.white); final button = IconButton( @@ -85,9 +84,9 @@ class VideoControlButton extends StatelessWidget { semanticLabel: effectiveSemanticLabel, semanticValue: semanticValue, checked: checked, - borderRadius: 20, // Circular for icon buttons - autoScroll: false, // Video controls don't scroll - useBackgroundFocus: true, // Use background highlight for video controls + borderRadius: 20, + autoScroll: false, + useBackgroundFocus: true, child: result, ); } else if (effectiveSemanticLabel != null) { diff --git a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart index 16b8f3dc..56219764 100644 --- a/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart +++ b/lib/widgets/video_controls/widgets/performance_overlay/performance_stats.dart @@ -5,10 +5,8 @@ import '../../../../i18n/strings.g.dart'; /// Contains metrics queried from the video player (MPV or ExoPlayer) /// including video/audio codec info, playback performance, and buffer state. class PerformanceStats { - // Player info - final String playerType; // 'mpv' or 'exoplayer' + final String playerType; - // Video metrics final String? videoCodec; final int? videoWidth; final int? videoHeight; @@ -19,38 +17,32 @@ class PerformanceStats { final int? rotate; final String? videoDecoderName; - // Color/Format metrics final String? pixelformat; final String? hwPixelformat; final String? colormatrix; final String? primaries; final String? gamma; - // HDR metadata final double? maxLuma; final double? minLuma; final double? maxCll; final double? maxFall; - // Audio metrics final String? audioCodec; final int? audioSamplerate; final String? audioChannels; final int? audioBitrate; final String? audioDecoderName; - // Tunneling final bool tunneledPlayback; final String? tunnelingStatus; - // Performance metrics final double? actualFps; final double? avsyncChange; final double? displayFps; final int? frameDropCount; final int? decoderFrameDropCount; - // Buffer metrics final int? cacheUsed; final int? cacheLimit; final double? cacheSpeed; @@ -58,9 +50,8 @@ class PerformanceStats { final int? bufferTargetBytes; final int? bufferMaxMs; - // DV conversion final bool dvConversionActive; - final String dvConversionMode; // "DV81", "HEVC_STRIP", "DISABLED" + final String dvConversionMode; final int? dvConvertedRpus; final int? dvRpuConversionFailures; final int? dvRpuOutputTooSmall; @@ -70,7 +61,6 @@ class PerformanceStats { final String? dvPlaybackPath; final String? dvPlaybackReason; - // App metrics final int? appMemoryBytes; final double? uiFps; diff --git a/lib/widgets/video_controls/widgets/track_chapter_controls.dart b/lib/widgets/video_controls/widgets/track_chapter_controls.dart index 3db1bb85..744e7e8f 100644 --- a/lib/widgets/video_controls/widgets/track_chapter_controls.dart +++ b/lib/widgets/video_controls/widgets/track_chapter_controls.dart @@ -69,7 +69,6 @@ class TrackChapterControls extends StatelessWidget { final key = event.logicalKey; - // LEFT arrow - move to previous button or exit to volume if (key == LogicalKeyboardKey.arrowLeft) { if (index > 0 && focusNodes != null && focusNodes!.length > index - 1) { focusNodes![index - 1].requestFocus(); @@ -81,23 +80,19 @@ class TrackChapterControls extends StatelessWidget { return KeyEventResult.handled; } - // RIGHT arrow - move to next button if (key == LogicalKeyboardKey.arrowRight) { if (index < totalButtons - 1 && focusNodes != null && focusNodes!.length > index + 1) { focusNodes![index + 1].requestFocus(); return KeyEventResult.handled; } - // At end, consume to prevent bubbling return KeyEventResult.handled; } - // UP arrow - navigate up (e.g., to timeline) if (key == LogicalKeyboardKey.arrowUp) { onNavigateUp?.call(); return KeyEventResult.handled; } - // DOWN arrow - navigate down (e.g., to content strip) if (key == LogicalKeyboardKey.arrowDown) { onNavigateDown?.call(); return KeyEventResult.handled; @@ -146,11 +141,9 @@ class TrackChapterControls extends StatelessWidget { final isMobile = PlatformDetector.isMobile(context); final isDesktop = PlatformDetector.isDesktopOS(); - // Build list of buttons dynamically to track indices final buttons = []; int buttonIndex = 0; - // Settings button (always shown) buttons.add( ListenableBuilder( listenable: SleepTimerService(), @@ -193,7 +186,6 @@ class TrackChapterControls extends StatelessWidget { ); buttonIndex++; - // Combined audio & subtitles button { final currentIndex = buttonIndex; buttons.add( @@ -232,7 +224,6 @@ class TrackChapterControls extends StatelessWidget { buttonIndex++; } - // Chapters button (hidden on mobile when content strip is available) if (chapters.isNotEmpty && !hideChaptersAndQueue) { final currentIndex = buttonIndex; buttons.add( @@ -264,7 +255,6 @@ class TrackChapterControls extends StatelessWidget { buttonIndex++; } - // Queue button (hidden on mobile when content strip is available) if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) { final currentIndex = buttonIndex; buttons.add( @@ -286,7 +276,6 @@ class TrackChapterControls extends StatelessWidget { buttonIndex++; } - // Picture-in-Picture mode if (state.onTogglePIPMode != null) { final currentIndex = buttonIndex; buttons.add( diff --git a/linux/CMakeLists.txt b/linux/CMakeLists.txt index dbe574fa..3644a4af 100644 --- a/linux/CMakeLists.txt +++ b/linux/CMakeLists.txt @@ -1,4 +1,3 @@ -# Project-level configuration. cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) @@ -6,8 +5,6 @@ project(runner LANGUAGES CXX) # registered by the runner subdirectory. include(CTest) -# The name of the executable created for the application. Change this to change -# the on-disk name of your application. set(BINARY_NAME "plezy") # The unique GTK application identifier for this application. See: # https://wiki.gnome.org/HowDoI/ChooseApplicationID @@ -30,7 +27,6 @@ if(FLUTTER_TARGET_PLATFORM_SYSROOT) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) endif() -# Define build configuration options. if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) set(CMAKE_BUILD_TYPE "Debug" CACHE STRING "Flutter build mode" FORCE) @@ -63,7 +59,6 @@ function(APPLY_STANDARD_SETTINGS TARGET) target_compile_definitions(${TARGET} PRIVATE "$<$>:NDEBUG>") endfunction() -# Flutter library and tool build rules. set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") add_subdirectory(${FLUTTER_MANAGED_DIR}) diff --git a/linux/packaging/after-install.sh b/linux/packaging/after-install.sh index f32be006..c06b906d 100644 --- a/linux/packaging/after-install.sh +++ b/linux/packaging/after-install.sh @@ -1,15 +1,12 @@ #!/bin/bash -# Ensure binaries are executable chmod +x /usr/bin/plezy chmod +x /opt/plezy/plezy chmod +x /opt/plezy/lib/crashpad_handler -# Update icon cache if command -v gtk-update-icon-cache &> /dev/null; then gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true fi -# Update desktop database if command -v update-desktop-database &> /dev/null; then update-desktop-database /usr/share/applications || true fi diff --git a/linux/packaging/after-remove.sh b/linux/packaging/after-remove.sh index 682188b3..f70d2196 100644 --- a/linux/packaging/after-remove.sh +++ b/linux/packaging/after-remove.sh @@ -1,10 +1,8 @@ #!/bin/bash -# Update icon cache if command -v gtk-update-icon-cache &> /dev/null; then gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true fi -# Update desktop database if command -v update-desktop-database &> /dev/null; then update-desktop-database /usr/share/applications || true fi diff --git a/linux/packaging/build-packages.py b/linux/packaging/build-packages.py index 055e0ee8..a8725c69 100755 --- a/linux/packaging/build-packages.py +++ b/linux/packaging/build-packages.py @@ -6,14 +6,12 @@ import shutil import sys from pathlib import Path -# Paths SCRIPT_DIR = Path(__file__).parent.resolve() PROJECT_ROOT = SCRIPT_DIR.parent.parent BUILD_DIR = Path(os.environ["BUILD_DIR"]) if "BUILD_DIR" in os.environ else PROJECT_ROOT / "build/linux/x64/release/bundle" OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", PROJECT_ROOT)) ARCH_SUFFIX = os.environ.get("ARCH_SUFFIX", "x64") -# Package metadata METADATA = { "name": "plezy", "license": "GPL-3.0", @@ -23,16 +21,13 @@ METADATA = { "description": "A modern Plex and Jellyfin client for desktop and mobile", } -# Icon sizes to generate ICON_SIZES = [16, 32, 48, 64, 128, 256, 512] -# Architecture mappings per package format ARCH_MAP = { "x64": {"deb": "amd64", "rpm": "x86_64", "pacman": "x86_64"}, "arm64": {"deb": "arm64", "rpm": "aarch64", "pacman": "aarch64"}, } -# Distro-specific configuration DISTROS = { "deb": { "type": "deb", diff --git a/linux/runner/CMakeLists.txt b/linux/runner/CMakeLists.txt index dbb8ab5c..e41c9daf 100644 --- a/linux/runner/CMakeLists.txt +++ b/linux/runner/CMakeLists.txt @@ -1,11 +1,6 @@ cmake_minimum_required(VERSION 3.13) project(runner LANGUAGES CXX) -# Define the application target. To change its name, change BINARY_NAME in the -# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer -# work. -# -# Any new source files that you add to the application should be added here. add_executable(${BINARY_NAME} "main.cc" "my_application.cc" @@ -15,8 +10,6 @@ add_executable(${BINARY_NAME} "${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" ) -# Apply the standard set of build settings. This can be removed for applications -# that need different build settings. apply_standard_settings(${BINARY_NAME}) # apply_standard_settings asks for cxx_std_14, which current compilers satisfy @@ -25,14 +18,8 @@ apply_standard_settings(${BINARY_NAME}) # compiler happens to default to. target_compile_features(${BINARY_NAME} PRIVATE cxx_std_17) -# Add preprocessor definitions for the application ID. add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}") -# Find mpv library. -pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) - -# Find epoxy (OpenGL loader). -pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy) # Wayland client + EGL back the native video plane (a wl_subsurface below the # Flutter surface). GTK's Wayland backend already links these, but the runner @@ -40,6 +27,8 @@ pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy) pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client) pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl) +pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv) +pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy) # Vendored wayland-scanner output for color-management-v1 (staging), which backs # HDR on the native video plane. Built separately because it is C (the runner is @@ -65,14 +54,11 @@ target_include_directories(wayland_protocols PUBLIC "${CMAKE_CURRENT_SOURCE_DIR} target_link_libraries(wayland_protocols PUBLIC PkgConfig::WAYLAND_CLIENT) target_compile_options(wayland_protocols PRIVATE -w) -# Build simdutf as a static library from the single-header amalgamation. add_library(simdutf STATIC "${simdutf_SOURCE_DIR}/simdutf.cpp") target_include_directories(simdutf PUBLIC "${simdutf_SOURCE_DIR}") target_compile_features(simdutf PUBLIC cxx_std_14) -# Suppress warnings in third-party code target_compile_options(simdutf PRIVATE -w) -# Add dependency libraries. Add any application-specific dependencies here. target_link_libraries(${BINARY_NAME} PRIVATE flutter) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::MPV) diff --git a/macos/Runner/Configs/AppInfo.xcconfig b/macos/Runner/Configs/AppInfo.xcconfig index 5d40372b..c7843717 100644 --- a/macos/Runner/Configs/AppInfo.xcconfig +++ b/macos/Runner/Configs/AppInfo.xcconfig @@ -1,14 +1,6 @@ -// Application-level settings for the Runner target. -// -// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the -// future. If not, the values below would default to using the project name when this becomes a -// 'flutter create' template. -// The application's name. By default this is also the title of the Flutter window. PRODUCT_NAME = Plezy -// The application's bundle identifier PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy -// The copyright displayed in application information PRODUCT_COPYRIGHT = Copyright © 2025 edde746 diff --git a/macos/Runner/MainFlutterWindow.swift b/macos/Runner/MainFlutterWindow.swift index 144e29e0..27d4cfbe 100644 --- a/macos/Runner/MainFlutterWindow.swift +++ b/macos/Runner/MainFlutterWindow.swift @@ -16,8 +16,7 @@ class MainFlutterWindow: NSWindow { self.contentViewController = flutterViewController self.setFrame(windowFrame, display: true) - // Apply initial window configuration BEFORE frame restoration - // This prevents the window from shrinking on launch + // Configure window chrome before restoring the saved frame. self.titlebarAppearsTransparent = true self.titleVisibility = .hidden self.styleMask.insert(.fullSizeContentView) @@ -26,28 +25,23 @@ class MainFlutterWindow: NSWindow { let toolbar = ForwardingToolbar(flutterViewController: flutterViewController) self.toolbar = toolbar - // Register MPV player plugin for video playback MpvPlayerPlugin.register( with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin")) - // Register the audio-only MPV player plugin for music playback MpvAudioPlayerPlugin.register( with: flutterViewController.registrar(forPlugin: "MpvAudioPlayerPlugin")) - // Register window utils plugin for dynamic titlebar/fullscreen control from Dart WindowUtilsPlugin.register( with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin")) WindowUtilsPlugin.setWindow(self) WindowUtilsPlugin.installWindowDelegate() WindowUtilsPlugin.syncWindowChrome() - // Register Launch Services lookups used to detect installed external players AppLookupPlugin.register( with: flutterViewController.registrar(forPlugin: "AppLookupPlugin")) RegisterGeneratedPlugins(registry: flutterViewController) - // Enable window position/size persistence self.setFrameAutosaveName("com.edde746.plezy.MainWindow") WindowUtilsPlugin.syncWindowChrome() diff --git a/macos/Runner/MpvPlayer/MpvPipController.swift b/macos/Runner/MpvPlayer/MpvPipController.swift index ae2fd4c2..17e6b5e4 100644 --- a/macos/Runner/MpvPlayer/MpvPipController.swift +++ b/macos/Runner/MpvPlayer/MpvPipController.swift @@ -47,8 +47,6 @@ class MpvPipController: NSObject, PIPViewControllerDelegate { sourceWindow = window - // Create a layer-hosting wrapper view for the Metal layer. - // PIPViewController resizes the view (and its root layer) as the PiP window resizes. let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio)) videoView.wantsLayer = true videoView.layer = metalLayer @@ -57,14 +55,12 @@ class MpvPipController: NSObject, PIPViewControllerDelegate { // Without this, the explicit main-window drawableSize persists in PiP. metalLayer.drawableSize = .zero - // Create a view controller for PIPViewController let vc = NSViewController() vc.view = videoView pipVideoVC = vc pipVideoView = videoView - // Configure PiP pip.playing = delegate?.isPipPlaying ?? false pip.aspectRatio = aspectRatio pip.replacementWindow = window @@ -72,7 +68,6 @@ class MpvPipController: NSObject, PIPViewControllerDelegate { delegate?.pipWillStart() - // Present PiP pip.presentAsPicture(inPicture: vc) isActive = true delegate?.pipDidStart() diff --git a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift index e715535a..b5fcdbd3 100644 --- a/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift +++ b/macos/Runner/MpvPlayer/MpvPlayerPlugin.swift @@ -11,14 +11,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS private weak var registrar: FlutterPluginRegistrar? var nameToId: [String: Int] = [:] - // MpvPluginShared conformance var coreBase: MpvPlayerCoreBase? { playerCore } func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) { playerCore?.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible) } func updatePlayerFrame() { playerCore?.updateFrame() } - // PiP private var pipController: MpvPipController? private var pipChannel: FlutterMethodChannel? private var autoPipEnabled = false @@ -27,13 +25,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS // MARK: - FlutterPlugin Registration static func register(with registrar: FlutterPluginRegistrar) { - // Method channel for commands let methodChannel = FlutterMethodChannel( name: "com.plezy/mpv_player", binaryMessenger: registrar.messenger ) - // Event channel for state updates let eventChannel = FlutterEventChannel( name: "com.plezy/mpv_player/events", binaryMessenger: registrar.messenger @@ -110,7 +106,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS } } - // MARK: - PiP private func ensurePipController() -> MpvPipController { if let existing = pipController { return existing } @@ -135,13 +130,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS let pip = ensurePipController() pip.setAutoStart(ready) if ready { - // Observe app resigning active to auto-enter PiP NotificationCenter.default.removeObserver( self, name: NSApplication.didResignActiveNotification, object: nil) NotificationCenter.default.addObserver( self, selector: #selector(appDidResignActive), name: NSApplication.didResignActiveNotification, object: nil) - // Observe app becoming active to auto-exit PiP NotificationCenter.default.removeObserver( self, name: NSApplication.didBecomeActiveNotification, object: nil) NotificationCenter.default.addObserver( @@ -184,8 +177,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS return } - // Get video dimensions for aspect ratio - var aspectRatio = NSSize(width: 16, height: 9) // default + var aspectRatio = NSSize(width: 16, height: 9) if let videoSize = playerCore.videoSize { aspectRatio = NSSize(width: videoSize.width, height: videoSize.height) } @@ -226,14 +218,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS return } - // Check if already initialized if self.playerCore?.isInitialized == true { print("[MpvPlayerPlugin] Already initialized") result(true) return } - // Find the Flutter window guard let (window, _, _) = self.findFlutterWindow() else { print("[MpvPlayerPlugin] Failed to find Flutter window") result( @@ -242,7 +232,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS return } - // Create and initialize player core let core = MpvPlayerCore() core.delegate = self @@ -256,7 +245,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS self.playerCore = core - // Start hidden core.setVisible(false) print("[MpvPlayerPlugin] Initialized successfully") @@ -303,7 +291,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS } } - // Fallback for window in NSApplication.shared.windows { if let contentView = window.contentView, let contentVC = window.contentViewController @@ -334,13 +321,10 @@ extension MpvPlayerPlugin: MpvPipDelegate { playerCore?.isPipActive = false enteredPipViaAuto = false - // Detach the Metal layer from the PiP wrapper view pipController?.detachLayer() - // Re-attach the Metal layer to the main window playerCore?.reattachMetalLayer() - // Force a redraw if paused (prevents black frame after PiP exit) if playerCore?.isPaused == true { playerCore?.forceDraw() } diff --git a/macos/Runner/WindowDelegate.swift b/macos/Runner/WindowDelegate.swift index 1c435149..0cf6a4bf 100644 --- a/macos/Runner/WindowDelegate.swift +++ b/macos/Runner/WindowDelegate.swift @@ -5,8 +5,6 @@ class WindowDelegate: NSObject, NSWindowDelegate { weak var channel: FlutterMethodChannel? weak var window: NSWindow? - // Hardcoded presentation options for fullscreen mode - // Auto-hide toolbar, menu bar, and dock when in fullscreen private let fullScreenPresentationOptions: NSApplication.PresentationOptions = [ .fullScreen, .autoHideToolbar, @@ -62,7 +60,6 @@ class WindowDelegate: NSObject, NSWindowDelegate { func windowWillEnterFullScreen(_ notification: Notification) { guard let window = window else { return } applyFullScreenChrome(to: window) - // Notify Dart for state management only emit("windowWillEnterFullScreen") } @@ -72,7 +69,6 @@ class WindowDelegate: NSObject, NSWindowDelegate { func windowWillExitFullScreen(_ notification: Notification) { guard let window = window else { return } - // Hide title and make titlebar transparent BEFORE exiting window.titleVisibility = .hidden window.titlebarAppearsTransparent = true emit("windowWillExitFullScreen") diff --git a/macos/Runner/WindowUtilsPlugin.swift b/macos/Runner/WindowUtilsPlugin.swift index eedaa6c8..58e66ee2 100644 --- a/macos/Runner/WindowUtilsPlugin.swift +++ b/macos/Runner/WindowUtilsPlugin.swift @@ -1,8 +1,6 @@ import Cocoa import FlutterMacOS -// MARK: - ForwardingView -// A view that forwards mouse events to the Flutter view controller class ForwardingView: NSView { weak var flutterViewController: NSViewController? @@ -15,8 +13,6 @@ class ForwardingView: NSView { } } -// MARK: - ForwardingToolbar -// A custom toolbar that forwards mouse events from the toolbar area to Flutter class ForwardingToolbar: NSToolbar, NSToolbarDelegate { let flutterViewController: NSViewController @@ -26,7 +22,6 @@ class ForwardingToolbar: NSToolbar, NSToolbarDelegate { self.delegate = self self.showsBaselineSeparator = false - // Prevent toolbar customization UI (the "rounded box") self.allowsUserCustomization = false self.allowsExtensionItems = false if #available(macOS 15.0, *) { @@ -48,11 +43,11 @@ class ForwardingToolbar: NSToolbar, NSToolbarDelegate { ) -> NSToolbarItem? { if itemIdentifier == NSToolbarItem.Identifier("ForwardingItem") { let item = NSToolbarItem(itemIdentifier: itemIdentifier) - item.isBordered = false // Remove the rounded box appearance let view = ForwardingView() view.flutterViewController = flutterViewController view.widthAnchor.constraint(lessThanOrEqualToConstant: 100000).isActive = true view.widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true + item.isBordered = false item.view = view return item } @@ -68,7 +63,6 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { private var windowDelegate: WindowDelegate? private var originalButtonConstraints: [NSWindow.ButtonType: [NSLayoutConstraint]] = [:] - // Centralized traffic light positions - the single source of truth private static let customButtonPositions: [(NSWindow.ButtonType, CGPoint)] = [ (.closeButton, CGPoint(x: 20, y: 21)), (.miniaturizeButton, CGPoint(x: 40, y: 21)), @@ -224,7 +218,6 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { window: NSWindow, buttonType: NSWindow.ButtonType, offset: CGPoint ) { withButton(buttonType, in: window) { button, superview in - // Store original constraints if not already stored if originalButtonConstraints[buttonType] == nil { let constraints = superview.constraints.filter { constraint in (constraint.firstItem as? NSButton) == button @@ -233,12 +226,10 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { originalButtonConstraints[buttonType] = constraints } - // Remove existing position constraints superview.removeConstraints(positionConstraints(for: button, in: superview)) button.translatesAutoresizingMaskIntoConstraints = false - // Add new positioning constraints superview.addConstraints([ button.leftAnchor.constraint(equalTo: superview.leftAnchor, constant: offset.x), button.topAnchor.constraint(equalTo: superview.topAnchor, constant: offset.y), @@ -249,10 +240,8 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin { private func resetButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType) { withButton(buttonType, in: window) { button, superview in - // Remove custom constraints superview.removeConstraints(positionConstraints(for: button, in: superview)) - // Restore original constraints if we have them if let originalConstraints = originalButtonConstraints[buttonType] { superview.addConstraints(originalConstraints) originalButtonConstraints.removeValue(forKey: buttonType) diff --git a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt index 650e33eb..a8bc6caa 100644 --- a/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt +++ b/packages/saf_util/android/src/main/kotlin/com/fluttercavalry/saf_util/SafUtilPlugin.kt @@ -25,15 +25,10 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -/** SafUtilPlugin */ class SafUtilPlugin : FlutterPlugin, MethodCallHandler, ActivityAware { - // / The MethodChannel that will the communication between Flutter and native Android - // / - // / This local reference serves to register the plugin with the Flutter Engine and unregister it - // / when the Flutter Engine is detached from the Activity private lateinit var channel: MethodChannel private lateinit var context: Context @@ -138,10 +133,8 @@ class SafUtilPlugin : val lastModified = cursor.getLong(4) val documentUri = DocumentsContract.buildDocumentUriUsingTree(mUri, documentId) - // Determine if the file is a directory based on the MIME type val isDirectory = DocumentsContract.Document.MIME_TYPE_DIR == mimeType - // Create a dictionary (map) for each file with its details val fileInfo = fileObjMap( documentUri, @@ -325,8 +318,7 @@ class SafUtilPlugin : if (findRes == null) { val createRes = curDocument.createDirectory(curName) if (createRes != null && createRes.name != curName) { - // There are cases where the created directory has a different name due to concurrent operations. - // In this case, we need to find the directory with the correct name again. + // SAF may rename a concurrently created directory; resolve the requested name. val findRes2 = findDirectChild(curDocument.uri, curName) nextDocument = if (findRes2 != null) { @@ -504,7 +496,6 @@ class SafUtilPlugin : return } - // Store the result to return the URI later pendingResult = result pendingArguments = PendingDirArguments(writePermission, persistablePermission) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) @@ -548,7 +539,6 @@ class SafUtilPlugin : return } - // Store the result to return the URI later pendingResult = result val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) intent.addCategory(Intent.CATEGORY_OPENABLE) @@ -772,7 +762,6 @@ class SafUtilPlugin : } } - // Handle folder/file/media picker results; unrelated request codes are not ours. private fun onActivityResult( requestCode: Int, resultCode: Int, diff --git a/pubspec.yaml b/pubspec.yaml index ab5afa37..88260cf1 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,22 +48,15 @@ dependencies: cryptography: ^2.9.0 file_picker: ^11.0.2 saf_util: - # Vendored upstream 3.1.0 with a pending Result lifecycle fix (CHANGELOG) — - # upstream still double-replies MethodChannel results and crashes the Activity. + # Vendored 3.1.0: fixes upstream's duplicate MethodChannel result. path: packages/saf_util saf_stream: ^3.1.0 material_symbols_icons: ^4.2951.0 web_socket_channel: ^3.0.1 dart_discord_presence: ^1.2.0 flutter_svg: ^2.2.3 - # Fork note: 41c16be creates the desktop temp parent before opening the - # partial file, independently fixing upstream #649's missing-directory case. - # Upstream's later target-directory fallback is unnecessary while this fork - # retains its same-filesystem move and resume-data cleanup implementation. - # No per-host trust bypass is carried: Plezy has no app-wide certificate - # exception policy, and weakening only background downloads would make API - # and playback behavior inconsistent. Revisit only with an explicit, - # host-scoped trust setting shared by every media-server HTTP client. + # Fork 41c16be fixes upstream #649's missing desktop temp parent. + # Keep the same-filesystem move/resume cleanup; no per-host TLS bypass. background_downloader: git: url: https://github.com/edde746/background_downloader @@ -87,7 +80,7 @@ dev_dependencies: flutter_test: sdk: flutter flutter_lints: ^6.0.0 - # Root analysis traverses packages/saf_util and consumes its lint profile. + # Root analysis includes packages/saf_util. mgenware_dart_lints: ^8.0.0 fake_async: ^1.3.3 stream_channel: ^2.1.4 @@ -95,15 +88,13 @@ dev_dependencies: json_serializable: ^6.7.1 slang_build_runner: ^4.14.0 sentry_dart_plugin: ^3.3.0 - # Used by the explicit check-unused-* CI steps; its analyzer plugin is disabled. + # Used by explicit check-unused CI steps; analyzer plugin disabled. dart_code_linter: ^4.1.8 drift_dev: ^2.28.3 shared_preferences_platform_interface: ^2.4.0 path_provider_platform_interface: ^2.1.0 - # Drive the real desktop preference backends against a temp file in tests, so - # the #1732 corrupt-store repair loop and the vendored atomic write are both - # covered against genuine file I/O. All four expose `fs`/`pathProvider`. - # The two shared_preferences packages resolve to the vendored copies below. + # Exercise vendored desktop preference stores against real file I/O, + # including #1732 repair and atomic writes. shared_preferences_linux: ^2.4.1 shared_preferences_windows: ^2.4.1 path_provider_linux: ^2.2.1 @@ -128,9 +119,7 @@ dependency_overrides: url: https://github.com/edde746/auto_updater ref: 9e150f7 path: packages/auto_updater_windows - # Vendored 2.4.1 with an atomic store replacement (CHANGELOG, provenance.json) - # — upstream truncates the live preference document on every write, which is - # the corruption behind #1732. + # Vendored 2.4.1: atomic replacement prevents upstream #1732 corruption. shared_preferences_windows: path: packages/shared_preferences_windows shared_preferences_linux: diff --git a/scripts/check_build_workflow.py b/scripts/check_build_workflow.py index 8d8628a8..09bf81a1 100644 --- a/scripts/check_build_workflow.py +++ b/scripts/check_build_workflow.py @@ -15,9 +15,8 @@ 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 -# The shared bootstrap both windows-arm jobs call, and the pins it must keep. -# Resolved beside the workflow rather than from ROOT so that checking a fixture -# tree exercises this rule instead of silently re-reading the real action. +# Resolve the shared bootstrap beside the workflow so fixture checks use their +# local action rather than the checkout's real action. SETUP_FLUTTER_GIT = WORKFLOW.parents[1] / "actions/setup-flutter-git/action.yml" text = WORKFLOW.read_text(encoding="utf-8") errors: list[str] = [] @@ -168,9 +167,7 @@ require(bool(setup_flutter_git), "missing .github/actions/setup-flutter-git/acti for expected in ( f'$version = "{FLUTTER_VERSION}"', f'$expectedCommit = "{FLUTTER_COMMIT}"', - # Fetch the release tag rather than the bare commit: the commit is only - # reachable through the tag, and the tag is what makes the SDK report its - # own version. Both halves are then verified, so a moved tag fails the job. + # Fetch and verify the release tag so moved tags cannot change the SDK. '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", @@ -336,10 +333,8 @@ require( "every Flutter SDK cache must define its trusted cache key", ) -# 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. +# Action-pin checks run elsewhere; this guard adds the checkout credential +# invariant for the workflow-dispatch-only build. remote_actions = [ reference.rpartition("@")[0] for _, reference in iter_uses_references(text) diff --git a/scripts/check_hardcoded_strings.py b/scripts/check_hardcoded_strings.py index da55adec..a98092b2 100755 --- a/scripts/check_hardcoded_strings.py +++ b/scripts/check_hardcoded_strings.py @@ -40,11 +40,7 @@ _UI_ARGUMENT_RE = re.compile(r"(? 'Created ...'`), so the argument name is not -# the text immediately preceding the literal. The `[a-z]Log` arm keeps -# innocent names such as `catalog:` out of the exclusion. +# `log:`/`createdLog:` are diagnostic sinks, including inside lambdas. _DIAGNOSTIC_RE = re.compile( r"(?:\bappLogger\.|\bSentry\.|\bassert\s*\(|\bthrow\b|(?:\blog|[a-z]Log)\s*:)" ) @@ -52,17 +48,9 @@ _TRANSLATION_INTERPOLATION_RE = re.compile( r"\$\{\s*(?:t\.|context\.t\b|Translations\.of\s*\()" ) _T_PARAMETER_RE = re.compile(r"(?:\(\s*t\s*\)|\bt)\s*=>[^;]*$") -# Rule 4 support: a literal bound to a name or returned, rather than handed -# straight to a widget. This is the shape issue #1856 actually had -- -# `baseButtonText = 'Skip Intro';` a few lines above the `Text(buttonText)` -# that renders it -- which rules 1-3 structurally cannot see. -# -# Restricted to phrase-shaped literals: at least two letter-words separated by -# whitespace. Without that restriction the rule cannot tell display copy from -# the identifier strings this codebase binds constantly ('cast_row', 'auto', -# 'liveTv', 'HDR_UNSUPPORTED'), and it drowns in false positives. The cost is -# that a bound single-word label ('Software', 'Stereo') slips through; those -# are indistinguishable from an identifier without dataflow analysis. +# Rule 4 catches phrases assigned or returned before a widget renders them. +# Restrict it to multi-word phrases to avoid confusing identifiers with UI copy; +# single-word labels remain indistinguishable without dataflow analysis. _PHRASE_RE = re.compile(r"[A-Za-z]\s+[A-Za-z]") _BOUND_LITERAL_RE = re.compile(r"(?:\breturn|=>|(?+\-*/%&|^~])=)\s*$") _RENDERS_UI_RE = re.compile( @@ -326,7 +314,7 @@ def scan( seen_literals: dict[str, set[str]] = {} for path in sorted(lib_dir.rglob("*.dart")): relative = path.relative_to(root).as_posix() - # lib/dev is a separate measurement entrypoint, explicitly "NOT part of the app". + # lib/dev is a separate, non-app measurement entrypoint. if ( relative.startswith(("lib/i18n/", "lib/dev/")) or path.name.endswith((".g.dart", ".freezed.dart")) diff --git a/scripts/check_linux_package_deps.py b/scripts/check_linux_package_deps.py index ba63de97..40e99068 100644 --- a/scripts/check_linux_package_deps.py +++ b/scripts/check_linux_package_deps.py @@ -1,32 +1,9 @@ #!/usr/bin/env python3 -"""Guard the Linux package dependency lists against what the runner links. +"""Guard distro dependencies for host libraries linked by the Linux runner. -linux/packaging/bundle-libs.sh deliberately refuses to bundle the display- and -driver-coupled libraries (libEGL, libwayland-*, libGL, libdrm ...): they must -come from the host or the app will not talk to the compositor it is running -under. That makes them the package manager's problem, and the depends lists in -linux/packaging/build-packages.py are maintained by hand. - -Nothing connected the two. Adding a pkg-config link to the runner produced a -binary with an undeclared shared-library dependency, and the failure surfaces -only on a user's machine at exec time - a class of bug no compile or unit test -can reach. This walks the runner's own link line instead: - - target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_EGL) - -> pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) - -> RUNTIME_PACKAGES["wayland-egl"] -> libwayland-egl1 / libwayland-egl / wayland - -and requires every distro to declare it. A new pkg-config module fails here -until its runtime package names are named for all three. - -What it walks is exactly CMAKE_FILES, the three CMakeLists.txt this checkout -owns - and nothing else. The Flutter plugins link into the same binary from -linux/flutter/generated_plugins.cmake, whose add_subdirectory() targets live -under flutter/ephemeral/.plugin_symlinks/, a directory that only exists after -`flutter pub get`. A plugin's own pkg_check_modules is therefore unreadable at -pull-request time, and a plugin that starts linking a new host library passes -here. linux/packaging/check-bundle-host-deps.py is what covers that: it runs -ldd over the built bundle, where every link edge is finally real. +The runner's CMake link graph maps through pkg-config modules to the hand-maintained +package lists. This catches undeclared runtime libraries before they fail on users' +machines; the built-bundle check covers plugin links unavailable before `pub get`. """ from pathlib import Path @@ -43,31 +20,24 @@ LINUX = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else ROOT / "linux" RUNNER_CMAKE = LINUX / "runner/CMakeLists.txt" PACKAGES_PY = LINUX / "packaging/build-packages.py" BUNDLE_SH = LINUX / "packaging/bundle-libs.sh" -# pkg_check_modules for targets the runner links may live in any of these. +# Runner-linked pkg_check_modules may be declared in any of these files. CMAKE_FILES = (RUNNER_CMAKE, LINUX / "CMakeLists.txt", LINUX / "flutter/CMakeLists.txt") -# pkg-config modules whose library ships *inside* the package instead of being -# depended on. libmpv is pinned and Wayland-enabled because the video plane needs -# it to be; a distro libmpv silently drops hwdec to vaapi-copy. Bundling it means -# there is deliberately no runtime dependency to find, so the walk must not -# demand one - but the libraries it links that bundle-libs.sh excludes still have -# to be declared, which the packaging job re-derives from the built bundle. +# Bundled modules have no host dependency; their excluded runtime links are +# checked separately against the built bundle. BUNDLED_MODULES = {"mpv"} -# pkg-config module -> the package that ships its runtime library, per distro. -# Only modules the runner actually links are consulted, so an unused entry here -# is harmless; a missing one is an error. +# pkg-config module -> runtime package name for each distro. RUNTIME_PACKAGES = { "gtk+-3.0": {"deb": "libgtk-3-0", "rpm": "gtk3", "pacman": "gtk3"}, "epoxy": {"deb": "libepoxy0", "rpm": "libepoxy", "pacman": "libepoxy"}, - # Reached through the `flutter` INTERFACE target rather than named by the - # runner, which is why the graph has to cross file boundaries to see them. + # Reached through the `flutter` INTERFACE target. "glib-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"}, "gio-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"}, "wayland-client": { "deb": "libwayland-client0", "rpm": "libwayland-client", - # Arch ships every libwayland-* in the one `wayland` package. + # Arch ships all libwayland-* libraries in `wayland`. "pacman": "wayland", }, "wayland-egl": { @@ -75,7 +45,7 @@ RUNTIME_PACKAGES = { "rpm": "libwayland-egl", "pacman": "wayland", }, - # libglvnd is the vendor-neutral dispatch that provides libEGL.so.1. + # libglvnd provides libEGL.so.1. "egl": {"deb": "libegl1", "rpm": "libglvnd-egl", "pacman": "libglvnd"}, } @@ -95,9 +65,8 @@ def read(path: Path) -> str: return "" -# Keywords that carry no target name. LINK_KEYWORDS = {"PRIVATE", "PUBLIC", "INTERFACE", "optimized", "debug", "general"} -# Options CMake accepts between IMPORTED_TARGET and the module names, in any order. +# Link keywords and pkg_check_modules options are not module names. PKG_OPTIONS = ("REQUIRED", "QUIET", "GLOBAL", "NO_CMAKE_PATH", "NO_CMAKE_ENVIRONMENT_PATH") @@ -167,19 +136,11 @@ def pkgconfig_modules() -> dict[str, list[str]]: options = "|".join(PKG_OPTIONS) for path in CMAKE_FILES: for match in re.finditer( - # The options may precede the module names, so skip any run of them - # rather than taking the first token and reporting `REQUIRED` as a - # package nobody ships. The tail is then every remaining token up to - # the closing paren. + # Skip options before module names. r"pkg_check_modules\(\s*(\w+)\b[^)]*?IMPORTED_TARGET\s+((?:(?:" + options + r")\s+)*[^)]*)\)", strip_comments(read(path)), ): - # A moduleSpec is `` or ``, so the version - # constraint has to come off before the name is looked up - otherwise - # a perfectly legal `mpv>=0.40` is reported as a package nobody - # ships, and the message sends whoever hits it off to invent a - # RUNTIME_PACKAGES entry for it. Pinning that minimum is a plausible - # next edit here: target-colorspace-hint=auto needs mpv 0.40. + # Strip version constraints before package lookup. names = [ re.split(r"[<>=!]", t, maxsplit=1)[0] for t in match.group(2).split() if t not in PKG_OPTIONS ] @@ -203,12 +164,8 @@ def declared_depends() -> dict[str, list[str]]: return {} -# Every file, not just the runner's: `flutter` is defined in flutter/CMakeLists.txt -# and propagates GTK, GLIB and GIO to whatever links it, so a graph built from one -# file treats it as a leaf and never sees them. A member that moved or was renamed -# is fatal rather than a smaller walk: its modules drop out of the graph, a -# declaration deleted alongside it goes unreported, and every check below then -# passes over a tree nobody actually looked at. +# Include every owned CMake input; missing files fail closed instead of shrinking +# the graph and silently passing. absent = [path for path in CMAKE_FILES if not path.is_file()] if absent: for path in absent: @@ -225,8 +182,7 @@ depends = declared_depends() require(bool(depends), "no distro depends lists were found, so nothing was checked") -# The exclusion list is what makes declaring these mandatory rather than -# optional. If bundling ever starts covering them, this guard is the wrong shape. +# These libraries must remain host-provided; bundling them would invalidate this guard. bundle = read(BUNDLE_SH) for pattern in (r"libEGL\.so", r"libwayland.*\.so"): require( @@ -253,9 +209,7 @@ for target in sorted(linked): for module in target_modules: checked_modules += 1 if module in BUNDLED_MODULES: - # Shipped inside the package, so there is no dependency to find. Still - # counted, so the summary keeps naming everything the walk reached and - # a module going missing is a drop rather than a silent skip. + # Bundled modules have no host dependency but remain part of the summary. continue packages = RUNTIME_PACKAGES.get(module) if packages is None: diff --git a/scripts/check_shrinker_rules.py b/scripts/check_shrinker_rules.py index a704287b..7324ba34 100755 --- a/scripts/check_shrinker_rules.py +++ b/scripts/check_shrinker_rules.py @@ -24,11 +24,9 @@ PROGUARD_RULES = Path("android/app/proguard-rules.pro") APP_JAVA_ROOT = Path("android/app/src/main/java") CPP_ROOT = Path("android/app/src/main/cpp") NATIVE_SUFFIXES = {".c", ".cc", ".cpp", ".h", ".hpp"} -# Namespaces the app borrows from a dependency purely so that dependency can reflect on -# them. A class under one of these has no direct caller by construction. +# Dependency namespaces reached only through reflection have no direct callers. REFLECTED_NAMESPACES = ("androidx/media3/",) -# Framework types live on the bootclasspath, never in the app's dex, so R8 cannot rename -# them and they need no keep. +# Bootclasspath framework types are not in the app dex and need no keep rule. PLATFORM_PREFIXES = ("java.", "javax.", "android.") _STRING_LITERAL = re.compile(r'"((?:[^"\\]|\\.)*)"') @@ -42,9 +40,7 @@ _MEMBER_LOOKUP = re.compile( + r"\s*\)" ) _DESCRIPTOR_CLASS = re.compile(r"L([\w/$]+);") -# Only -keep and -keepclasseswithmembers protect a class from both shrinking and -# renaming. -keepclassmembers/-keepclassmembernames cover members alone, and the -# -keepnames family allows shrinking, so none of them save a class nothing references. +# Only -keep variants without allowshrinking/allowobfuscation keep classes and names. _KEEP = re.compile( r"^-(?:keep|keepclasseswithmembers)((?:\s*,\s*\w+)*)\s+(?:class|interface|enum)\s+(\S+)" r"(?:\s*\{(.*?)\})?", @@ -62,7 +58,7 @@ class Keep: self.members = members self._regex = re.compile( "".join( - # ** spans package separators, * does not, ? is a single character. + # ** crosses package separators; * and ? match within a segment. {"**": r".*", "*": r"[^.]*", "?": r"."}.get(token, re.escape(token)) for token in re.findall(r"\*\*|[*?]|[^*?]+", pattern) ) diff --git a/scripts/check_windows_installer.py b/scripts/check_windows_installer.py index f3606d06..e3f9dec0 100755 --- a/scripts/check_windows_installer.py +++ b/scripts/check_windows_installer.py @@ -40,8 +40,7 @@ require( ) iss = template() -# The script used to carry two near-identical copies of the whole .iss, one per -# architecture shape. Anything that appears twice again has drifted apart. +# A single template prevents architecture copies from drifting. for once in ( r"^\[Setup\]$", r"^\[Code\]$", @@ -76,9 +75,7 @@ require( "the dual-architecture [Files] entries must keep their architecture checks", ) -# A fresh install stays per-user and prompts for nothing; only an existing -# machine-wide copy pulls in elevation, and only via /ALLUSERS, which Inno -# ignores unless the commandline override is allowed. +# Fresh installs stay per-user; only /ALLUSERS may trigger elevation. require( re.search(r"(?m)^PrivilegesRequired=lowest\s*$", iss) is not None, "a fresh install must stay per-user; PrivilegesRequired=lowest", @@ -93,7 +90,7 @@ require( "allowing dialog makes a silent install with no previous copy prompt; winget installs that way", ) -# The elevation path itself. +# Verify the elevation path. require( "IsAdminInstallMode" in iss, "the elevation path must be skipped once Setup already runs in administrative install mode", @@ -125,7 +122,7 @@ require( "a refused elevation must explain itself instead of failing silently", ) -# Behavior other tooling already depends on. +# Preserve behavior required by release tooling. require( "{param:WINGET|0}" in iss and "{app}\\.winget" in iss, "the winget marker file gates UpdateService.useNativeUpdater", diff --git a/scripts/check_windows_msix.py b/scripts/check_windows_msix.py index 81052483..e7d5741a 100644 --- a/scripts/check_windows_msix.py +++ b/scripts/check_windows_msix.py @@ -36,18 +36,14 @@ PUBSPEC = ROOT / "pubspec.yaml" WORKFLOW = ROOT / ".github/workflows/build.yml" MSIX_STEP = "Build Store package (MSIX)" BUNDLE = "plezy-windows.msixbundle" -# The identity reserved in Partner Center. All three are pinned here because a -# character of drift in any of them fails Store validation, and the first two -# derive the package family name edde746.Plezy_13q3sv6jzathm that installed -# copies are keyed by. +# Partner Center reserves these identity values and derives the package family name. IDENTITY_NAME = "edde746.Plezy" PUBLISHER = "CN=AA9C53CB-AD3C-48DA-B3E3-D1E8986D4E25" PUBLISHER_DISPLAY_NAME = "edde746" PACKAGE_FAMILY_SUFFIX = "13q3sv6jzathm" FOUNDATION = "http://schemas.microsoft.com/appx/manifest/foundation/windows10" ASSET_REFERENCE = re.compile(r"assets\\([A-Za-z0-9._-]+\.png)") -# Package's child order is fixed by the foundation schema. A manifest may use a -# subset of these, but never a different order. +# Package child order is fixed by the foundation schema. SCHEMA_ORDER = ( "Identity", "PhoneIdentity", @@ -68,12 +64,10 @@ REQUIRED_ELEMENTS = ( "Applications", ) REQUIRED_CAPABILITIES = ("runFullTrust", "internetClient", "privateNetworkClientServer") -# Certification requires these three. The optional tile and splash assets are -# only checked once referenced, since dropping them is a legitimate choice. +# Certification requires these capabilities; optional assets are checked only when referenced. REQUIRED_ASSETS = ("StoreLogo.png", "Square150x150Logo.png", "Square44x44Logo.png") -# Normalized so that every pattern below can anchor on \n and $ regardless of -# whether this checkout stores the PowerShell scripts with CRLF endings. +# Normalize line endings so subsequent patterns are portable. text = SCRIPT.read_text(encoding="utf-8").replace("\r\n", "\n") errors: list[str] = [] @@ -144,8 +138,7 @@ require( prelude = declarations() manifest_template = template() -# build-installer.ps1 once carried one whole .iss per architecture shape and the -# copies drifted apart. Anything that appears twice here has drifted too. +# A single manifest template prevents architecture copies from drifting. for once in ( r'^ return @"$', r"^"$out" 2>&1; then @@ -71,7 +65,6 @@ else fi rm -f "$out" -# 3. Translation hygiene section "translation hygiene" if python3 scripts/clean_translations.py --check --strict; then ok "locale files normalized and no unused keys found" @@ -80,7 +73,6 @@ else FAILED=1 fi -# 4. Hardcoded user-facing strings section "hardcoded UI strings" if python3 scripts/check_hardcoded_strings.py; then ok "user-facing strings use the translation layer" @@ -89,7 +81,6 @@ else FAILED=1 fi -# 5. Workflow and script regression guards section "workflow and script guards" if bash scripts/ci_guard_checks.sh; then ok "workflow and script guards passed" @@ -98,7 +89,6 @@ else FAILED=1 fi -# 6. Icon consistency section "icon consistency" if dart run scripts/check_icon_consistency.dart; then ok "production icons use AppIcon and rounded Symbols" @@ -107,7 +97,6 @@ else FAILED=1 fi -# 7. Native formatting section "native format" out="$(mktemp)" if scripts/format_native.sh --check >"$out" 2>&1; then @@ -119,7 +108,6 @@ else fi rm -f "$out" -# 8. Dart analyzer (mirrors ci.yml "Analyze code") section "Dart analyzer" if dart run scripts/check_analyzer.dart; then ok "no unapproved diagnostics" @@ -128,7 +116,6 @@ else FAILED=1 fi -# 9. Unused code (mirrors ci.yml "Check for unused code") section "dart_code_linter: unused code" if ! have_dart_code_linter; then skip "dart_code_linter unresolved — run 'flutter pub get'" @@ -145,7 +132,6 @@ else rm -f "$out" fi -# 10. Unused files (mirrors ci.yml "Check for unused files") section "dart_code_linter: unused files" if ! have_dart_code_linter; then skip "dart_code_linter unresolved — run 'flutter pub get'" diff --git a/scripts/ci_guard_checks.sh b/scripts/ci_guard_checks.sh index f95316e1..0399584b 100644 --- a/scripts/ci_guard_checks.sh +++ b/scripts/ci_guard_checks.sh @@ -1,15 +1,8 @@ #!/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, and linux/packaging/check-bundle-host-deps.py and -# check-package-deps.py need a built bundle and built packages, so both run in -# the linux-packages job and again in the release build), 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. +# Single source of truth for workflow/script guards, shared by CI and +# scripts/ci_checks.sh. Regression tests use a glob so new test_checkers are +# picked up automatically; checkers requiring Bun or built packages run in +# their owning jobs as well. set -euo pipefail shopt -s nullglob diff --git a/scripts/generate_android_icons.sh b/scripts/generate_android_icons.sh index 9b73409f..593c968c 100755 --- a/scripts/generate_android_icons.sh +++ b/scripts/generate_android_icons.sh @@ -1,23 +1,18 @@ #!/usr/bin/env bash -# Android Icon Generator Script -# Generates notification icons and monochrome launcher icons from SVG source -# Usage: ./generate_android_icons.sh +# Generate Android notification and monochrome icons from assets/plezy.svg. set -e -# Configuration SVG_SOURCE="assets/plezy.svg" ANDROID_RES="android/app/src/main/res" TEMP_DIR="/tmp/android_icons_$$" -# Check if source SVG exists if [ ! -f "$SVG_SOURCE" ]; then echo "Error: $SVG_SOURCE not found" exit 1 fi -# Check for required tools if ! command -v rsvg-convert &> /dev/null; then echo "Error: rsvg-convert not found. Install with: brew install librsvg" exit 1 @@ -37,27 +32,24 @@ if ! command -v bc &> /dev/null; then exit 1 fi -# Create temp directory mkdir -p "$TEMP_DIR" echo "🎨 Generating Android icons from $SVG_SOURCE..." -# Function to generate white silhouette icon generate_white_icon() { local size=$1 local output=$2 - # Get SVG dimensions to detect if it's non-square + # Read dimensions so non-square artwork can be padded. local svg_viewbox=$(grep -o 'viewBox="[^"]*"' "$SVG_SOURCE" | sed 's/viewBox="//;s/"//') local svg_width=$(echo "$svg_viewbox" | awk '{print $3}') local svg_height=$(echo "$svg_viewbox" | awk '{print $4}') - # Convert SVG to PNG, preserving aspect ratio + # Preserve the source aspect ratio. if (( $(echo "$svg_width != $svg_height" | bc -l) )); then - # Non-square SVG: render at size and add padding to center it + # Center non-square artwork on a transparent square canvas. rsvg-convert --keep-aspect-ratio --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp_unpadded.png" - # Center the image in a square canvas with transparent padding "$IMAGEMAGICK" "$TEMP_DIR/temp_unpadded.png" \ -resize "${size}x${size}" \ -gravity center \ @@ -65,11 +57,10 @@ generate_white_icon() { -extent "${size}x${size}" \ "$TEMP_DIR/temp.png" else - # Square SVG: convert directly rsvg-convert -w "$size" -h "$size" --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp.png" fi - # Convert to white silhouette: extract alpha, fill with white, apply alpha + # Convert the source alpha channel to a white silhouette. "$IMAGEMAGICK" "$TEMP_DIR/temp.png" \ -alpha extract \ "$TEMP_DIR/alpha_mask.png" @@ -85,12 +76,9 @@ generate_white_icon() { echo " ✓ Generated $(basename $output) (${size}x${size}px)" } -# Generate Notification Icons (24dp base) echo "" echo "📱 Generating notification icons (ic_stat_notification.png)..." -# Notification icon densities: 24dp base -# mdpi=1x, hdpi=1.5x, xhdpi=2x, xxhdpi=3x, xxxhdpi=4x declare -A NOTIF_SIZES=( ["mdpi"]=24 ["hdpi"]=36 @@ -106,12 +94,9 @@ for density in "${!NOTIF_SIZES[@]}"; do generate_white_icon "$size" "$output_dir/ic_stat_notification.png" done -# Generate Monochrome Launcher Icons (108dp base) echo "" echo "🚀 Generating monochrome launcher icons (ic_launcher_monochrome.png)..." -# Monochrome launcher icon densities: 108dp base -# mdpi=1x, hdpi=1.5x, xhdpi=2x, xxhdpi=3x, xxxhdpi=4x declare -A MONO_SIZES=( ["mdpi"]=108 ["hdpi"]=162 @@ -127,7 +112,6 @@ for density in "${!MONO_SIZES[@]}"; do generate_white_icon "$size" "$output_dir/ic_launcher_monochrome.png" done -# Clean up rm -rf "$TEMP_DIR" echo "" diff --git a/scripts/generate_windows_msix_assets.ps1 b/scripts/generate_windows_msix_assets.ps1 index 8074d5bf..d96a8430 100644 --- a/scripts/generate_windows_msix_assets.ps1 +++ b/scripts/generate_windows_msix_assets.ps1 @@ -1,9 +1,5 @@ #!/usr/bin/env pwsh -# Windows MSIX Asset Generator -# Renders the tile, store and splash PNGs the Store manifest references from -# assets/plezy.png, for windows/msix/assets. The results are committed and -# windows/build-msix.ps1 only copies them, so packaging stays runnable without -# image tooling; regenerating is only needed when the app icon changes. +# Generate committed MSIX assets from assets/plezy.png. $ErrorActionPreference = "Stop" @@ -16,9 +12,7 @@ Add-Type -AssemblyName System.Drawing $Source = Resolve-Path "assets\plezy.png" $OutputDir = Join-Path $ProjectRoot "windows\msix\assets" -# Sizes and names come from the uap:VisualElements attributes in the manifest -# template; a rename here has to be made there too. The two non-square targets -# letterbox the square icon on transparent padding rather than stretching it. +# Names and dimensions mirror the manifest's VisualElements. $Targets = @( @{ Name = "Square44x44Logo.png"; Width = 44; Height = 44 } @{ Name = "Square150x150Logo.png"; Width = 150; Height = 150 } @@ -26,25 +20,19 @@ $Targets = @( @{ Name = "Wide310x150Logo.png"; Width = 310; Height = 150 } @{ Name = "Square310x310Logo.png"; Width = 310; Height = 310 } @{ Name = "SplashScreen.png"; Width = 620; Height = 300 } - # The base logos are scale-100. Without a 200 the shell upscales them on the - # high-DPI displays most laptops ship with. + # Scale-200 variants avoid shell upscaling on high-DPI displays. @{ Name = "Square44x44Logo.scale-200.png"; Width = 88; Height = 88 } @{ Name = "Square150x150Logo.scale-200.png"; Width = 300; Height = 300 } ) -# The taskbar, task view and Alt-Tab draw the small logo on a plate filled with -# BackgroundColor, and a transparent background leaves the shell painting the -# user's accent colour behind the icon. An altform-unplated variant is the only -# way to suppress that plate; lightunplated is its light-theme counterpart. -# These resolve through resources.pri, which windows/build-msix.ps1 builds - as -# plain payload files they are inert. +# Resources.pri resolves these unplated variants; plain payload files are inert. foreach ($Size in 16, 24, 32, 48, 256) { foreach ($Form in "", "_altform-unplated", "_altform-lightunplated") { $Targets += @{ Name = "Square44x44Logo.targetsize-${Size}${Form}.png"; Width = $Size; Height = $Size } } } -# Regenerate from scratch so a renamed target cannot leave an orphan behind. +# Remove stale outputs when targets are renamed. if (Test-Path $OutputDir) { Remove-Item (Join-Path $OutputDir "*.png") -Force } New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null @@ -58,9 +46,7 @@ try { try { $Graphics = [System.Drawing.Graphics]::FromImage($Canvas) try { - # SourceCopy keeps the icon's own alpha instead of blending it - # into the canvas; the manifest declares a transparent tile - # background and expects the padding to stay transparent. + # Preserve alpha for the manifest's transparent tile background. $Graphics.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy $Graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic $Graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality diff --git a/scripts/run_maestro.py b/scripts/run_maestro.py index 7b6bd77a..4504c44d 100755 --- a/scripts/run_maestro.py +++ b/scripts/run_maestro.py @@ -578,12 +578,8 @@ class MaestroRunner: check=False, quiet=True, ) - # Maestro waits for the view hierarchy to settle after every tap, input, - # and key press. With animations at their default 1.0 scale each of - # those waits pays for a real transition, which dominates a flow: taps - # measured 3-5s apiece on a physical Pixel 7. CI's emulator gets this - # from the runner's disable-animations flag; nothing was setting it for - # a real device. cleanup() restores the captured values. + # Disable animations to avoid Maestro's per-action transition waits; + # cleanup() restores the captured settings. for key in ANIMATION_SCALES: self._adb_run("shell", "settings", "put", "global", key, "0", check=False, quiet=True) self._adb_run("shell", "input", "keyevent", "KEYCODE_BACK", check=False, quiet=True) @@ -621,7 +617,7 @@ class MaestroRunner: ) as response: output.write(response.read().decode(errors="replace")) output.write("\n") - except Exception as error: # Diagnostics must not hide the original failure. + except Exception as error: # Keep diagnostics from masking the failure. output.write(f"health_error={error}\n") if self.container_name: diff --git a/scripts/run_maestro_ci.py b/scripts/run_maestro_ci.py index 0cb2bab5..6c77a2bc 100755 --- a/scripts/run_maestro_ci.py +++ b/scripts/run_maestro_ci.py @@ -15,9 +15,8 @@ ANDROID_15_INSTRUMENTATION_CLASSES = ( "com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest" ) ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation" -# Kept separate from the suites above: only one build type can host androidTest, and -# those suites drive media3 builder APIs the app itself never calls, which R8 shrinks -# legitimately. This class asserts only name-based reachability (#1703). +# Separate R8 reachability from instrumentation: these suites exercise APIs the app +# does not call directly, so shrinking them is expected. Covers name-based reachability (#1703). ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest" ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability" @@ -126,9 +125,7 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = { "android-9": ( ( "basic", - # API 28's emulator routing to the 10.0.2.2 host alias is unreliable - # on this image, so reach Jellyfin over an adb reverse mapping the - # way the media suite already does. + # API 28 uses adb reverse because 10.0.2.2 routing is unreliable here. "--adb-reverse", "--flow", ".maestro/flows/05_playback.yaml", @@ -138,13 +135,7 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = { "build/maestro-legacy/diagnostics", ), ), - # Real Android TV hardware only, so no workflow dispatches it. The three - # `tv` regressions above run on a phone emulator that `onboard_jellyfin_tv` - # forces into TV mode; this drives the rail layout a device reports on its - # own. The D-pad-only path is also the only way to reach the TV number - # spinner, which InputModeTracker hides as soon as a tap arrives. Run as - # `python3 scripts/run_maestro_ci.py android-tv-device` with - # MAESTRO_DEVICE_ID set to the box. + # Manual Android TV hardware target; workflow runs phone-emulator TV regressions. "android-tv-device": ( ( "basic", @@ -201,12 +192,8 @@ def run_android_15_instrumentation() -> None: def run_android_r8_reachability() -> None: print("==> Android R8 reachability", flush=True) - # The `minified` build type runs R8 over the app under test, so a keep rule that stops - # covering a reflective lookup, a JNI callback or a native library load fails here - # instead of shipping. No other gate in this repository runs R8 at all. - # - # compileFlutterBuildMinified is deliberately not excluded: CI only prebuilds the - # debug APK, so this variant has no Flutter outputs to reuse. + # Run minified instrumentation to catch reflective, JNI, and native-load keep + # regressions. Do not exclude the Flutter build; CI only prebuilds debug. run_maestro._run_checked( ( "android/gradlew", diff --git a/scripts/run_tests.sh b/scripts/run_tests.sh index 71bb27dc..7ef51ae9 100755 --- a/scripts/run_tests.sh +++ b/scripts/run_tests.sh @@ -1,41 +1,17 @@ #!/usr/bin/env bash set -uo pipefail -# Run the Flutter test suite with a concurrency that matches the host. -# -# `flutter test` defaults to ceil(numCPUs / 2), which leaves half the machine -# idle. That default is a poor fit here because roughly three quarters of this -# suite's cost is per-file Dart kernel compilation rather than test execution -# (436 test files, each its own isolate), and compilation scales with cores. -# -# Measured on an 8-core host, full suite: -# -j 4 (the default) 190s -# -j 6 168s -# -j 8 136s -# -j 12 165s -# -# One job per core wins; oversubscribing regresses. So scale to the core count -# instead of hard-coding a number that would oversubscribe smaller CI runners. -# -# Any arguments are forwarded to `flutter test`, and an explicit -j/--concurrency -# still overrides the computed value. +# Match Flutter test concurrency to the host. Kernel compilation dominates this +# suite, and one job per available core outperformed the default on CI hosts. +# Explicit -j/--concurrency arguments are forwarded unchanged. SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" -# Overridable so scripts/test_run_tests.py can point the detector at fixtures. +# Overridable for scripts/test_run_tests.py fixtures. : "${PLEZY_CGROUP_ROOT:=/sys/fs/cgroup}" -# Cores this process may actually use. -# -# Three limits can each be the binding one, and a container can hit any subset -# of them: a generous CPU quota paired with a narrow cpuset is as common as the -# reverse. Taking whichever is discovered first would oversubscribe whenever a -# different one binds, so collect them all and use the smallest. -# -# cgroup v2 quota cpu.max (" ", or "max" when unlimited) -# cgroup v1 quota cpu.cfs_quota_us / cpu.cfs_period_us (-1 when unlimited) -# affinity/cpuset reported by nproc, which honours sched_getaffinity +# Use the smallest available limit: cgroup quota, legacy quota, or affinity. online_cpus() { if command -v nproc >/dev/null 2>&1; then nproc 2>/dev/null && return @@ -46,7 +22,7 @@ online_cpus() { getconf _NPROCESSORS_ONLN 2>/dev/null } -# ceil(quota / period), skipped unless both are positive integers. +# Return ceil(quota / period) for positive numeric values. quota_cpus() { local quota="$1" period="$2" case "$quota$period" in @@ -82,7 +58,7 @@ detect_cpus() { fi fi - # Nothing readable anywhere: prefer a conservative guess over the host count. + # Nothing readable: prefer a conservative default. if [ "${#limits[@]}" -eq 0 ]; then echo 4 return @@ -96,7 +72,7 @@ detect_cpus() { echo "$smallest" } -# Sourced by the tests to exercise the detector; only a direct run continues. +# Tests source this file to exercise the detector. if [ "${BASH_SOURCE[0]}" != "$0" ]; then return 0 fi diff --git a/server/artifact_store.go b/server/artifact_store.go index a2caf70e..16b8f9c9 100644 --- a/server/artifact_store.go +++ b/server/artifact_store.go @@ -14,14 +14,6 @@ import ( "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 @@ -117,8 +109,8 @@ func validID(id string, length int) bool { 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. +// pendingRemoval tracks an artifact that could not be deleted. Size is unknown +// when stat fails or the path is not a regular file. type pendingRemoval struct { size int64 sizeKnown bool @@ -140,27 +132,22 @@ type artifactStore struct { maxAge time.Duration removeFile func(string) error - // Policy. generateID is a field so tests can force ID collisions. + // Policy hooks and quota behavior. 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 returns the content type for a usable on-disk artifact. 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 uses cost units: one per log, bytes per poster. 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 evicts oldest entries instead of rejecting when full. evictToFit bool - // retryKnownDebtOnPut retries only debt whose size is accounted, leaving - // unknown debt to periodic cleanup. + // retryKnownDebtOnPut retries only size-accounted pending removals. retryKnownDebtOnPut bool errFull error - // Pending removals whose cost is not accounted (pendingCost returns 0) are - // tracked only by their pendingRemovals entry; no separate counter exists. + // Unaccounted pending removals are tracked only in pendingRemovals. used int64 // accounted cost of live entries pendingDebt int64 // accounted cost of pending removals startupErr error @@ -170,11 +157,11 @@ type artifactStore struct { 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() @@ -230,7 +217,7 @@ func (as *artifactStore) loadExisting(now time.Time) error { return removalErr } -// put writes data as `` once the quota allows it. +// put writes data to a quota-approved `` file. func (as *artifactStore) put(data []byte, ext, contentType string, now time.Time) (string, artifactEntry, error) { as.mu.Lock() defer as.mu.Unlock() @@ -287,9 +274,7 @@ func (as *artifactStore) put(data []byte, ext, contentType string, now time.Time 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. +// lookupEntry returns a live matching entry, deleting it if expired. func (as *artifactStore) lookupEntry( id string, now time.Time, @@ -363,8 +348,8 @@ func (as *artifactStore) deleteEntryLocked(id string) error { return nil } -// removeUntrackedLocked deletes a file the index does not own, recording it as -// pending debt when the removal fails. +// removeUntrackedLocked deletes an unindexed file and records failed removal +// as pending debt. 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) diff --git a/server/main.go b/server/main.go index 3e1bf1cb..bd3708f2 100644 --- a/server/main.go +++ b/server/main.go @@ -39,7 +39,7 @@ const ( httpResponseWriteTimeout = oauthResultWait + httpResponseWriteMargin pongWait = 60 * time.Second pingInterval = 30 * time.Second - maxLogSize = 1 * 1024 * 1024 // 1MB + maxLogSize = 1 * 1024 * 1024 logMaxAge = 3 * 24 * time.Hour logIDLength = 5 logRateInterval = 1 * time.Minute @@ -49,7 +49,7 @@ const ( maxFailedLogLookupSources = 4096 maxConcurrentLogLookups = 32 maxHTTPHeaderBytes = 64 * 1024 - maxPosterSize = 5 * 1024 * 1024 // 5MB + maxPosterSize = 5 * 1024 * 1024 maxPosterStoreSize = int64(1 * 1024 * 1024 * 1024) posterMaxAge = 3 * time.Hour posterIDLength = 16 @@ -78,7 +78,6 @@ var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true }, } -// --- Messages --- type clientMsg struct { Type string `json:"type"` @@ -104,7 +103,6 @@ type serverMsg struct { Payload json.RawMessage `json:"payload,omitempty"` } -// --- Client (serializes writes to a single goroutine) --- type outboundFrame struct { data []byte @@ -216,7 +214,6 @@ func (c *Client) close() { }) } -// --- Room --- type reconnectVerifier [sha256.Size]byte @@ -241,11 +238,8 @@ type Room struct { LastActivityAt time.Time } -// --- Snapshot types (on-disk JSON format) --- -// Nanosecond timestamps preserve exact absence state without the expansion of -// RFC3339 strings at the maximum admitted reservation count. Zero means the -// peer was connected when the snapshot was captured. +// Nanosecond timestamps preserve exact absence state; zero means connected. type peerReservationSnapshot struct { Verifier string `json:"verifier"` AbsentSinceUnixNano int64 `json:"absentSince,omitempty"` @@ -305,8 +299,8 @@ func reconnectVerifierMatches(expected, presented reconnectVerifier) bool { return subtle.ConstantTimeCompare(expected[:], presented[:]) == 1 } -// pruneExpiredPeerReservationsLocked removes only expired, disconnected guest -// reservations. The caller must hold room.mu. +// pruneExpiredPeerReservationsLocked removes expired, disconnected guest +// reservations; the caller must hold room.mu. func pruneExpiredPeerReservationsLocked(room *Room, now time.Time) bool { changed := false for peerID, reservation := range room.peerReservations { @@ -417,14 +411,12 @@ func (r *Room) sendFrom(senderID string, sender *Client, targetID string, msg se return directedTargetFound } -// --- Log store --- 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. +// logStore rejects uploads when its artifact-count quota is full. type logStore struct { artifactStore rateLimit map[string]time.Time // IP -> last upload time @@ -521,12 +513,10 @@ func (ls *logStore) cleanup(now time.Time) error { return removalErr } -// --- Poster store --- var errPosterStoreFull = errors.New("poster store full") -// posterStore caps shared posters by accounted bytes and evicts the oldest to -// admit a new upload. +// posterStore evicts oldest artifacts to stay within its byte quota. type posterStore struct { artifactStore } @@ -649,7 +639,6 @@ func (ps *posterStore) lookup(filename string, now time.Time) (artifactEntry, bo }) } -// --- Snapshotter (single-writer, debounced, atomic disk persistence) --- var errSnapshotterStopped = errors.New("snapshot writer is stopped") @@ -726,9 +715,8 @@ func newSnapshotter(path string, build func() stateSnapshot) *snapshotter { return sn } -// recordMutation publishes a protected identity, membership, or reservation -// mutation to the single writer. Callers record after changing state and before -// releasing the lock that made the mutation visible. +// recordMutation publishes a mutation after the caller changes state and before +// releasing the lock that made it visible. func (sn *snapshotter) recordMutation() uint64 { sn.stateMu.Lock() if sn.stopped { @@ -742,9 +730,7 @@ func (sn *snapshotter) recordMutation() uint64 { return seq } -// recordTerminalMutation atomically publishes a protected mutation together -// with its outcome channel. A buffered result retains even an immediate write -// failure until the handler begins waiting. +// recordTerminalMutation publishes a mutation and its buffered outcome channel. func (sn *snapshotter) recordTerminalMutation( complete func(error) terminalMutationOutcome, ) *terminalMutationTicket { @@ -755,9 +741,7 @@ func (sn *snapshotter) recordTerminalMutation( if complete == nil { result <- terminalMutationOutcome{err: errSnapshotterStopped, deliver: true} } else { - // The caller still holds the protected state lock. Run the - // rollback continuation asynchronously so it can acquire the - // normal s.mu -> room.mu order after the caller unlocks. + // Run rollback asynchronously after the caller releases its state lock. go func() { result <- complete(errSnapshotterStopped) }() @@ -848,8 +832,7 @@ func (sn *snapshotter) run() { default: } } - // Drain tokens queued before capture. A mutation recorded after - // capture re-arms the channels and therefore requires a later write. + // Drain pre-capture tokens; later mutations re-arm the channels. select { case <-sn.trigger: default: @@ -870,9 +853,8 @@ func (sn *snapshotter) run() { } } -// write is the narrowly serialized storage entry retained for atomic-storage -// tests. Production mutations use writeNextGeneration so generation outcomes -// cannot bypass the single writer. +// write is retained for synchronous storage tests; production uses the single +// writer's writeNextGeneration. func (sn *snapshotter) write() error { sn.writeMu.Lock() defer sn.writeMu.Unlock() @@ -922,9 +904,7 @@ func (sn *snapshotter) writeNextGeneration() (bool, error) { } sn.stateMu.Unlock() - // Continuations are part of the writer barrier. In particular, a failed - // staged release rolls back and records its corrective generation before - // this writer can capture any queued later mutation. + // Continuations roll back failed releases before later mutations are captured. for _, terminal := range covered { outcome := terminalMutationOutcome{err: err, deliver: true} if terminal.complete != nil { @@ -1004,9 +984,8 @@ func (sn *snapshotter) persistAtomic(data []byte) error { os.Remove(tmpPath) return err } - // Rename is the commit boundary: the replacement is file-synced and - // non-torn. Parent-directory sync adds crash durability where supported, - // but its post-commit failure must not report the mutation as uncommitted. + // Rename commits the file-synced replacement. Directory-sync failure is + // warning-only after that boundary. if err := sn.syncDir(sn.dir); err != nil { sn.logDirSyncErr(err) } @@ -1038,7 +1017,7 @@ func (sn *snapshotter) flushAndStop(timeout time.Duration) error { return sn.stopErr } -// logWriteErr throttles pre-commit snapshot-write error spam to once per hour. +// logWriteErr throttles pre-commit snapshot errors to once per hour. func (sn *snapshotter) logWriteErr(err error) { sn.errMu.Lock() defer sn.errMu.Unlock() @@ -1049,8 +1028,7 @@ func (sn *snapshotter) logWriteErr(err error) { log.Printf("snapshot: write failed before rename commit: %v", err) } -// logDirSyncErr has an independent throttle so a degraded post-rename warning -// cannot suppress a later pre-commit persistence error. +// logDirSyncErr independently throttles post-rename directory-sync warnings. func (sn *snapshotter) logDirSyncErr(err error) { sn.dirErrMu.Lock() defer sn.dirErrMu.Unlock() @@ -1061,7 +1039,6 @@ func (sn *snapshotter) logDirSyncErr(err error) { log.Printf("snapshot: parent directory sync failed after rename commit: %v", err) } -// --- Server --- type removalErrorThrottle struct { mu sync.Mutex lastLog map[string]time.Time @@ -1157,9 +1134,8 @@ func newServer(logDir, stateFile, posterDir string, clientIPs clientIPResolver) return s } -// removeRoomLocked removes room only while it is still the authoritative map -// entry. The caller must hold s.mu. A current-process quota reservation follows -// the retained room and is returned exactly once by successful removal. +// removeRoomLocked removes only the authoritative entry and releases its +// current-process quota reservation once. func (s *Server) removeRoomLocked(sessionID string, room *Room) bool { if s.rooms[sessionID] != room { return false @@ -1171,18 +1147,14 @@ func (s *Server) removeRoomLocked(sessionID string, room *Room) bool { return true } -// buildSnapshot is the synchronous storage-test entry. Production capture uses -// captureSnapshot so the copied state and its covered generation share one -// ordering boundary. +// buildSnapshot is the synchronous test entry; production uses captureSnapshot. func (s *Server) buildSnapshot() stateSnapshot { snapshot, _ := s.captureSnapshot(func() uint64 { return 0 }) return snapshot } -// captureSnapshot freezes every durable room mutation under the established -// s.mu -> room.mu order, then captures the covered sequence while those locks -// remain held. A mutation is therefore either both present and covered, or -// neither present nor covered. Locks are released before marshal or disk I/O. +// captureSnapshot holds s.mu -> room.mu while copying state and its covered +// generation, then releases locks before marshal or I/O. func (s *Server) captureSnapshot(captureSequence func() uint64) (stateSnapshot, uint64) { s.mu.RLock() rooms := make([]*Room, 0, len(s.rooms)) @@ -1235,9 +1207,8 @@ func (s *Server) captureSnapshot(captureSequence func() uint64) (stateSnapshot, return snapshot, targetSeq } -// loadSnapshot restores rooms from disk on startup. The returned rewrite flag -// reports reservation migration, initialization, or pruning that must be -// persisted before serving. Missing/corrupt files still allow startup. +// loadSnapshot restores rooms. Missing or corrupt files allow startup; the +// rewrite flag requests persistence of migration or pruning. func (s *Server) loadSnapshot(path string) (bool, error) { data, err := os.ReadFile(path) if err != nil { @@ -1699,7 +1670,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { http.Error(w, "Invalid client address", http.StatusBadRequest) return } - // Retained-room ownership uses the same canonical source key as connection admission. + // Retained-room ownership uses the admission source key. quotaOwnerKey := ip if !s.conns.tryConnect(ip) { @@ -1740,9 +1711,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { return true } - // Cleanup on disconnect only when this client is still authoritative. A - // displaced client's defer must neither remove the replacement nor start - // its reservation's absence clock. + // Only the authoritative client may remove the room or start its absence clock. defer func() { if currentRoom != nil && currentPeerID != "" { currentRoom.mu.Lock() @@ -1883,10 +1852,8 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { } continue } - // A room nobody is connected to is an abandoned code, not property. - // Whoever asks for it next takes it, so a host that restarted with a - // fresh reconnect token can reuse its own code instead of waiting out - // the cleanup sweep. An occupied room still belongs to its peers. + // An empty room code is abandoned and may be reclaimed; occupied rooms + // remain owned by their peers. reclaimable := len(existing.Peers) == 0 && !existing.closing existing.mu.Unlock() if !reclaimable { @@ -2038,15 +2005,14 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { !occupied && room.quotaOwnerKey != "" && room.quotaOwnerKey == quotaOwnerKey: - // Tokenless host reconnect is retained only for unversioned rooms, - // only within this process, and only from the creating source. + // Tokenless host reconnect is limited to unversioned local rooms + // from the creating source. authorized = true responseToken = "" responseVerifier = room.hostVerifier } } else { - // Legacy guests have no durable proof. Never let one replace a live - // identity; disconnected identity reuse remains confined to legacy rooms. + // Legacy guests lack durable proof, so identity reuse is legacy-only. authorized = !occupied } @@ -2189,8 +2155,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { if room.LastActivityAt.Equal(leaveActivity) { room.LastActivityAt = previousActivity } - // The failed attempt captured the pending omission. Record - // the restored reservation before a queued later capture. + // Record the restored reservation before a queued later capture. s.snap.recordMutation() } room.mu.Unlock() @@ -2258,9 +2223,7 @@ func (s *Server) handleWS(w http.ResponseWriter, r *http.Request) { room.mu.Unlock() s.mu.Unlock() - // A successful outcome means the file-synced atomic rename committed. - // Supported filesystems also complete parent-directory sync before - // this barrier; post-rename sync degradation is warning-only. + // Atomic rename committed; post-rename directory-sync degradation is warning-only. outcome := s.snap.waitForDurable(ticket) if outcome.err != nil { client.sendJSON(serverMsg{Type: relayTypeError, Code: relayErrorInvalidMessage, Message: "Unable to persist ended room"}) diff --git a/server/main_test.go b/server/main_test.go index da049ed4..ced62d5e 100644 --- a/server/main_test.go +++ b/server/main_test.go @@ -49,9 +49,7 @@ func TestGeneratedRelayProtocolVersionsMatchSpec(t *testing.T) { } } -// newTestServer builds a Server wired for tests: no goroutines and no network. -// Its snapshotter is not started; storage tests drive the narrow synchronous -// write entry directly. +// newTestServer builds a goroutine-free, network-free test server. func newTestServer(t *testing.T, stateFile string) *Server { t.Helper() s := &Server{ @@ -135,7 +133,7 @@ func TestSnapshotRoundTrip(t *testing.T) { t.Fatalf("snapshot persisted process-local client identity: %s", data) } - // Reconstruct into a fresh Server and verify identity. + // Reload from disk into a fresh server. s2 := newTestServer(t, path) if _, err := s2.loadSnapshot(path); err != nil { t.Fatalf("loadSnapshot: %v", err) @@ -229,7 +227,7 @@ func TestLoadHandlesCorrupt(t *testing.T) { if len(s.rooms) != 0 { t.Fatalf("expected empty rooms after corrupt load, got %d", len(s.rooms)) } - // File should be preserved for debugging. + // Corrupt snapshots remain available for diagnosis. if _, err := os.Stat(path); err != nil { t.Fatalf("corrupt file should NOT be deleted: %v", err) } @@ -270,7 +268,7 @@ func TestCleanupUsesIdleNotAge(t *testing.T) { s := newTestServer(t, filepath.Join(t.TempDir(), "rooms.json")) now := time.Now() - // 2h-old room that has activity 1min ago — must NOT be cleaned up. + // Recent activity keeps this old room. s.rooms["KEEP"] = &Room{ SessionID: "KEEP", HostPeerID: "h", @@ -278,7 +276,7 @@ func TestCleanupUsesIdleNotAge(t *testing.T) { CreatedAt: now.Add(-2 * time.Hour), LastActivityAt: now.Add(-1 * time.Minute), } - // 2h-old room that emptied 10min ago — MUST be cleaned up. + // Idle rooms are removed. s.rooms["GONE"] = &Room{ SessionID: "GONE", HostPeerID: "h", @@ -286,11 +284,11 @@ func TestCleanupUsesIdleNotAge(t *testing.T) { CreatedAt: now.Add(-2 * time.Hour), LastActivityAt: now.Add(-10 * time.Minute), } - // 25h-old room — absolute TTL nukes it even if recently active. + // The absolute TTL removes this room despite recent activity. s.rooms["OLD"] = &Room{ SessionID: "OLD", HostPeerID: "h", - Peers: map[string]*Client{}, // empty anyway + Peers: map[string]*Client{}, CreatedAt: now.Add(-25 * time.Hour), LastActivityAt: now.Add(-10 * time.Second), } @@ -352,7 +350,7 @@ func TestSnapshotAtomicWriteSurvivesRenameFailure(t *testing.T) { path := filepath.Join(dir, "rooms.json") s := newTestServer(t, path) - // Seed a valid snapshot on disk. + // Seed the on-disk snapshot. s.rooms["ORIG"] = &Room{ SessionID: "ORIG", HostPeerID: "h", @@ -368,8 +366,7 @@ func TestSnapshotAtomicWriteSurvivesRenameFailure(t *testing.T) { t.Fatalf("read orig: %v", err) } - // Block the temporary-file open with a directory at the same path. This - // deterministically fails before rename on every supported platform. + // A directory at the temporary path makes the open fail before rename. if err := os.Mkdir(path+".tmp", 0755); err != nil { t.Fatalf("create blocking temporary directory: %v", err) } @@ -598,7 +595,7 @@ func TestSnapshotDebounceCoalesces(t *testing.T) { go sn.run() t.Cleanup(func() { _ = sn.flushAndStop(time.Second) }) - // Fire a burst — should collapse into one write due to debounce. + // A burst should collapse into one debounced write. for i := 0; i < 20; i++ { sn.recordMutation() } @@ -1132,11 +1129,6 @@ func TestSnapshotDirectorySyncWarningIsThrottled(t *testing.T) { } } -// ====================================================================== -// Integration harness — boots a real Server behind httptest with the full -// HTTP mux. Each dial sets X-Forwarded-For so tests control the perceived -// client IP independently of the rate limiters. -// ====================================================================== type relayHarness struct { srv *Server @@ -1171,8 +1163,7 @@ func newRelayHarnessNoTrust(t *testing.T) *relayHarness { ) } -// newRelayHarnessAt lets a test control the stateFile path so two harnesses -// can share a snapshot across a simulated restart. +// newRelayHarnessAt allows two harnesses to share a snapshot across restarts. func newRelayHarnessAt(t *testing.T, logDir, stateFile string) *relayHarness { t.Helper() return newRelayHarnessAtWithResolver(t, logDir, stateFile, mustClientIPResolver(t, "127.0.0.0/8")) @@ -1501,8 +1492,7 @@ func (c *testConn) expectAuthority(typ, hostPeerID string) serverMsg { return message } -// recvNothing asserts no message arrives within the given window. Used to -// verify silent paths (sender not receiving own broadcast, stale-peer skip). +// recvNothing asserts that no frame arrives within the window. func (c *testConn) recvNothing(within time.Duration) { c.t.Helper() c.conn.SetReadDeadline(time.Now().Add(within)) @@ -1515,8 +1505,7 @@ func (c *testConn) recvNothing(within time.Duration) { } } -// recvUntilClosed consumes any frames already queued on the wire and requires -// a permanent terminal read error before the absolute deadline. +// recvUntilClosed drains queued frames and waits for terminal closure. func (c *testConn) recvUntilClosed(within time.Duration) ([]serverMsg, error) { c.t.Helper() if err := c.conn.SetReadDeadline(time.Now().Add(within)); err != nil { @@ -1681,9 +1670,6 @@ func TestClientWriteFailureClosesConnection(t *testing.T) { client.close() } -// ====================================================================== -// Unit tests — pure logic -// ====================================================================== func TestRateLimiterBurstExhausts(t *testing.T) { rl := newRateLimiter(5, 10) @@ -1698,7 +1684,7 @@ func TestRateLimiterBurstExhausts(t *testing.T) { } func TestRateLimiterRefillsOverTime(t *testing.T) { - rl := newRateLimiter(5, 10) // 10 tokens/sec + rl := newRateLimiter(5, 10) for i := 0; i < 5; i++ { rl.allow() } @@ -1734,8 +1720,7 @@ func TestRateLimiterAllowRace(t *testing.T) { }() } wg.Wait() - // Real assertion is that -race finds no data race. Spot-check the - // result is within plausible bounds. + // Spot-check the result bounds; -race checks synchronization. if got := successes.Load(); got <= 0 || got > 500 { t.Fatalf("unexpected successes count %d (want 1..500)", got) } @@ -1775,9 +1760,6 @@ func TestCleanupRateWindowsUsesWindowBoundary(t *testing.T) { } } -// ====================================================================== -// connTracker unit tests -// ====================================================================== func TestConnTrackerPerIPLimit(t *testing.T) { ct := newConnTracker() @@ -1821,7 +1803,7 @@ func TestConnTrackerDisconnectFrees(t *testing.T) { t.Errorf("globalCount=%d, want 0", ct.globalCount) } ct.mu.Unlock() - // Extra disconnect is a no-op (doesn't panic). + // Extra disconnect is a no-op. ct.disconnect(ip) } @@ -1881,9 +1863,7 @@ func TestConnTrackerConnectRateLimit(t *testing.T) { t.Fatalf("warmup tryConnect %d: expected true", i) } } - // Free one slot so the perIP check won't be what rejects us. - ct.disconnect(ip) - // Rate-limit bucket is empty now; this should be the denial path. + // Free a slot so the next denial comes from the rate limiter. if ct.tryConnect(ip) { t.Fatal("expected false from rate-limit bucket, not per-IP cap") } @@ -1990,9 +1970,6 @@ func TestPosterUploadLimiterAdmissionPolicy(t *testing.T) { }) } -// ====================================================================== -// clientIPResolver unit tests -// ====================================================================== func TestClientIPResolverTrustChains(t *testing.T) { tests := []struct { @@ -2088,12 +2065,8 @@ func TestParseTrustedProxyCIDRs(t *testing.T) { } } -// ====================================================================== -// generateLogID -// ====================================================================== -// Random ids may legitimately repeat, so shape is the only contract here; -// collision retry is covered deterministically by +// Random IDs may repeat; this test checks shape. Collision retry is covered by // TestLogStorePersistsAcrossRestartAndAvoidsIDCollisions. func TestGenerateLogIDShape(t *testing.T) { for range 200 { @@ -2109,9 +2082,6 @@ func TestGenerateLogIDShape(t *testing.T) { } } -// ====================================================================== -// handleWS — create case -// ====================================================================== func TestCreateSucceeds(t *testing.T) { h := newRelayHarness(t) @@ -2144,7 +2114,7 @@ func TestCreateDuplicateReturnsRoomExists(t *testing.T) { c1.send(clientMsg{Type: "create", SessionID: "SAME", PeerID: "host-1"}) c1.expect("created") - // Different IP to avoid the per-IP rooms quota interfering. + // Use a different IP so the rooms quota does not interfere. c2 := h.dial(t, "1.1.1.5") c2.send(clientMsg{Type: "create", SessionID: "SAME", PeerID: "host-2"}) c2.expectError("room_exists") @@ -2308,8 +2278,7 @@ func TestCreateReclaimsAbandonedEmptyRoom(t *testing.T) { t.Fatal("abandoned room identity survived the reclaim") } - // The previous owner's capability died with the room it belonged to, and - // the live replacement is not reclaimable by anyone, owner included. + // The former capability cannot reclaim the live replacement. former := h.dial(t, "1.1.1.60") former.send(clientMsg{ Type: relayTypeCreate, @@ -2321,9 +2290,7 @@ func TestCreateReclaimsAbandonedEmptyRoom(t *testing.T) { former.expectError(relayErrorRoomExists) } -// The recent-rooms flow: a host restarts its app, so it presents a fresh -// reconnect capability for a code the relay still holds. The abandoned code -// must come back as a hosted room instead of a ghost room with no host. +// A restarted host presents a fresh capability for an abandoned room code. func TestAbandonedCodeIsRecreatableByARestartedHost(t *testing.T) { h := newRelayHarness(t) firstToken, _ := mustReconnectToken(t) @@ -2339,8 +2306,7 @@ func TestAbandonedCodeIsRecreatableByARestartedHost(t *testing.T) { host.conn.Close() h.waitRoomPeers(t, "REUSE", 0) - // A restarted app mints a new capability, so it cannot prove the previous - // ownership even when it reuses its own peer ID. + // A fresh capability cannot prove previous ownership, even with the same peer ID. restartToken, _ := mustReconnectToken(t) restarted := h.dial(t, "6.4.0.2") restarted.send(clientMsg{ @@ -2423,7 +2389,7 @@ func TestCreateHitsRoomsPerIPLimit(t *testing.T) { c.send(clientMsg{Type: "create", SessionID: fmt.Sprintf("R%d", i), PeerID: "host"}) c.expect("created") } - // 4th create from same IP exceeds the quota. + // The fourth room from this IP exceeds the quota. c := h.dial(t, ip) c.send(clientMsg{Type: "create", SessionID: "ROVERFLOW", PeerID: "host"}) c.expectError("rate_limited") @@ -2782,9 +2748,6 @@ func TestConnectionCannotRetainMultipleRoomMemberships(t *testing.T) { } } -// ====================================================================== -// handleWS — join case -// ====================================================================== func TestJoinSucceedsAndBroadcastsPeerJoined(t *testing.T) { h := newRelayHarness(t) @@ -3345,9 +3308,6 @@ func TestJoinAdmissionIsAtomicWithReservedRoomCreate(t *testing.T) { } } -// ====================================================================== -// handleWS — broadcast / sendTo -// ====================================================================== func TestBroadcastDeliversToOthersNotSender(t *testing.T) { h := newRelayHarness(t) @@ -3381,7 +3341,7 @@ func TestBroadcastDeliversToOthersNotSender(t *testing.T) { t.Errorf("g2 From=%q want G1", g2Msg.From) } - // Sender should not receive its own broadcast. + // Broadcasts exclude the sender. g1.recvNothing(200 * time.Millisecond) } @@ -3490,9 +3450,6 @@ func TestSendToNotInRoomRejected(t *testing.T) { c.expectError("not_in_room") } -// ====================================================================== -// handleWS — ping / misc / rate limits -// ====================================================================== func TestPingReturnsPong(t *testing.T) { h := newRelayHarness(t) @@ -3521,7 +3478,7 @@ func TestPerConnectionMessageRateLimit(t *testing.T) { c.send(clientMsg{Type: "create", SessionID: "RL", PeerID: "H"}) c.expect("created") - // The per-connection bucket is rateBurst=30. After ~30 pings we start seeing rate_limited. + // Exceed the per-connection bucket and observe rate limiting. sawRateLimit := false for i := 0; i < rateBurst+10; i++ { c.send(clientMsg{Type: "ping"}) @@ -3538,9 +3495,6 @@ func TestPerConnectionMessageRateLimit(t *testing.T) { } } -// ====================================================================== -// handleWS — disconnect lifecycle -// ====================================================================== func TestDisconnectBroadcastsPeerLeft(t *testing.T) { h := newRelayHarness(t) @@ -5364,9 +5318,7 @@ func TestHostEndDeliversEndedAfterConcurrentGuestTraffic(t *testing.T) { t.Fatal("ending room remained discoverable before terminal delivery") } - // WebSocket frames are processed in order. Receiving pong proves the - // preceding membership-sensitive traffic was handled while ended delivery - // was blocked, without closing the guest as a stale client. + // Ordered frames prove membership traffic completed while ended delivery waited. guest.send(clientMsg{ Type: relayTypeBroadcast, Payload: json.RawMessage(`{"during":"end"}`), @@ -5543,9 +5495,6 @@ func TestCleanupDisconnectsPeersBeforeRemovingExpiredOccupiedRoom(t *testing.T) } } -// ====================================================================== -// Logs endpoints -// ====================================================================== func postLog(t *testing.T, baseURL, ip string, body []byte) *http.Response { t.Helper() @@ -5579,8 +5528,6 @@ func getLog(t *testing.T, baseURL, ip, id string) *http.Response { return resp } -// postLogAndGetID uploads a log and returns the generated id, asserting the -// POST succeeded. func postLogAndGetID(t *testing.T, baseURL, ip string, body []byte) string { t.Helper() resp := postLog(t, baseURL, ip, body) @@ -6082,7 +6029,7 @@ func TestLogsUploadDoesNotWriteCapabilityToOperationalLog(t *testing.T) { func TestLogStoreRetiresLegacyCapabilitiesOnStartup(t *testing.T) { dir := t.TempDir() now := time.Now().Add(-time.Minute) - legacyID := strings.Repeat("a", 25) // capability shape used before ids went back to logIDLength + legacyID := strings.Repeat("a", 25) // legacy capability length currentID := strings.Repeat("a", logIDLength) legacyPath := filepath.Join(dir, legacyID+".log") currentPath := filepath.Join(dir, currentID+".log") @@ -6365,7 +6312,6 @@ func TestLogsGetExpiredIs404(t *testing.T) { h := newRelayHarness(t) id := postLogAndGetID(t, h.baseURL, "7.3.0.1", []byte("temp")) - // Poison the entry's ExpiresAt into the past. h.srv.logs.mu.Lock() entry := h.srv.logs.entries[id] entry.ExpiresAt = time.Now().Add(-time.Minute) @@ -6394,9 +6340,6 @@ func TestLogsMethodNotAllowed(t *testing.T) { } } -// ====================================================================== -// Poster endpoints -// ====================================================================== var minimalPNG = []byte{0x89, 'P', 'N', 'G', 0x0d, 0x0a, 0x1a, 0x0a, 0x01, 0x02, 0x03} @@ -7687,9 +7630,6 @@ func TestRemovalFailureLogDoesNotExposeCapabilityPath(t *testing.T) { } } -// ====================================================================== -// End-to-end: rooms survive a process restart -// ====================================================================== func TestSnapshotSurvivesRestartWithHostAuthority(t *testing.T) { stateFile := filepath.Join(t.TempDir(), "rooms.json") diff --git a/server/oauth.go b/server/oauth.go index c6ab3761..b8ac4202 100644 --- a/server/oauth.go +++ b/server/oauth.go @@ -1,10 +1,7 @@ package main -// OAuth proxy: relays MAL + AniList authorization-code flows for devices that -// can't listen on localhost (TVs) or lack a browser (headless set-top boxes -// pair via a phone QR scan). Sessions live in memory for 10 minutes; AniList's -// client secret lives only in env vars. Access tokens transit the server -// briefly during code→token exchange and are never logged or persisted. +// OAuth proxy for TV/headless clients. Sessions remain in memory for 10 minutes; +// client secrets are environment-only and tokens are never logged or persisted. import ( "context" @@ -31,14 +28,13 @@ const ( oauthMaxSessions = 5000 oauthStartBurst = 3 oauthStartRateSustained = 1 - oauthBrowserStateBytes = 18 // 144 bits → 24 base64url chars - oauthPollSecretBytes = 18 // Independently generated device capability. + oauthBrowserStateBytes = 18 // 144 bits, 24 base64url characters + oauthPollSecretBytes = 18 // independent device capability oauthPKCEVerifierLen = 64 oauthUpstreamTimeout = 15 * time.Second ) -// oauthServiceConfig describes a single upstream OAuth provider. Populated from -// env vars in oauthConfigFromEnv. A service with an empty ClientID is disabled. +// oauthServiceConfig describes one configured upstream provider. type oauthServiceConfig struct { ClientID string ClientSecret string // empty ⇒ provider doesn't issue/require one (MAL w/ PKCE) @@ -56,10 +52,8 @@ type oauthTokenResult struct { Error string `json:"error,omitempty"` } -// oauthSession is created by /auth/start and lives until its result is claimed -// or it is removed by cleanup. browserState crosses the browser/provider trust -// boundary. Only the SHA-256 digest of the device-only poll secret is retained. -// When both locks are needed, oauthProxy.mu must be acquired before s.mu. +// oauthSession links browser state to a device-only poll capability. Only the +// poll secret digest is retained; acquire oauthProxy.mu before s.mu. type oauthSession struct { browserState string pollDigest [sha256.Size]byte @@ -72,7 +66,7 @@ type oauthSession struct { completed bool result *oauthTokenResult - // Test seam used to deterministically seat concurrent result waiters. + // Test seam for deterministic concurrent waiter tests. waitStarted func() } @@ -88,8 +82,8 @@ func (s *oauthSession) completeLocked(r oauthTokenResult) bool { return true } -// wait blocks only until the session is ready or ctx is cancelled. Result -// ownership is transferred separately by oauthProxy.claimResult. +// wait blocks until the session is ready or ctx is cancelled. Result ownership +// transfers separately through oauthProxy.claimResult. func (s *oauthSession) wait(ctx context.Context) error { if s.waitStarted != nil { s.waitStarted() @@ -109,7 +103,7 @@ func (s *oauthSession) pkceVerifier() string { } type oauthProxy struct { - baseURL string // e.g. https://ice.plezy.app + baseURL string services map[string]oauthServiceConfig client *http.Client clientIPs clientIPResolver @@ -134,9 +128,7 @@ func newOAuthProxy(baseURL string, services map[string]oauthServiceConfig, clien } } -// oauthConfigFromEnv reads the public base URL and per-service creds from the -// environment. Returns (nil, false) if OAUTH_BASE_URL is unset — the caller -// wires this as "OAuth disabled, endpoints return 503". +// oauthConfigFromEnv returns disabled when OAUTH_BASE_URL is unset. func oauthConfigFromEnv(clientIPs clientIPResolver) (*oauthProxy, bool) { base := os.Getenv("OAUTH_BASE_URL") if base == "" { @@ -163,8 +155,7 @@ func oauthConfigFromEnv(clientIPs clientIPResolver) (*oauthProxy, bool) { return newOAuthProxy(base, services, clientIPs), true } -// registerOAuthRoutes registers all /auth/* handlers. If p is nil (no env -// config), all paths 503 so the integration page clearly says "not configured". +// registerOAuthRoutes mounts /auth/*; a nil proxy returns 503. func registerOAuthRoutes(mux *http.ServeMux, p *oauthProxy) { if p == nil { mux.HandleFunc("/auth/", func(w http.ResponseWriter, r *http.Request) { @@ -178,8 +169,7 @@ func registerOAuthRoutes(mux *http.ServeMux, p *oauthProxy) { mux.HandleFunc("/auth/", p.handleAuthRoot) } -// handleAuthRoot dispatches /auth/... paths that aren't served by their own -// registered handler. Covers /auth/:service and /auth/:service/callback. +// handleAuthRoot dispatches /auth/:service and /auth/:service/callback. func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) { rest := strings.TrimPrefix(r.URL.Path, "/auth/") parts := strings.SplitN(rest, "/", 2) @@ -199,8 +189,7 @@ func (p *oauthProxy) handleAuthRoot(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) } -// POST /auth/start body={"service":"mal"|"anilist"} -// Response: {"session":"device-only poll capability","url":"https://.../auth/:service?state=...","expiresIn":600} +// POST /auth/start returns a device poll capability and authorization URL. func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store, private") if r.Method != http.MethodPost { @@ -230,8 +219,7 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) { return } - // Generate independent trust-domain values outside the map lock — - // crypto/rand syscalls must not serialize concurrent /auth/start calls. + // Generate trust-domain values outside the map lock; crypto/rand can block. var pollSecret string var sess *oauthSession for { @@ -270,7 +258,7 @@ func (p *oauthProxy) handleStart(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, resp) } -// GET /auth/:service?state=X → 302 upstream authorize URL +// GET /auth/:service?state=X redirects to the upstream authorize URL. func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, service string) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -306,7 +294,7 @@ func (p *oauthProxy) handleAuthorize(w http.ResponseWriter, r *http.Request, ser http.Redirect(w, r, cfg.AuthorizeURL+"?"+q.Encode(), http.StatusFound) } -// GET /auth/:service/callback?code=...&state=... → exchange, park, render page +// GET /auth/:service/callback exchanges the code and renders a result page. func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, service string) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -368,7 +356,7 @@ func (p *oauthProxy) handleCallback(w http.ResponseWriter, r *http.Request, serv renderSuccessPage(w) } -// GET /auth/result?session=X → long-poll, returns one terminal result. +// GET /auth/result?session=X long-polls for one terminal result. func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) { w.Header().Set("Cache-Control", "no-store, private") if r.Method != http.MethodGet { @@ -403,7 +391,7 @@ func (p *oauthProxy) handleResult(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusOK, result) } -// GET /auth/done — static success page (Simkl's redirect target). +// GET /auth/done renders the static OAuth success page. func (p *oauthProxy) handleDone(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet { http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) @@ -464,15 +452,13 @@ func (p *oauthProxy) redirectURI(service string) string { return fmt.Sprintf("%s/auth/%s/callback", p.baseURL, service) } -// addSessionLocked installs both independently generated keys as one logical -// session. The caller has already verified that neither key is live. +// addSessionLocked installs both keys for one session after collision checks. func (p *oauthProxy) addSessionLocked(sess *oauthSession) { p.browserStates[sess.browserState] = sess p.pollDigests[sess.pollDigest] = sess } -// removeSessionLocked removes only entries still owned by sess, so a stale -// callback or waiter cannot remove a replacement. +// removeSessionLocked removes only entries still owned by sess. func (p *oauthProxy) removeSessionLocked(sess *oauthSession) { if p.browserStates[sess.browserState] == sess { delete(p.browserStates, sess.browserState) @@ -510,7 +496,7 @@ func (p *oauthProxy) claimResult(digest [sha256.Size]byte, sess *oauthSession) ( return result, true } -// cleanup drops sessions past oauthSessionTTL. Called by the main cleanup loop. +// cleanup drops sessions past oauthSessionTTL. func (p *oauthProxy) cleanup() { now := time.Now() p.mu.Lock() @@ -539,7 +525,7 @@ func (p *oauthProxy) ipAllow(ip string) bool { const successPageHTML = `Signed in

Signed in to Plezy

You can close this tab and return to the app.

` -// Split around the message so CSS `%` literals don't collide with Fprintf verbs. +// Keep CSS percent literals outside Fprintf's format string. const errorPagePrefix = `Sign-in failed

Sign-in failed

` const errorPageSuffix = `

` @@ -570,15 +556,12 @@ func digestPollSecret(secret string) [sha256.Size]byte { func randToken(numBytes int) string { b := make([]byte, numBytes) if _, err := rand.Read(b); err != nil { - // crypto/rand failing is catastrophic; log.Fatalf matches the style - // in newLogStore for similar unrecoverable init failures. log.Fatalf("crypto/rand: %v", err) } return base64.RawURLEncoding.EncodeToString(b) } -// randPKCEVerifier returns a 64-char string from MAL's required alphabet -// (RFC 7636 §4.1 unreserved set). +// randPKCEVerifier returns MAL's RFC 7636 §4.1 verifier alphabet. func randPKCEVerifier() string { const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" b := make([]byte, oauthPKCEVerifierLen) diff --git a/server/oauth_test.go b/server/oauth_test.go index 7c0797c4..ca2f243f 100644 --- a/server/oauth_test.go +++ b/server/oauth_test.go @@ -15,9 +15,7 @@ import ( "time" ) -// mockUpstream runs an httptest server that impersonates MAL/AniList. It -// records the last token-exchange form submission and returns a canned -// access_token/refresh_token response. +// mockUpstream records token exchanges and returns a canned response. type mockUpstream struct { srv *httptest.Server mu sync.Mutex @@ -26,9 +24,7 @@ type mockUpstream struct { tokenCode int } -// httpGet / httpPost / httpDo wrap the stdlib calls to fail the test on error. -// Keeps test bodies one-liner without tripping `go vet`'s -// "using resp before checking errors" rule. +// Request helpers fail tests on client errors. func httpGet(t *testing.T, url string) *http.Response { t.Helper() resp, err := http.Get(url) @@ -65,7 +61,7 @@ func newMockUpstream(t *testing.T) *mockUpstream { m.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { switch r.URL.Path { case "/oauth/authorize": - // Unused in tests — we assert on the 302 Location from our proxy. + // Authorization is asserted on the proxy's redirect. w.WriteHeader(http.StatusOK) case "/oauth/token": if err := r.ParseForm(); err != nil { @@ -101,8 +97,7 @@ func (m *mockUpstream) form() url.Values { return m.lastForm } -// newOAuthHarness boots a relay-less httptest server that mounts /auth/* only, -// with `mal` and `anilist` services pointed at a shared mock upstream. +// newOAuthHarness mounts /auth/* against shared mock providers. type oauthHarness struct { proxy *oauthProxy srv *httptest.Server @@ -137,7 +132,7 @@ func newOAuthHarnessWithResolver(t *testing.T, clientIPs clientIPResolver) *oaut registerOAuthRoutes(mux, proxy) srv := httptest.NewServer(mux) t.Cleanup(srv.Close) - // Rewire baseURL to the real httptest URL so redirect_uri computes correctly. + // Use the real server URL when constructing redirect_uri. proxy.baseURL = srv.URL return &oauthHarness{proxy: proxy, srv: srv, base: srv.URL, upstream: up} } @@ -197,7 +192,6 @@ func postOAuthStart(t *testing.T, h *oauthHarness, service, xff string) *http.Re return httpDo(t, req) } -// ====== /auth/start ====== func TestOAuthStartSeparatesDeviceCapabilityFromBrowserState(t *testing.T) { h := newOAuthHarness(t) @@ -247,7 +241,7 @@ func TestOAuthStartRateLimitedPerIP(t *testing.T) { h := newOAuthHarness(t) ip := "5.5.5.5" for range oauthStartBurst { - _, _, _ = h.startSession(t, "mal", ip) // should all succeed + _, _, _ = h.startSession(t, "mal", ip) } body, _ := json.Marshal(map[string]string{"service": "mal"}) req, err := http.NewRequest(http.MethodPost, h.base+"/auth/start", bytes.NewReader(body)) @@ -364,7 +358,6 @@ func TestOAuthStartMethodNotAllowed(t *testing.T) { } } -// ====== /auth/:service (authorize redirect) ====== func TestOAuthAuthorizeMALRedirectIncludesPKCE(t *testing.T) { h := newOAuthHarness(t) @@ -439,7 +432,7 @@ func TestOAuthAuthorizeUnknownSessionRendersError(t *testing.T) { func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) { h := newOAuthHarness(t) _, browserState, _ := h.startSession(t, "mal", "1.1.1.3") - // Try to use the MAL browser state against the AniList authorize endpoint. + // A browser state is valid only for its original service. resp := httpGet(t, h.base+"/auth/anilist?state="+url.QueryEscape(browserState)) defer resp.Body.Close() if resp.StatusCode != http.StatusNotFound { @@ -447,7 +440,6 @@ func TestOAuthAuthorizeWrongServiceRejected(t *testing.T) { } } -// ====== /auth/:service/callback + /auth/result ====== type oauthResultResponse struct { status int @@ -693,7 +685,6 @@ func TestOAuthCallbackUnknownSessionIgnored(t *testing.T) { } } -// ====== Cleanup ====== func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.T) { h := newOAuthHarness(t) @@ -725,7 +716,6 @@ func TestOAuthCleanupRemovesBothIndexesAndSuppressesStaleCompletion(t *testing.T } } -// ====== /auth/done ====== func TestOAuthDoneRendersSuccessPage(t *testing.T) { h := newOAuthHarness(t) @@ -740,7 +730,6 @@ func TestOAuthDoneRendersSuccessPage(t *testing.T) { } } -// ====== Disabled proxy returns 503 ====== func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) { mux := http.NewServeMux() @@ -755,7 +744,6 @@ func TestOAuthRoutesReturn503WhenDisabled(t *testing.T) { } } -// ====== Path dispatch ====== func TestOAuthAuthRootRejectsBadPaths(t *testing.T) { h := newOAuthHarness(t) @@ -768,12 +756,9 @@ func TestOAuthAuthRootRejectsBadPaths(t *testing.T) { } } -// ====== Long-poll timeout ====== func TestOAuthResultBlocksUntilCancel(t *testing.T) { - // Pending sessions must NOT respond immediately; the long-poll contract is - // that /auth/result blocks until the session completes or the client - // cancels. The 204-after-server-timeout path takes 50s so isn't asserted. + // Pending sessions must block until completion or client cancellation. h := newOAuthHarness(t) sess, _, _ := h.startSession(t, "mal", "5.5.5.1") diff --git a/server/rate_limit.go b/server/rate_limit.go index b7fddbeb..9c89e8b6 100644 --- a/server/rate_limit.go +++ b/server/rate_limit.go @@ -5,7 +5,6 @@ import ( "time" ) -// --- Rate limiter (token bucket) --- type rateLimiter struct { tokens float64 @@ -65,8 +64,7 @@ func (rl *rateLimiter) refillAtLocked(now time.Time) { } } -// reclaimable reports whether discarding this limiter would preserve its -// behavior: enough idle time has passed for the bucket to be full again. +// reclaimable is true when the bucket has refilled completely. func (rl *rateLimiter) reclaimable(now time.Time) bool { rl.mu.Lock() defer rl.mu.Unlock() @@ -90,7 +88,6 @@ func cleanupRateWindows(windows map[string]time.Time, now time.Time, duration ti } } -// --- Poster upload admission (global, per-IP, and concurrency) --- type posterUploadLimiter struct { mu sync.Mutex @@ -152,7 +149,6 @@ func (pl *posterUploadLimiter) cleanup(now time.Time) { cleanupRateLimiters(pl.perIP, now, nil) } -// --- Connection tracker (per-IP limits) --- type connTracker struct { mu sync.Mutex @@ -186,8 +182,7 @@ func (ct *connTracker) tryConnect(ip string) bool { rl = newRateLimiter(connRateBurst, connRateSustained) ct.ipRate[ip] = rl } - // Unlock ct.mu before calling rl.allow() would be cleaner, - // but since rl has its own mutex this is safe (no deadlock). + // rl has its own mutex, so holding ct.mu here cannot deadlock. if !rl.allow() { return false } @@ -210,9 +205,7 @@ func (ct *connTracker) disconnect(ip string) { } } -// tryCreateRoom reserves capacity for a retained room created in this process. -// The reservation survives creator disconnect and is released only when the -// authoritative room is removed from Server.rooms. +// tryCreateRoom reserves capacity until the retained room is removed. func (ct *connTracker) tryCreateRoom(ip string) bool { ct.mu.Lock() defer ct.mu.Unlock() @@ -223,9 +216,8 @@ func (ct *connTracker) tryCreateRoom(ip string) bool { return true } -// tryCreateRoomReplacing reserves a room while accounting for the reservation -// that removeRoomLocked will immediately release from an empty same-ID room. -// Server.mu serializes this paired reservation/removal transaction. +// tryCreateRoomReplacing accounts for removal of an empty same-ID room. +// Server.mu serializes the reservation/removal transaction. func (ct *connTracker) tryCreateRoomReplacing(ip, replacedOwnerKey string) bool { ct.mu.Lock() defer ct.mu.Unlock() diff --git a/slang.yaml b/slang.yaml index dcb04c60..41cc2263 100644 --- a/slang.yaml +++ b/slang.yaml @@ -2,8 +2,5 @@ input_directory: lib/i18n input_file_pattern: .i18n.json output_directory: lib/i18n timestamp: false -# Fall back to base (en) when a non-base key is missing or still has the -# translation cleanup placeholder (`""`). Without this, every locale would -# need every key translated up-front and empty placeholders would render -# without meaningful text. +# Use English for missing keys and empty translation placeholders. fallback_strategy: base_locale_empty_string diff --git a/test/connection/connection_bootstrap_test.dart b/test/connection/connection_bootstrap_test.dart index dab73932..f0c089f9 100644 --- a/test/connection/connection_bootstrap_test.dart +++ b/test/connection/connection_bootstrap_test.dart @@ -75,7 +75,6 @@ void main() { // (no network in the test environment). expect(result.accountLabel, isNotEmpty); - // The migrated row is now in the registry. final stored = await registry.list(); expect(stored.length, 1); expect(stored.single, isA()); diff --git a/test/connection/connection_models_test.dart b/test/connection/connection_models_test.dart index d6c330d8..4973d9b2 100644 --- a/test/connection/connection_models_test.dart +++ b/test/connection/connection_models_test.dart @@ -3,11 +3,8 @@ import 'package:plezy/connection/connection.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_browser_dialect.dart'; -/// Backend-agnostic [Connection] sealed-class tests. The -/// `connection_registry_test` already covers DB persistence; these focus on -/// the model layer's `toConfigJson` / `fromConfigJson` round-trip and the -/// derived `kind` / `backend` mappings — the bits the registry treats as a -/// black box. +/// Backend-agnostic [Connection] model tests pin config round-trips and derived +/// kind/backend mappings; registry persistence is covered separately. void main() { group('ConnectionKind', () { test('id round-trips through fromId', () { diff --git a/test/connection/connection_registry_test.dart b/test/connection/connection_registry_test.dart index c479f5a3..6403d0a8 100644 --- a/test/connection/connection_registry_test.dart +++ b/test/connection/connection_registry_test.dart @@ -194,10 +194,8 @@ void main() { test('setDefault flips the flag and clears it on others', () async { await registry.upsert(_jellyfin(id: 'a')); await registry.upsert(_jellyfin(id: 'b')); - // First is default by default; explicitly switch to b. await registry.setDefault('b'); expect(await _defaultConnectionId(db), 'b'); - // Switch back to a. await registry.setDefault('a'); expect(await _defaultConnectionId(db), 'a'); }); @@ -205,11 +203,8 @@ void main() { test('remove deletes a row and re-elects a default when needed', () async { await registry.upsert(_jellyfin(id: 'a')); await registry.upsert(_jellyfin(id: 'b')); - // a is default (first one in). await registry.remove('a'); - // b should now be the default. expect(await _defaultConnectionId(db), 'b'); - // Removing the last clears the default cleanly. await registry.remove('b'); expect(await _defaultConnectionId(db), isNull); }); @@ -222,11 +217,9 @@ void main() { await registry.upsert(_jellyfin(id: 'b')); expect(await _defaultConnectionId(db), 'a'); - // Re-upsert the default with refreshed credentials. await registry.upsert(_jellyfin(id: 'a', userName: 'refreshed')); expect(await _defaultConnectionId(db), 'a'); - // And re-upserting a non-default row doesn't accidentally promote it. await registry.upsert(_jellyfin(id: 'b', userName: 'refreshed')); expect(await _defaultConnectionId(db), 'a'); }); diff --git a/test/database/app_database_test.dart b/test/database/app_database_test.dart index a310197d..0a467533 100644 --- a/test/database/app_database_test.dart +++ b/test/database/app_database_test.dart @@ -61,10 +61,6 @@ class _AppDatabaseTestSuite { } void _registerSchemaTests() { - // ============================================================ - // Schema sanity - // ============================================================ - group('schema', () { test('all tables are accessible and start empty', () async { expect(await db.select(db.downloadedMedia).get(), isEmpty); @@ -936,13 +932,6 @@ class _AppDatabaseTestSuite { } void _registerLegacyDesktopMigrationTests() { - // ============================================================ - // Legacy desktop DB-file relocation (Documents → AppSupport). - // Regression coverage for #1022: cross-drive rename (e.g. OneDrive - // Documents on X:, AppData on C:) used to throw an uncaught - // FileSystemException out of _openConnection and strand the splash. - // ============================================================ - group('legacy desktop DB migration', () { late Directory tempDir; @@ -1133,10 +1122,6 @@ class _AppDatabaseTestSuite { } void _registerApiCacheTests() { - // ============================================================ - // ApiCache schema defaults and constraints - // ============================================================ - group('ApiCache', () { test('default pinned=false, custom pinned=true is honored', () async { await db.into(db.apiCache).insert(ApiCacheCompanion.insert(cacheKey: 'k1', data: 'a')); @@ -1159,10 +1144,6 @@ class _AppDatabaseTestSuite { } void _registerDownloadedMediaTests() { - // ============================================================ - // DownloadedMedia: persistence, defaults, constraints, and helpers - // ============================================================ - group('DownloadedMedia', () { Future insertMovie({ String serverId = 'srv1', @@ -1471,10 +1452,6 @@ class _AppDatabaseTestSuite { } void _registerOfflineWatchProgressTests() { - // ============================================================ - // OfflineWatchProgress helpers - // ============================================================ - group('OfflineWatchProgress', () { Future insertAction({ String serverId = 's', @@ -2080,10 +2057,6 @@ class _AppDatabaseTestSuite { } void _registerSyncRulesTests() { - // ============================================================ - // Sync Rules helpers - // ============================================================ - group('SyncRules', () { test('insertSyncRule + getSyncRules round-trip with defaults', () async { await db.insertSyncRule( diff --git a/test/database/download_operations_test.dart b/test/database/download_operations_test.dart index d500e0ca..7f2fafe4 100644 --- a/test/database/download_operations_test.dart +++ b/test/database/download_operations_test.dart @@ -333,10 +333,6 @@ void main() { }); }); - // ============================================================ - // Download queue + getNextQueueItem - // ============================================================ - group('queue', () { test('addToQueue inserts a row with defaults', () async { await db.addToQueue(mediaGlobalKey: 'srv:100'); @@ -383,7 +379,6 @@ void main() { }); test('getNextQueueItem only returns items whose media is queued', () async { - // Two items in queue; one's media is still queued, the other is downloading. await db.insertDownload( serverId: ServerId('srv'), ratingKey: '1', @@ -404,12 +399,10 @@ void main() { final next = await db.getNextQueueItem(); expect(next, isNotNull); - // Should pick srv:1 since srv:2 is downloading (not queued). expect(next!.mediaGlobalKey, 'srv:1'); }); test('getNextQueueItem orders by priority desc, then addedAt asc', () async { - // All have queued status await db.insertDownload( serverId: ServerId('srv'), ratingKey: '1', @@ -445,7 +438,6 @@ void main() { .insert(DownloadQueueCompanion.insert(mediaGlobalKey: 'srv:3', priority: const Value(5), addedAt: now + 50)); final next = await db.getNextQueueItem(); - // priority 5 wins; srv:3 added before srv:2. expect(next!.mediaGlobalKey, 'srv:3'); }); @@ -537,10 +529,6 @@ void main() { }); }); - // ============================================================ - // Update helpers - // ============================================================ - group('update helpers', () { Future seed({String key = 'srv:100'}) async { await db.insertDownload( @@ -558,7 +546,7 @@ void main() { final r = (await db.select(db.downloadedMedia).get()).single; expect(r.status, DownloadStatus.downloading.index); - expect(r.progress, 0); // untouched + expect(r.progress, 0); }); test('updateDownloadProgress writes progress + bytes', () async { @@ -652,10 +640,6 @@ void main() { }); }); - // ============================================================ - // Lookup helpers - // ============================================================ - group('lookup helpers', () { Future seedTree() async { await db.insertDownload( @@ -885,10 +869,6 @@ void main() { }); }); - // ============================================================ - // Download owners - // ============================================================ - group('download owners', () { Future insertProfile(String id) async { await db @@ -964,10 +944,6 @@ void main() { }); }); - // ============================================================ - // deleteDownload — removes from both tables - // ============================================================ - group('deleteDownload', () { test('removes the row from downloadedMedia AND its queue entry', () async { await db.insertDownload( @@ -999,7 +975,6 @@ void main() { }); test('deleteDownload on a missing globalKey is a no-op', () async { - // Should not throw. expect(await db.deleteDownload('nope:nope'), isNull); expect(await db.select(db.downloadedMedia).get(), isEmpty); expect(await db.select(db.downloadQueue).get(), isEmpty); diff --git a/test/focus/key_event_utils_test.dart b/test/focus/key_event_utils_test.dart index e10a9855..b01c56b2 100644 --- a/test/focus/key_event_utils_test.dart +++ b/test/focus/key_event_utils_test.dart @@ -224,17 +224,14 @@ void main() { await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); - // Interior RIGHT moves to the next button. await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[1]'); - // RIGHT at the last button is trapped — must NOT escape to 'outside'. await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[1]'); - // LEFT back to the first, then LEFT again is trapped. await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft); await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); diff --git a/test/media/media_playlist_test.dart b/test/media/media_playlist_test.dart index 32eac4e2..5e992e2b 100644 --- a/test/media/media_playlist_test.dart +++ b/test/media/media_playlist_test.dart @@ -2,14 +2,9 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/media/media_backend.dart'; import 'package:plezy/media/media_playlist.dart'; -/// Backend-agnostic [MediaPlaylist] tests. Mappers (`plex_mappers_test` / -/// `jellyfin_mappers_test`) cover JSON → model translation; this file pins -/// the neutral model's surface so a future mapper swap can't silently -/// regress its derived getters. -/// -/// Note: [MediaPlaylist] does **not** override `==` / `hashCode`, so this -/// file deliberately avoids equality tests that would exercise default -/// identity behavior. +/// Backend-agnostic [MediaPlaylist] tests pin the neutral model's getters +/// separately from mapper coverage. The model uses identity equality, so these +/// tests intentionally avoid equality assertions. MediaPlaylist _playlist({ String id = 'pl1', MediaBackend backend = MediaBackend.plex, @@ -55,7 +50,6 @@ void main() { final renamed = original.copyWith(title: 'Renamed', smart: true); expect(renamed.title, 'Renamed'); expect(renamed.smart, isTrue); - // Source untouched — copyWith must be non-mutating. expect(original.title, 'Original'); expect(original.smart, isFalse); }); @@ -148,7 +142,6 @@ void main() { expect(minimal.serverName, isNull); expect(minimal.displayImagePath, isNull); expect(minimal.displayTitle, 'Min'); - // Without a serverId, globalKey reduces to the bare id. expect(minimal.globalKey, 'pl'); }); }); diff --git a/test/mixins/paginated_item_loader_test.dart b/test/mixins/paginated_item_loader_test.dart index a4fb4358..35e61acd 100644 --- a/test/mixins/paginated_item_loader_test.dart +++ b/test/mixins/paginated_item_loader_test.dart @@ -178,12 +178,10 @@ void main() { await tester.pumpWidget( _PaginatedProbe( onState: (s) => state = s, - // Empty list mirrors the "library has no items" wire response. fetcher: (start, size, abort) async => const LibraryPage(items: [], totalCount: 0), ), ); - // Initial page reports totalSize = 0. await state.loadInitialPage(20); await tester.pump(); @@ -243,7 +241,6 @@ void main() { state.ensureIndexLoaded(350, pageSize: 200); await tester.pumpAndSettle(); - // The probe records its calls; the second one should target start=200. expect(state.fetchArgs.length, greaterThanOrEqualTo(2)); final pageFetch = state.fetchArgs.last; expect(pageFetch.start, 200); @@ -259,7 +256,6 @@ void main() { onState: (s) => state = s, fetcher: (start, size, abort) async { if (start == 0) { - // Initial page always succeeds so totalSize > 0. return _result(start: 0, size: size, totalSize: 400); } rangeAttempt++; @@ -267,7 +263,6 @@ void main() { // First range fetch fails — triggers retry path. throw MediaServerHttpException(type: MediaServerHttpErrorType.connectionError, message: 'boom'); } - // Retry fetch succeeds. return _result(start: start, size: size, totalSize: 400); }, ), @@ -327,7 +322,6 @@ void main() { // we'd see another fetch attempt. await tester.pump(const Duration(milliseconds: 1500)); - // Only the failed fetch happened — no retry on cancellation. expect(state.fetchCalls, beforeFetches + 1); }); @@ -414,7 +408,6 @@ void main() { ), ); - // No initial load — totalSize stays 0. state.removeLoadedItemAndShift(0); expect(state.totalSize, 0); }); diff --git a/test/mixins/tab_navigation_mixin_test.dart b/test/mixins/tab_navigation_mixin_test.dart index ebdbdb51..1dad2b96 100644 --- a/test/mixins/tab_navigation_mixin_test.dart +++ b/test/mixins/tab_navigation_mixin_test.dart @@ -74,9 +74,7 @@ void main() { expect(state.tabCount, 3); expect(state.tabController.length, 3); - // Initial tab is 0 by default. expect(state.tabController.index, 0); - // Auto-focus suppression flag starts false. expect(state.suppressAutoFocus, isFalse); }); diff --git a/test/mixins/watch_state_aware_test.dart b/test/mixins/watch_state_aware_test.dart index 55144054..2c1720a0 100644 --- a/test/mixins/watch_state_aware_test.dart +++ b/test/mixins/watch_state_aware_test.dart @@ -158,7 +158,6 @@ void main() { WatchStateNotifier().notify(_ev(serverId: ServerId('s1'), itemId: '42')); await tester.pump(Duration.zero); - // No second delivery — subscription cancelled. expect(state.events, hasLength(1)); }); }); diff --git a/test/mpv/player_android_subtitle_visibility_test.dart b/test/mpv/player_android_subtitle_visibility_test.dart index a6ab29be..a8fa02ab 100644 --- a/test/mpv/player_android_subtitle_visibility_test.dart +++ b/test/mpv/player_android_subtitle_visibility_test.dart @@ -137,7 +137,6 @@ void main() { ]); await player.selectSubtitleTrack(const SubtitleTrack(id: '3', language: 'swe')); - // A redundant show is a no-op rather than a replayed selection. await player.setProperty('sub-visibility', 'yes'); expect(harness.subtitleSelections, ['3']); diff --git a/test/mpv/player_error_http_status_test.dart b/test/mpv/player_error_http_status_test.dart index 7a465041..b5969808 100644 --- a/test/mpv/player_error_http_status_test.dart +++ b/test/mpv/player_error_http_status_test.dart @@ -29,7 +29,6 @@ void main() { }); test('does not mistake an unrelated number for a status', () { - // A bare code with no HTTP context must not reach the fatal dialogs. expect(PlayerError.httpStatusFromLog('Set property: stream-buffer-size="404"'), isNull); expect(PlayerError.httpStatusFromLog('audio/aac 500 kbps'), isNull); // Adjacent digits are not a 3-digit status. diff --git a/test/providers/hidden_libraries_provider_test.dart b/test/providers/hidden_libraries_provider_test.dart index b0c5e7fa..fbaf81ce 100644 --- a/test/providers/hidden_libraries_provider_test.dart +++ b/test/providers/hidden_libraries_provider_test.dart @@ -31,7 +31,6 @@ void main() { expect(p.hiddenLibraryKeys, contains('lib-1')); expect(notified, 1); - // Same key again → no-op, no extra notification await p.hideLibrary('lib-1'); expect(notified, 1); @@ -66,7 +65,6 @@ void main() { expect(p.isLibraryHidden('lib-1'), isFalse); expect(p.isLibraryHidden('lib-2'), isTrue); - // Unhiding an already-absent key is a no-op await p.unhideLibrary('lib-3'); expect(p.hiddenLibraryKeys, equals({'lib-2'})); @@ -143,7 +141,6 @@ void main() { final p = HiddenLibrariesProvider(); await p.ensureInitialized(); p.dispose(); - // Should not throw, even though notifyListeners after dispose normally does. await p.refresh(); }); }); diff --git a/test/providers/multi_server_provider_test.dart b/test/providers/multi_server_provider_test.dart index 5fdb2bf4..c021c595 100644 --- a/test/providers/multi_server_provider_test.dart +++ b/test/providers/multi_server_provider_test.dart @@ -39,7 +39,6 @@ void main() { test('liveTvServers getter returns an unmodifiable view', () { final p = MultiServerProvider(manager, aggregation); - // Empty by default; mutating through the unmodifiable view must throw. expect(() => p.liveTvServers.clear(), throwsUnsupportedError); p.dispose(); }); @@ -65,7 +64,6 @@ void main() { var notified = 0; p.addListener(() => notified++); - // Push a status change through the manager's public API. manager.updateServerStatus(ServerId('srv-1'), true); // Give the broadcast stream microtask time to deliver. await Future.delayed(Duration.zero); @@ -137,11 +135,9 @@ void main() { expect(notified, 2); expect(p.hasExplicitVisibleServerFilter, isTrue); - // Idempotent: same membership is a no-op. p.setVisibleServerIds({'b', 'a'}); expect(notified, 2); - // Clearing back to null after a real filter is a state change. p.setVisibleServerIds(null); expect(notified, 3); expect(p.hasExplicitVisibleServerFilter, isFalse); diff --git a/test/providers/shader_provider_test.dart b/test/providers/shader_provider_test.dart index 572628a9..7151a20b 100644 --- a/test/providers/shader_provider_test.dart +++ b/test/providers/shader_provider_test.dart @@ -46,7 +46,6 @@ void main() { expect(p.isShaderEnabled, isTrue); expect(notified, 1); - // Verify persisted via the SettingsService directly. final svc = await SettingsService.getInstance(); expect(svc.read(SettingsService.globalShaderPreset), ShaderPreset.nvscalerDefault.id); @@ -67,7 +66,6 @@ void main() { expect(p.savedPreset, ShaderPreset.nvscalerDefault); expect(notified, 1); - // Same id → no notify. p.setCurrentPreset(ShaderPreset.none); expect(notified, 1); @@ -267,7 +265,6 @@ void main() { final p = ShaderProvider(); await Future.delayed(Duration.zero); p.dispose(); - // Should not throw — setPreset calls safeNotifyListeners under the hood. await p.setPreset(ShaderPreset.none); }); }); diff --git a/test/providers/theme_provider_test.dart b/test/providers/theme_provider_test.dart index dac6221f..babf6ca9 100644 --- a/test/providers/theme_provider_test.dart +++ b/test/providers/theme_provider_test.dart @@ -44,11 +44,9 @@ void main() { expect(p.themeMode, next); expect(notified, 1); - // Same value → no notify. await p.setThemeMode(next); expect(notified, 1); - // Verify persisted via SettingsService. final svc = await settings.SettingsService.getInstance(); expect(svc.read(settings.SettingsService.themeMode), next); @@ -178,7 +176,6 @@ void main() { final p = ThemeProvider(); await Future.delayed(Duration.zero); p.dispose(); - // Should not throw — reload calls safeNotifyListeners under the hood. await p.reload(); }); }); diff --git a/test/screens/music/album_detail_screen_test.dart b/test/screens/music/album_detail_screen_test.dart index 2dfd14f4..409c7c6f 100644 --- a/test/screens/music/album_detail_screen_test.dart +++ b/test/screens/music/album_detail_screen_test.dart @@ -36,7 +36,6 @@ void main() { await tester.pumpWidget(harness.wrap(const AlbumDetailScreen(album: _album))); await tester.pumpAndSettle(); - // Header: album title (app bar + header), tappable artist line, metadata. expect(find.text('Test Album'), findsWidgets); expect(find.text('Test Artist'), findsOneWidget); expect(find.textContaining('2001'), findsOneWidget); @@ -50,7 +49,6 @@ void main() { expect(find.text('Track Two'), findsOneWidget); expect(find.text('Track Three'), findsOneWidget); - // Track numbers restart per disc. expect(find.text('1'), findsNWidgets(2)); }); } diff --git a/test/screens/music/queue_sheet_test.dart b/test/screens/music/queue_sheet_test.dart index 3e7bfc9a..fcc435db 100644 --- a/test/screens/music/queue_sheet_test.dart +++ b/test/screens/music/queue_sheet_test.dart @@ -96,7 +96,6 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 300)); - // Header: title + total track count. expect(find.text(t.music.queue), findsOneWidget); expect(find.text(t.music.trackCount(n: 3)), findsOneWidget); diff --git a/test/services/base_shared_preferences_service_test.dart b/test/services/base_shared_preferences_service_test.dart index f4f8221c..d6063b87 100644 --- a/test/services/base_shared_preferences_service_test.dart +++ b/test/services/base_shared_preferences_service_test.dart @@ -65,15 +65,8 @@ void main() { _poisonedCacheRegression(); } -/// A repair that quarantines the store and then cannot reopen it must not -/// leave the process permanently unable to try again. -/// -/// Before #1732's fix the repaired future was built straight from the cache -/// loader, bypassing the self-healing `onError` reset that `sharedCache` -/// installs. A reopen failure therefore parked a rejected future in -/// `_cacheFuture`, and every later attempt replayed that stale error for the -/// rest of the process — with the damaged file already moved aside, so a -/// restart would have booted cleanly. +/// A failed reopen after repair must reset the cached future so a later attempt +/// can retry instead of replaying the stale error (#1732). void _poisonedCacheRegression() { group('repairCorruptStore', () { late Directory root; diff --git a/test/services/bif_thumbnail_service_test.dart b/test/services/bif_thumbnail_service_test.dart index 0b4ab081..53c18e75 100644 --- a/test/services/bif_thumbnail_service_test.dart +++ b/test/services/bif_thumbnail_service_test.dart @@ -4,18 +4,8 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/services/bif_thumbnail_service.dart'; import 'package:plezy/services/plex_client.dart'; -// BIF (Roku Base Index Format) is a binary container for video timeline -// thumbnails. The service exposes [BifThumbnailService] which downloads + parses -// a file (network-bound), but the parser itself is reachable through -// [BifThumbnailService.load] when paired with a fake [PlexClient] that returns -// hand-crafted bytes. -// -// What's NOT covered (by design): -// - The 50MiB size guard — verifying it would mean producing a 50MiB -// `Uint8List`, which is wasteful for unit tests. -// - The download-throws path — `BifThumbnailService.load` swallows errors -// into a "no thumbnails" state, and the only observable difference between -// "download failed" and "valid 0-image BIF" is `isAvailable=false`. +// BIF parser coverage uses a fake client with hand-crafted bytes. Size-limit and +// download-failure behavior are intentionally left to integration coverage. /// Build a minimal valid BIF byte buffer. /// @@ -85,10 +75,6 @@ class _FakePlexClient implements PlexClient { } void main() { - // ============================================================ - // Initial state - // ============================================================ - group('initial state', () { test('isAvailable is false before load()', () { final svc = BifThumbnailService(); @@ -104,10 +90,6 @@ void main() { }); }); - // ============================================================ - // Pure parser (via load + getThumbnail) - // ============================================================ - group('valid BIF parsing', () { test('parses a 3-entry BIF with default 1000ms multiplier', () async { final bytes = _buildBif([ @@ -169,10 +151,6 @@ void main() { }); }); - // ============================================================ - // Malformed input - // ============================================================ - group('malformed BIF input', () { test('rejects bytes shorter than the 64-byte header', () async { final svc = BifThumbnailService(); @@ -238,10 +216,6 @@ void main() { }); }); - // ============================================================ - // Empty / null input - // ============================================================ - group('empty input', () { test('null download keeps the service in unavailable state', () async { final svc = BifThumbnailService(); @@ -271,10 +245,6 @@ void main() { }); }); - // ============================================================ - // Reload + dispose - // ============================================================ - group('reload + dispose', () { test('a second load() replaces prior entries', () async { final first = _buildBif([ diff --git a/test/services/data_aggregation_bridge_test.dart b/test/services/data_aggregation_bridge_test.dart index fbd0c44d..185252ae 100644 --- a/test/services/data_aggregation_bridge_test.dart +++ b/test/services/data_aggregation_bridge_test.dart @@ -139,11 +139,8 @@ class _GatedHubsClient implements MediaServerClient { dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); } -/// Smoke tests for the surviving cross-server aggregation surface on -/// [DataAggregationService]. Single-server passthroughs were removed in -/// favour of `context.tryGetMediaClientForServer(...).()`; what's -/// left here is the multi-client fan-out, which is testable without a -/// real backend by simply asserting the empty-state behaviour. +/// Covers the remaining cross-server [DataAggregationService] fan-out surface; +/// single-server calls now go through the per-server client context. void main() { late AppDatabase db; late MultiServerManager manager; diff --git a/test/services/download_storage_service_test.dart b/test/services/download_storage_service_test.dart index 835205e7..d4d398dd 100644 --- a/test/services/download_storage_service_test.dart +++ b/test/services/download_storage_service_test.dart @@ -51,10 +51,6 @@ void main() { }); }); - // ============================================================ - // SAF mode (Android-only). On host (macOS/Linux) it is always false. - // ============================================================ - group('SAF mode', () { test('isUsingSaf is false on the host (non-Android)', () async { final settings = await SettingsService.getInstance(); @@ -81,10 +77,6 @@ void main() { }); }); - // ============================================================ - // Default download directory + custom-path switching - // ============================================================ - group('downloads directory resolution', () { test('defaults to /downloads on desktop hosts', () async { final settings = await SettingsService.getInstance(); @@ -176,10 +168,6 @@ void main() { }); }); - // ============================================================ - // Artwork directory - // ============================================================ - group('artwork directory', () { test('initializes alongside support directory by default and caches sync path', () async { final settings = await SettingsService.getInstance(); @@ -250,10 +238,6 @@ void main() { }); }); - // ============================================================ - // Path resolution helpers (relative <-> absolute) - // ============================================================ - group('toRelativePath / toAbsolutePath', () { test('strips a single base-dir prefix to make a path relative', () async { final settings = await SettingsService.getInstance(); @@ -399,10 +383,6 @@ void main() { }); }); - // ============================================================ - // ensureAbsolutePath / getReadablePath - // ============================================================ - group('ensureAbsolutePath', () { test('keeps an existing absolute path that points at a real file', () async { final settings = await SettingsService.getInstance(); @@ -502,10 +482,6 @@ void main() { }); }); - // ============================================================ - // SAF path-component helpers (no platform calls — pure formatting) - // ============================================================ - group('SAF path components & names', () { test('movie components/filename use sanitized "Title (Year)"', () async { final dss = DownloadStorageService.instance; @@ -568,10 +544,6 @@ void main() { }); }); - // ============================================================ - // Real on-disk media directory helpers - // ============================================================ - group('media directories on disk', () { test('getMediaDirectory creates serverId/ratingKey under downloads', () async { final settings = await SettingsService.getInstance(); @@ -622,10 +594,6 @@ void main() { }); }); - // ============================================================ - // DownloadStorageException - // ============================================================ - group('DownloadStorageException', () { test('toString embeds message, path, and cause', () { final ex = DownloadStorageException('boom', '/tmp/x', StateError('inner')); @@ -637,10 +605,6 @@ void main() { }); } -// ============================================================ -// MediaItem fixtures (only the fields the SUT actually reads) -// ============================================================ - MediaItem _movie({required String title, int? year}) { return testMediaItem( id: 'm-${title.hashCode}', diff --git a/test/services/episode_navigation_service_test.dart b/test/services/episode_navigation_service_test.dart index 40a1b6f2..e6ee5729 100644 --- a/test/services/episode_navigation_service_test.dart +++ b/test/services/episode_navigation_service_test.dart @@ -513,10 +513,6 @@ void main() { }); }); - // =========================================================== - // loadAdjacentEpisodes: shuffled same-series queue (#1466) - // =========================================================== - group('loadAdjacentEpisodes with a shuffled same-series queue', () { // Mirrors JellyfinSequentialLauncher.launchShuffledShow: the full series // episode list, locally shuffled, published with contextKey == seriesId. diff --git a/test/services/jellyfin_client_failures_test.dart b/test/services/jellyfin_client_failures_test.dart index 14ce9b87..5eb25439 100644 --- a/test/services/jellyfin_client_failures_test.dart +++ b/test/services/jellyfin_client_failures_test.dart @@ -50,13 +50,8 @@ class _AbortAwareClient extends http.BaseClient { } } -/// Failure-path coverage for the Jellyfin HTTP layer. -/// -/// The original test suite covered the 200-OK happy paths and a single 404 -/// (handled inside `fetchItem`). Anything else — auth rejection, server -/// errors, malformed JSON — was untested. These cases are the exact shapes -/// that surface in the field when a Jellyfin server is mid-update or the -/// access token has been revoked, so they're worth pinning. +/// Failure-path coverage for Jellyfin HTTP errors, malformed responses, and +/// revoked credentials beyond the existing happy-path and 404 tests. void main() { // fetchChildren writes through `JellyfinApiCache.instance` on a // successful 200, so the singleton needs to exist for tests that exercise diff --git a/test/services/jellyfin_client_urls_test.dart b/test/services/jellyfin_client_urls_test.dart index 249db2b2..ccf63d97 100644 --- a/test/services/jellyfin_client_urls_test.dart +++ b/test/services/jellyfin_client_urls_test.dart @@ -131,11 +131,8 @@ _initializeJellyfinAudioCarry({int? selectedAudioStreamId, AudioTrack? preferred return (client: client, requests: requests); } -/// URL-builder smoke tests. We can't unit-test a network round-trip without -/// spinning up a Jellyfin server, but the URL shape is a clear unit-of-work: -/// query parameters must include the right keys and the auth token. These -/// tests pin the contract so the next iteration of the player (Task 8 wiring) -/// has something to point at. +/// URL-builder smoke tests. Without a live Jellyfin server, pin query keys and +/// authentication parameters directly. void main() { // Pin device identity so JellyfinClient.create's MediaBrowser header falls // back to Device="Plezy" instead of resolving the host machine's name. diff --git a/test/services/jellyfin_mappers_test.dart b/test/services/jellyfin_mappers_test.dart index 0dccdbc3..6ef63dda 100644 --- a/test/services/jellyfin_mappers_test.dart +++ b/test/services/jellyfin_mappers_test.dart @@ -82,7 +82,6 @@ void main() { expect(item.viewOffsetMs, 3000000); // 3000s in ms expect(item.viewCount, 1); - // Image paths. expect(item.thumbPath, '/Items/abc123/Images/Primary?tag=thumbtag'); expect(item.artPath, '/Items/abc123/Images/Backdrop/0?tag=backtag'); expect(item.backdropPaths, [ @@ -92,7 +91,6 @@ void main() { ]); expect(item.clearLogoPath, '/Items/abc123/Images/Logo?tag=logotag'); - // Multi-server fields. expect(item.serverId, _serverId); expect(item.serverName, 'Home'); }); diff --git a/test/services/jellyfin_media_info_test.dart b/test/services/jellyfin_media_info_test.dart index 050e04d1..3b3620b3 100644 --- a/test/services/jellyfin_media_info_test.dart +++ b/test/services/jellyfin_media_info_test.dart @@ -69,7 +69,6 @@ void main() { expect(eng.channels, 6); expect(eng.selected, isTrue); - // Non-default audio final jpn = info.audioTracks[1]; expect(jpn.id, 2); expect(jpn.languageCode, 'jpn'); @@ -105,7 +104,6 @@ void main() { expect(info.defaultSubtitleStreamIndex, isNull); expect(info.subtitleTracks.map((track) => track.selected), [false, false]); - // The row metadata itself is untouched; only the selection claim is. expect(info.subtitleTracks.first.forced, isTrue); // Audio keeps the container default: something always has to play. expect(info.audioTracks.single.selected, isTrue); diff --git a/test/services/jellyfin_music_mapper_test.dart b/test/services/jellyfin_music_mapper_test.dart index 82fc1bee..300d8877 100644 --- a/test/services/jellyfin_music_mapper_test.dart +++ b/test/services/jellyfin_music_mapper_test.dart @@ -86,7 +86,6 @@ void main() { expect(item.parentTitle, 'Live at Testhalle'); expect(item.grandparentId, 'a603621309dc866c91b6c5fe10cee64d'); expect(item.grandparentTitle, 'The Synth Pops'); - // Derived music getters. expect(item.trackNumber, 1); expect(item.discNumber, 1); expect(item.albumTitle, 'Live at Testhalle'); diff --git a/test/services/local_playback_history_test.dart b/test/services/local_playback_history_test.dart index 09bfb29f..7faa8ec6 100644 --- a/test/services/local_playback_history_test.dart +++ b/test/services/local_playback_history_test.dart @@ -80,7 +80,6 @@ void main() { final history = await LocalPlaybackHistory.snapshot(); expect(history.length, 400); expect(history, contains('srv-1:fresh')); - // The oldest entry (value 1) was evicted to make room. expect(history, isNot(contains('srv-1:old-0'))); }); } diff --git a/test/services/multi_server_manager_test.dart b/test/services/multi_server_manager_test.dart index 3de98e31..39c8dcf3 100644 --- a/test/services/multi_server_manager_test.dart +++ b/test/services/multi_server_manager_test.dart @@ -104,18 +104,9 @@ class _LoopbackJellyfinServer { } } -// Coverage includes status and lifecycle changes, endpoint exhaustion, -// in-place and fresh scoped Plex profile binding, endpoint persistence and -// promotion ownership, connectivity monitoring/debounce teardown, Jellyfin -// reuse/update, and selected registered-client health outcomes. - void main() { setUp(resetSharedPreferencesForTest); - // ============================================================ - // Initial state - // ============================================================ - group('initial state', () { test('a freshly constructed manager has no servers, clients, or status', () { final m = MultiServerManager(); @@ -136,10 +127,6 @@ void main() { }); }); - // ============================================================ - // updateServerStatus + status stream - // ============================================================ - group('updateServerStatus + statusStream', () { test('emits a snapshot when status flips for a tracked server', () async { final m = MultiServerManager(); @@ -1436,10 +1423,6 @@ void main() { }); }); - // ============================================================ - // addJellyfinConnection reuse - // ============================================================ - group('addJellyfinConnection reuse', () { // The reuse branch is what keeps a passive rebind (re-adding the same // persisted connection) from tearing down a live client and aborting its @@ -1543,10 +1526,6 @@ void main() { }); }); - // ============================================================ - // removeServer - // ============================================================ - group('removeServer', () { test('removes a tracked server\'s status entry and emits a snapshot', () async { final m = MultiServerManager(); @@ -1602,10 +1581,6 @@ void main() { }); }); - // ============================================================ - // disconnectAll - // ============================================================ - group('disconnectAll', () { test('clears all status and emits an empty snapshot', () async { final m = MultiServerManager(); @@ -1642,10 +1617,6 @@ void main() { }); }); - // ============================================================ - // dispose - // ============================================================ - group('dispose', () { test('disposing without connectivity monitoring does not throw', () { final m = MultiServerManager(); diff --git a/test/services/offline_watch_sync_service_test.dart b/test/services/offline_watch_sync_service_test.dart index 11e27c07..800ebac2 100644 --- a/test/services/offline_watch_sync_service_test.dart +++ b/test/services/offline_watch_sync_service_test.dart @@ -27,13 +27,8 @@ import '../test_helpers/playback_report_fakes.dart'; import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; -// Direct `syncPendingItems` coverage exercises retry retention, Plex/Jellyfin -// progress replay, profile interruption, and scoped Jellyfin routing. Direct -// `syncWatchStatesFromServer` coverage exercises active-profile and -// active-scope routing plus selected watched outcomes. Trigger coalescing and -// throttle sequencing inside `_performBidirectionalSync`, direct cache-row and -// refresh-callback assertions, and non-default watched-threshold sources remain -// outside this suite. +// Direct calls cover retry retention, progress replay, profile interruption, and +// scoped routing; trigger coalescing and cache/refresh seams remain out of scope. /// Minimal [OfflineModeSource] that lets tests flip the offline flag and /// observe `addListener`/`removeListener` traffic via the protected @@ -162,10 +157,6 @@ JellyfinConnection _jellyfinConnection(String userId) => testJellyfinConnection( void main() { setUp(resetSharedPreferencesForTest); - // ============================================================ - // Initial state - // ============================================================ - group('initial state', () { test('a freshly constructed service is not syncing and has no pending count', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -213,10 +204,6 @@ void main() { }); }); - // ============================================================ - // queueMarkWatched / queueMarkUnwatched - // ============================================================ - group('queueMarkWatched / queueMarkUnwatched', () { test('queueMarkWatched persists a "watched" action and bumps pending count', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -466,11 +453,6 @@ void main() { }); }); - // ============================================================ - // queueProgressUpdate (also exercised so we can test the progress branches - // of getLocalWatchStatus / getLocalViewOffset). - // ============================================================ - group('syncPendingItems profile scoping', () { test('defers entirely when no profile is active', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -604,10 +586,6 @@ void main() { }); }); - // ============================================================ - // Superseded queued progress (#1812) - // ============================================================ - group('queued progress superseded by a watch-state write', () { MediaItem itemFor(String id) => testMediaItem(id: id, backend: MediaBackend.jellyfin, kind: MediaKind.movie, serverId: 'srv'); @@ -709,10 +687,6 @@ void main() { }); }); - // ============================================================ - // getLocalWatchStatus - // ============================================================ - group('getLocalWatchStatus', () { test('returns null when no local action exists', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -764,10 +738,6 @@ void main() { }); }); - // ============================================================ - // getLocalViewOffset - // ============================================================ - group('getLocalViewOffset', () { test('returns null when no local action exists', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -825,10 +795,6 @@ void main() { }); }); - // ============================================================ - // getPendingSyncCount - // ============================================================ - group('getPendingSyncCount', () { test('counts every queued action (manual + progress)', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -860,10 +826,6 @@ void main() { }); }); - // ============================================================ - // getLocalWatchStatusesBatched - // ============================================================ - group('getLocalWatchStatusesBatched', () { test('empty input returns empty map without touching the DB', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -1488,10 +1450,6 @@ void main() { }); }); - // ============================================================ - // clearAll - // ============================================================ - group('clearAll', () { test('removes every queued action and notifies listeners', () async { final (svc: svc, db: db, mgr: mgr) = _makeService(); @@ -1514,10 +1472,6 @@ void main() { }); }); - // ============================================================ - // startConnectivityMonitoring + dispose - // ============================================================ - group('startConnectivityMonitoring + dispose', () { test('attaches a listener to the source', () { final (svc: svc, db: db, mgr: mgr) = _makeService(); diff --git a/test/services/play_queue_launcher_test.dart b/test/services/play_queue_launcher_test.dart index fa15ce78..8b0b5fce 100644 --- a/test/services/play_queue_launcher_test.dart +++ b/test/services/play_queue_launcher_test.dart @@ -73,10 +73,6 @@ PlayQueueResponse _queueWith(MediaItem item) { void main() { TestWidgetsFlutterBinding.ensureInitialized(); - // ============================================================ - // PlayQueueResult sealed hierarchy - // ============================================================ - group('PlayQueueResult', () { test('PlayQueueCancelled is a distinct re-exported result', () { const PlayQueueResult result = PlayQueueCancelled(); @@ -85,10 +81,6 @@ void main() { }); }); - // ============================================================ - // Pre-flight branches that don't touch the network - // ============================================================ - group('launchShuffledShow pre-flight guard', () { testWidgets('returns PlayQueueError when metadata is not a show or season', (tester) async { // Build a launcher inside an active Element so its `context.mounted` diff --git a/test/services/playback_progress_tracker_test.dart b/test/services/playback_progress_tracker_test.dart index 35993430..2fc72781 100644 --- a/test/services/playback_progress_tracker_test.dart +++ b/test/services/playback_progress_tracker_test.dart @@ -22,9 +22,8 @@ import '../test_helpers/prefs.dart'; import '../test_helpers/media_items.dart'; import '../test_helpers/playback_report_fakes.dart'; -// Periodic behavior is virtualized with fake_async and the tracker's existing -// updateInterval seam. Routing, threshold, scrobble, cadence, coalescing, -// backoff, resume, and disposal are asserted through observable calls. +// fake_async drives periodic routing, threshold, scrobble, coalescing, backoff, +// resume, and disposal behavior through observable calls. /// Fake Player whose state is mutable from the test. class _FakePlayer implements Player { @@ -330,10 +329,6 @@ MediaItem _meta({ void main() { setUp(resetSharedPreferencesForTest); - // ============================================================ - // Constructor assertions - // ============================================================ - group('constructor assertions', () { test('offline=true requires offlineWatchService', () { expect( @@ -350,10 +345,6 @@ void main() { }); }); - // ============================================================ - // sendProgress: short-circuit on duration=0 - // ============================================================ - group('sendProgress: duration guard', () { test('does NOT send progress when duration is zero (player not yet ready)', () async { final client = _FakePlexClient(); @@ -443,10 +434,6 @@ void main() { }); }); - // ============================================================ - // sendProgress: online routing - // ============================================================ - group('sendProgress: online', () { test('"stopped" awaits the underlying call and reports correct args', () async { final client = _FakePlexClient(); @@ -792,10 +779,6 @@ void main() { }); }); - // ============================================================ - // Threshold gating + scrobble - // ============================================================ - group('threshold gating', () { test('does NOT scrobble when percent < watchedThresholdPercent', () async { // 89% < 90% threshold. @@ -1397,10 +1380,6 @@ void main() { }); }); - // ============================================================ - // Offline routing - // ============================================================ - group('sendProgress: offline', () { Future<({OfflineWatchSyncService svc, AppDatabase db, MultiServerManager mgr})> makeOfflineService() async { final db = AppDatabase.forTesting(NativeDatabase.memory()); @@ -1516,10 +1495,6 @@ void main() { }); }); - // ============================================================ - // WatchStateNotifier emission on 'stopped' - // ============================================================ - group('WatchStateNotifier event on "stopped"', () { test('emits a progress-update event when stopped past position 0', () async { final client = _FakePlexClient(thresholdPercent: 90); @@ -1837,10 +1812,6 @@ void main() { }); }); - // ============================================================ - // startTracking / stopTracking / dispose lifecycle - // ============================================================ - group('lifecycle', () { test('startTracking + stopTracking is a clean no-op for an inactive player', () async { final client = _FakePlexClient(); diff --git a/test/services/plex_api_cache_test.dart b/test/services/plex_api_cache_test.dart index 063ae623..46d673b5 100644 --- a/test/services/plex_api_cache_test.dart +++ b/test/services/plex_api_cache_test.dart @@ -54,10 +54,6 @@ void main() { }, }; - // ============================================================ - // Singleton - // ============================================================ - group('singleton', () { test('initialize swaps the underlying database', () async { final newDb = AppDatabase.forTesting(NativeDatabase.memory()); @@ -113,10 +109,6 @@ void main() { }); }); - // ============================================================ - // get / put — cache hit and miss - // ============================================================ - group('get / put', () { test('miss returns null for an unknown key', () async { expect(await cache.get(ServerId('srv'), '/library/metadata/1'), isNull); @@ -196,10 +188,6 @@ void main() { }); }); - // ============================================================ - // deleteForServer / deleteForItem / clearAll - // ============================================================ - group('deletion', () { test('deleteForServer wipes only the targeted serverId', () async { await cache.put(ServerId('srv-a'), '/library/metadata/1', mediaContainer(ratingKey: '1')); @@ -247,10 +235,6 @@ void main() { }); }); - // ============================================================ - // Pinning - // ============================================================ - group('pinning', () { test('isPinned defaults to false for a freshly cached item', () async { await cache.put(ServerId('srv'), '/library/metadata/1', mediaContainer()); @@ -316,10 +300,6 @@ void main() { }); }); - // ============================================================ - // getMetadata / getAllPinnedMetadata - // ============================================================ - group('metadata extraction', () { test('getMetadata returns null when the key is not cached', () async { expect(await cache.getMetadata(ServerId('srv'), 'missing'), isNull); diff --git a/test/services/plex_mappers_test.dart b/test/services/plex_mappers_test.dart index 99f94cbd..5af2237c 100644 --- a/test/services/plex_mappers_test.dart +++ b/test/services/plex_mappers_test.dart @@ -299,11 +299,9 @@ void main() { expect(item.roles![0].thumbPath, '/library/metadata/role/1/thumb'); expect(item.roles![1].thumbPath, isNull); - // Library identification. expect(item.libraryId, '1'); expect(item.libraryTitle, 'Movies'); - // Server-tagging. expect(item.serverId, _serverId); expect(item.serverName, _serverName); }); diff --git a/test/services/sleep_timer_service_test.dart b/test/services/sleep_timer_service_test.dart index 691c8683..2b92be9c 100644 --- a/test/services/sleep_timer_service_test.dart +++ b/test/services/sleep_timer_service_test.dart @@ -18,10 +18,6 @@ void main() { timer.cancelTimer(); }); - // ============================================================ - // Initial state - // ============================================================ - group('initial state', () { test('isActive is false on a fresh / cancelled service', () { expect(timer.isActive, isFalse); @@ -38,10 +34,6 @@ void main() { }); }); - // ============================================================ - // startTimer — bookkeeping - // ============================================================ - group('startTimer', () { test('sets isActive, duration, originalDuration, and endTime', () { timer.startTimer(const Duration(minutes: 30), () {}); @@ -81,10 +73,6 @@ void main() { }); }); - // ============================================================ - // cancelTimer - // ============================================================ - group('cancelTimer', () { test('clears all state and prevents a later prompt', () { fakeAsync((async) { @@ -185,10 +173,6 @@ void main() { }); }); - // ============================================================ - // restartTimer / restartIfNeeded / markNeedsRestart - // ============================================================ - group('restartTimer', () { test('restartTimer after cancel is a no-op (originalDuration cleared)', () { timer.startTimer(const Duration(minutes: 1), () {}); @@ -247,10 +231,6 @@ void main() { }); }); - // ============================================================ - // extendTimer - // ============================================================ - group('extendTimer', () { test('shifts endTime and grows duration by the additional time', () { timer.startTimer(const Duration(minutes: 10), () {}); @@ -274,10 +254,6 @@ void main() { }); }); - // ============================================================ - // executeCompletion - // ============================================================ - group('executeCompletion', () { test('runs the stored callback and emits onCompleted', () async { var fired = 0; @@ -311,10 +287,6 @@ void main() { }); }); - // ============================================================ - // Change notifications - // ============================================================ - group('change notifications', () { test('startTimer and cancelTimer each notify listeners at least once', () { var notifications = 0; @@ -348,10 +320,6 @@ void main() { }); }); - // ============================================================ - // armEndOfVideo / notifyVideoCompleted - // ============================================================ - group('armEndOfVideo', () { test('sets isActive and isEndOfVideoMode without starting a periodic timer', () { timer.armEndOfVideo(() {}); diff --git a/test/services/storage_service_test.dart b/test/services/storage_service_test.dart index c143c3df..a4f23635 100644 --- a/test/services/storage_service_test.dart +++ b/test/services/storage_service_test.dart @@ -71,10 +71,6 @@ void main() { }); }); - // ============================================================ - // Plex token / client identifier (legacy, retained for migration) - // ============================================================ - group('PlexToken & ClientIdentifier (legacy migration slots)', () { test('getPlexToken reads the legacy slot', () async { final s = await StorageService.getInstance(); @@ -113,10 +109,6 @@ void main() { }); }); - // ============================================================ - // Server endpoints (per-server URL caching) - // ============================================================ - group('ServerEndpoint', () { test('round-trip per server id', () async { final s = await StorageService.getInstance(); @@ -138,10 +130,6 @@ void main() { }); }); - // ============================================================ - // Multi-server slot (legacy, only `getServersListJson` retained for migration) - // ============================================================ - group('Servers list (legacy migration slot)', () { test('legacy raw read returns null when nothing is stored', () async { final s = await StorageService.getInstance(); @@ -178,10 +166,6 @@ void main() { }); }); - // ============================================================ - // Hidden libraries (Set persisted as JSON list) - // ============================================================ - group('Hidden libraries', () { test('default is empty set', () async { final s = await StorageService.getInstance(); @@ -229,10 +213,6 @@ void main() { }); }); - // ============================================================ - // Library order (List) — scoped to active profile - // ============================================================ - group('Library order', () { test('default is null', () async { final s = await StorageService.getInstance(); @@ -319,10 +299,6 @@ void main() { }); }); - // ============================================================ - // Library filters / sort / grouping / tab - // ============================================================ - group('Library filters / sort / grouping / tab', () { test('global filters round-trip', () async { final s = await StorageService.getInstance(); @@ -394,10 +370,6 @@ void main() { }); }); - // ============================================================ - // Current user UUID (legacy slot retained for migration) - // ============================================================ - group('CurrentUserUUID (legacy migration slot)', () { test('clearCurrentUserUUID wipes the slot', () async { final s = await StorageService.getInstance(); @@ -410,10 +382,6 @@ void main() { }); }); - // ============================================================ - // Plex Home user-scope migration (full profile id → home-user uuid) - // ============================================================ - group('migratePlexHomeUserScopes (onInit)', () { const fullId = 'plex-home-plex.e443d57860076fc3-379704d0c6601309'; const uuid = '379704d0c6601309'; @@ -459,10 +427,6 @@ void main() { }); }); - // ============================================================ - // clearCredentials - // ============================================================ - group('clearCredentials', () { test('removes credential keys, plex token, and multi-server data', () async { final s = await StorageService.getInstance(); @@ -503,10 +467,6 @@ void main() { }); }); - // ============================================================ - // clearLibraryPreferences (user-scoped) - // ============================================================ - group('clearLibraryPreferences', () { test('clears scoped library keys for current user only', () async { final s = await StorageService.getInstance(); @@ -644,10 +604,6 @@ void main() { }); }); - // ============================================================ - // clearUserData = clearCredentials + clearLibraryPreferences - // ============================================================ - group('clearUserData', () { test('combines credentials and library-preferences clear', () async { final s = await StorageService.getInstance(); diff --git a/test/services/track_manager_test.dart b/test/services/track_manager_test.dart index 2fae0c39..b184b737 100644 --- a/test/services/track_manager_test.dart +++ b/test/services/track_manager_test.dart @@ -226,10 +226,6 @@ void main() { // could leak across tests — reset to be safe. setUp(resetSharedPreferencesForTest); - // ============================================================ - // External subtitle cache - // ============================================================ - group('cacheExternalSubtitles', () { test('round-trips through the lastExternalSubtitles getter', () { final mgr = _make(player: _FakePlayer()); @@ -250,10 +246,6 @@ void main() { }); }); - // ============================================================ - // addExternalSubtitles - // ============================================================ - group('addExternalSubtitles', () { test('returns immediately on empty input', () async { final player = _FakePlayer(); @@ -376,10 +368,6 @@ void main() { }); }); - // ============================================================ - // applyTrackSelectionWhenReady - // ============================================================ - group('applyTrackSelectionWhenReady', () { test('waits for player subtitle tracks when Plex metadata advertises subtitles', () async { await SettingsService.getInstance(); @@ -1262,10 +1250,6 @@ void main() { }); }); - // ============================================================ - // Explicit user selection vs. the pending automatic pass - // ============================================================ - group('explicit user selection', () { test('user audio choice survives the advertised-subtitle deadline', () async { await SettingsService.getInstance(); @@ -1570,10 +1554,6 @@ void main() { }); }); - // ============================================================ - // Track cycling early-return paths - // ============================================================ - group('cycleSubtitleTrack', () { test('no-op when no real subtitle tracks exist', () { // Tracks contains only auto/none (filtered out). @@ -1653,10 +1633,6 @@ void main() { }); }); - // ============================================================ - // Misc handlers - // ============================================================ - group('onPlaybackRestart', () { test('no-op when not waiting for external subs', () { final mgr = _make(player: _FakePlayer()); @@ -1723,10 +1699,6 @@ void main() { }); }); - // ============================================================ - // onSubtitleTrackChanged — same-language stream mapping (#1443) - // ============================================================ - group('onSubtitleTrackChanged', () { // Reproduces the #1443 MKVToolNix screenshot: the "forced" French subtitle // is NOT flagged forced in the container — it only carries the name @@ -1834,10 +1806,6 @@ void main() { }); }); - // ============================================================ - // Lifecycle - // ============================================================ - group('dispose', () { test('is idempotent', () { final mgr = _make(player: _FakePlayer()); diff --git a/test/services/track_selection_service_test.dart b/test/services/track_selection_service_test.dart index 33fc7621..f11ce315 100644 --- a/test/services/track_selection_service_test.dart +++ b/test/services/track_selection_service_test.dart @@ -11,39 +11,9 @@ import 'package:plezy/services/subtitle_preference.dart'; import 'package:plezy/services/track_selection_service.dart'; import '../test_helpers/media_items.dart'; -// NOTE on coverage scope: -// `TrackSelectionService` is a large pure logic surface with one async -// integration point (`selectAndApplyTracks`). We cover: -// -// - `languageMatches` — direct, base-code, and ISO 639 variation matching. -// - `findBestTrackMatch` / `findBestSubtitleMatch` — -// id+title+language exact, title+language, language-only, and the -// "auto"/"no" filtering rule. -// - `findAudioTrackByProfile` — picks the first preferred-language match, -// respects autoSelectAudio, falls back across the language list. -// - `selectAudioTrack` — full priority cascade: -// Priority 1 (preferred from navigation), -// Priority 2 (Plex-selected via media info), -// Priority 3 (per-media metadata.audioLanguage), -// Priority 4 (user profile), -// Priority 5 (default / first track), -// and the empty-list null return. -// - `selectSubtitleTrack` — preferred=off, preferred=tracked, -// Plex-selected, Plex-server-explicit-no-subtitles, default fallback, -// and the off-by-default branch. -// -// Top-level subtitle matching helpers are exercised directly for complete, -// partial, unique, ambiguous, and container catalogs. Audio helpers are -// exercised through `selectAudioTrack` (Priority 2) and their focused -// disambiguation tests below. -// -// What's NOT covered: -// - `selectAndApplyTracks` — depends on a real Player + SettingsService -// singleton + `player.streams.tracks`. Out of scope for a unit test. - -// ============================================================ -// Fixtures -// ============================================================ +// Covers the pure language, track-matching, and audio/subtitle priority helpers, +// including fallback and ambiguity rules. `selectAndApplyTracks` is excluded +// because it requires a real Player and SettingsService singleton. MediaItem _meta({MediaBackend backend = MediaBackend.plex, String? audioLanguage, String? subtitleLanguage}) => testMediaItem( @@ -192,10 +162,6 @@ TrackSelectionService _svc({MediaItem? metadata, MediaServerUserProfile? profile } void main() { - // ============================================================ - // languageMatches - // ============================================================ - group('languageMatches', () { final svc = _svc(); @@ -228,10 +194,6 @@ void main() { }); }); - // ============================================================ - // findBestTrackMatch (via the audio/subtitle wrappers) - // ============================================================ - group('findBestSubtitleMatch', () { final svc = _svc(); @@ -251,10 +213,6 @@ void main() { }); }); - // ============================================================ - // findAudioTrackByProfile - // ============================================================ - group('findAudioTrackByProfile', () { final svc = _svc(); @@ -292,10 +250,6 @@ void main() { }); }); - // ============================================================ - // selectAudioTrack — the priority cascade - // ============================================================ - group('selectAudioTrack', () { test('returns null on empty available tracks', () { expect(_svc().selectAudioTrack(const [], _audio('1', lang: 'eng')), isNull); @@ -462,10 +416,6 @@ void main() { }); }); - // ============================================================ - // selectSubtitleTrack - // ============================================================ - group('selectSubtitleTrack', () { test('Priority 1: preferred id="no" forces subtitles off', () { final tracks = [_sub('1', lang: 'eng', isDefault: true)]; @@ -1029,13 +979,6 @@ void main() { }); }); - // ============================================================ - // findPlexTrackForMpvSubtitle / findPlexTrackForMpvAudio — same-language - // disambiguation (regression for #1443). The player reports null titles for - // MKV tracks that carry only a forced flag, so the forced flag (+2) and the - // ordinal tiebreaker (+1) must separate two tracks that share a language. - // ============================================================ - group('findPlexTrackForMpvSubtitle - forced disambiguation', () { // Disposition-flagged forced track: forced is set in the container, so both // Plex and the player carry forced=true on the forced track. @@ -1102,12 +1045,6 @@ void main() { }); }); - // ============================================================ - // Cross-item intent matching (#1716/#1717): language and effective - // forced-ness are hard requirements — the intent's class is preserved or - // the match declines so the ladder falls to the server's own selection. - // ============================================================ - group('findSourceTrackForIntent', () { const forcedIntent = SubtitleIntent(language: 'fre', forced: true, title: 'FR Forced [ASS]', codec: 'ass'); const fullIntent = SubtitleIntent(language: 'fre', forced: false, title: 'French', codec: 'srt'); @@ -1157,12 +1094,6 @@ void main() { expect(findSourceTrackForIntent(intent, [_plexSub(1, languageCode: 'fre')]), isNull); }); - // ============================================================ - // #1785 — missing language tags must not turn the carry off when a - // unique real title identifies the row; codec parity alone is never - // evidence, and ambiguity declines rather than guesses. - // ============================================================ - test('title-only intent matches the row with the same title when tags are missing (#1785)', () { const intent = SubtitleIntent(forced: false, title: 'Swedish', codec: 'subrip'); final rows = [_plexSub(1, title: 'English', codec: 'subrip'), _plexSub(2, title: 'Swedish', codec: 'subrip')]; diff --git a/tvos/Runner/AppDelegate.swift b/tvos/Runner/AppDelegate.swift index 541c2c03..090bc55a 100644 --- a/tvos/Runner/AppDelegate.swift +++ b/tvos/Runner/AppDelegate.swift @@ -118,12 +118,8 @@ import wakelock_plus _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { - // Dolby's sequence diagram prescribes exactly this at app launch: the - // long-form playback profile, then activation, so the session is eligible - // for the system's Dolby decode/render path and its rendering capabilities - // can be read before any content is chosen. The mpv AVFoundation audio - // output reconfigures and re-activates the same shared session at playback - // start; this establishes the launch-time state the guide expects. + // Set the long-form profile before activation so Dolby capabilities are + // available before playback. do { let session = AVAudioSession.sharedInstance() try session.setCategory( diff --git a/tvos/scripts/set_tvos_target_pods.sh b/tvos/scripts/set_tvos_target_pods.sh index 9f6f7f0f..e3a638a8 100644 --- a/tvos/scripts/set_tvos_target_pods.sh +++ b/tvos/scripts/set_tvos_target_pods.sh @@ -11,7 +11,6 @@ find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/TARGETED_DEVIC find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/SDKROOT[[:space:]]=[[:space:]]iphoneos/SDKROOT = appletvos/g' find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/SUPPORTED_PLATFORMS[[:space:]]=[[:space:]]iphoneos/SUPPORTED_PLATFORMS = appletvos/g' find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/IPHONEOS_DEPLOYMENT_TARGET[[:space:]]=[[:space:]]9.0/TVOS_DEPLOYMENT_TARGET = 13.0/g' -# TODO: 8.0 --> should work with any version! cd ../.. diff --git a/tvos/scripts/set_tvos_target_runner.sh b/tvos/scripts/set_tvos_target_runner.sh index 1531f90e..04e58cef 100644 --- a/tvos/scripts/set_tvos_target_runner.sh +++ b/tvos/scripts/set_tvos_target_runner.sh @@ -11,7 +11,6 @@ find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/TARGETED_DEVIC find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/SDKROOT[[:space:]]=[[:space:]]iphoneos/SDKROOT = appletvos/g' find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/SUPPORTED_PLATFORMS[[:space:]]=[[:space:]]iphoneos/SUPPORTED_PLATFORMS = appletvos/g' find . -name 'project.pbxproj' -print0 | xargs -0 sed -i '' -e 's/IPHONEOS_DEPLOYMENT_TARGET[[:space:]]=[[:space:]][0-9].[0-9]/TVOS_DEPLOYMENT_TARGET = 13.0/g' -# TODO: 8.0 --> should work with any version! cd .. diff --git a/website/src/app.d.ts b/website/src/app.d.ts index a58845f3..0f2a09b4 100644 --- a/website/src/app.d.ts +++ b/website/src/app.d.ts @@ -1,14 +1,7 @@ -// See https://svelte.dev/docs/kit/types#app.d.ts -// for information about these interfaces import "unplugin-icons/types/svelte"; declare global { namespace App { - // interface Error {} - // interface Locals {} - // interface PageData {} - // interface PageState {} - // interface Platform {} } } diff --git a/website/src/lib/components/DownloadButtons.svelte b/website/src/lib/components/DownloadButtons.svelte index f280dec4..4736c627 100644 --- a/website/src/lib/components/DownloadButtons.svelte +++ b/website/src/lib/components/DownloadButtons.svelte @@ -37,7 +37,6 @@ { linuxOpen = false; }} />
- -
-