chore: clean up code comments

This commit is contained in:
edde746
2026-08-10 20:28:41 +02:00
parent 5611c6785a
commit 69fadc220d
170 changed files with 324 additions and 1765 deletions
-2
View File
@@ -271,13 +271,11 @@ jobs:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }} MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
run: | run: |
# Create temporary keychain
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
# Import certificate to keychain
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12 CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
+1 -3
View File
@@ -18,9 +18,7 @@ migrate_working_dir/
*.iws *.iws
.idea/ .idea/
# The .vscode folder contains launch configuration and tasks you configure in # Keep .vscode/ available for contributors' local configuration.
# VS Code which you may wish to be included in version control, so this line
# is commented out by default.
#.vscode/ #.vscode/
# Flutter/Dart/Pub related # Flutter/Dart/Pub related
@@ -22,8 +22,8 @@ tags:
- pressKey: "Remote Dpad Up" - pressKey: "Remote Dpad Up"
- pressKey: "Remote Dpad Right" - pressKey: "Remote Dpad Right"
- pressKey: "Remote Dpad Right" - pressKey: "Remote Dpad Right"
# The grid row must be matchable before the sort sheet opens, otherwise the # Wait for the row before opening the sheet so occlusion is tested against the
# occlusion assertion below would pass for the wrong reason. # grid, not an absent or stale match.
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s).*Alpha Archive.*" visible: "(?s).*Alpha Archive.*"
timeout: 15000 timeout: 15000
@@ -31,9 +31,8 @@ tags:
- extendedWaitUntil: - extendedWaitUntil:
visible: "Title" visible: "Title"
timeout: 10000 timeout: 10000
# The host renders the grid, the barrier, and the sheet in one Stack and never # The grid remains mounted under the Stack; BlockSemantics is required for
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics # occluded rows to disappear from Maestro's accessibility tree.
# an occluded row still read as visible to accessibility and to Maestro.
- assertNotVisible: "(?s).*Alpha Archive.*" - assertNotVisible: "(?s).*Alpha Archive.*"
- pressKey: "Remote Dpad Center" - pressKey: "Remote Dpad Center"
- pressKey: "Remote Dpad Left" - pressKey: "Remote Dpad Left"
@@ -16,13 +16,9 @@ tags:
visible: "(?s)^(Play|Resume).*$" visible: "(?s)^(Play|Resume).*$"
commands: commands:
- pressKey: "Remote Dpad Center" - pressKey: "Remote Dpad Center"
# The TV player opens with its chrome down so the OSD and timebar never sit # TV playback starts with chrome hidden (#1765), so wait for its loading label
# over the opening seconds of the picture (#1765), so the Pause button can no # rather than treating the Pause button as readiness. A failed open reaches the
# longer stand in for "the player has finished loading". Wait for the detail # transport assertions below.
# 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.
- extendedWaitUntil: - extendedWaitUntil:
notVisible: "Overview" notVisible: "Overview"
timeout: 30000 timeout: 30000
@@ -30,18 +26,14 @@ tags:
notVisible: "Loading video" notVisible: "Loading video"
timeout: 30000 timeout: 30000
- assertNotVisible: "(?s)^(Play|Pause)$" - assertNotVisible: "(?s)^(Play|Pause)$"
# Hardware transport keys drive playback without raising the chrome (#1676): # Transport keys keep chrome hidden (#1676); pause first so seeks cannot race
# the player answers with a centred disc (pause) or a side readout (seek), never the # the end of this short fixture.
# 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.
- pressKey: "Remote Media Play Pause" - pressKey: "Remote Media Play Pause"
- extendedWaitUntil: - extendedWaitUntil:
visible: "Paused" visible: "Paused"
timeout: 10000 timeout: 10000
- assertNotVisible: "(?s)^Play$" - assertNotVisible: "(?s)^Play$"
# D-pad seeking reports through the skip badge, never the scrub bar, and # D-pad seeks use the cumulative skip badge, not the scrub bar.
# consecutive presses in one direction stack into a running total.
- pressKey: "Remote Dpad Right" - pressKey: "Remote Dpad Right"
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^Seek forward 10 seconds$" visible: "(?s)^Seek forward 10 seconds$"
@@ -56,7 +48,7 @@ tags:
visible: "Playing" visible: "Playing"
timeout: 10000 timeout: 10000
- assertNotVisible: "(?s)^Pause$" - 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" - pressKey: "Remote Dpad Center"
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^(Play|Pause)$" visible: "(?s)^(Play|Pause)$"
@@ -7,10 +7,8 @@ tags:
- playback - playback
--- ---
- runFlow: ../subflows/onboard_jellyfin_tv.yaml - runFlow: ../subflows/onboard_jellyfin_tv.yaml
# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A # Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label,
# tap here flips InputModeTracker into pointer mode, which collapses the rail # while percentage coordinates are not portable across TV sizes.
# 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.
- pressKey: "Remote Dpad Left" - pressKey: "Remote Dpad Left"
- extendedWaitUntil: - extendedWaitUntil:
visible: "Search" visible: "Search"
@@ -38,11 +36,8 @@ tags:
- waitForAnimationToEnd: - waitForAnimationToEnd:
timeout: 5000 timeout: 5000
- tapOn: "(?s)^Play S1E1$" - tapOn: "(?s)^Play S1E1$"
# The TV player opens with its chrome down (#1765), so the Pause button can no # The player starts with chrome hidden (#1765); wait for its loading label
# longer stand in for "the player has finished loading". Wait for the detail # rather than using Pause as readiness.
# 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.
- extendedWaitUntil: - extendedWaitUntil:
notVisible: "(?s)^Play S1E1$" notVisible: "(?s)^Play S1E1$"
timeout: 30000 timeout: 30000
@@ -51,9 +46,7 @@ tags:
timeout: 30000 timeout: 30000
- pressKey: "Remote Media Fast Forward" - pressKey: "Remote Media Fast Forward"
- 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. # Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back.
# "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.
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^Cancel$" visible: "(?s)^Cancel$"
timeout: 20000 timeout: 20000
@@ -8,10 +8,8 @@ tags:
--- ---
- runFlow: ../subflows/onboard_jellyfin.yaml - runFlow: ../subflows/onboard_jellyfin.yaml
- tapOn: "(?s)^Libraries.*" - tapOn: "(?s)^Libraries.*"
# Every seeded alphabet title carries the same dateadded, so which of them the # All seeded titles share dateadded, so accept any playable Recently Added movie
# "Recently Added" rail returns is not stable across scans. This flow only # instead of relying on an unstable tie-break.
# needs some playable movie, so accept any of them rather than pinning one and
# failing whenever the tie-break lands elsewhere.
- extendedWaitUntil: - 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, .*" 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 timeout: 15000
@@ -7,20 +7,9 @@ tags:
- settings - settings
- sheets - sheets
--- ---
# Guards 8e1904dd and c48cbf70 together. # Guards 8e1904dd and c48cbf70: phone Settings must host the sheet so one Back
# # dismisses only the sheet, while BlockSemantics hides rows behind the barrier.
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no # Start clean because preceding TV flows may leave Force TV mode enabled.
# 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.
- runFlow: ../subflows/onboard_jellyfin.yaml - runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: ../subflows/open_settings.yaml - runFlow: ../subflows/open_settings.yaml
- assertVisible: "(?s)^Services.*" - assertVisible: "(?s)^Services.*"
@@ -29,10 +18,8 @@ tags:
- extendedWaitUntil: - extendedWaitUntil:
visible: "Maestro Movies" visible: "Maestro Movies"
timeout: 15000 timeout: 15000
# The barrier swallows every pointer event, so the rows behind it must leave # The barrier removes covered rows from semantics; the sheet title remains
# the semantics tree too. Without BlockSemantics an occluded row still reads as # visible because it belongs to the sheet.
# 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.
- assertNotVisible: "(?s)^Services.*" - assertNotVisible: "(?s)^Services.*"
- assertNotVisible: "(?s)^Video Playback.*" - assertNotVisible: "(?s)^Video Playback.*"
- assertNotVisible: "(?s)^Appearance.*" - assertNotVisible: "(?s)^Appearance.*"
@@ -41,15 +28,13 @@ tags:
- waitForAnimationToEnd: - waitForAnimationToEnd:
timeout: 3000 timeout: 3000
- assertNotVisible: "Maestro Movies" - assertNotVisible: "Maestro Movies"
# One Back closed only the sheet: Settings is still the current route.
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^Manage Libraries.*" visible: "(?s)^Manage Libraries.*"
timeout: 10000 timeout: 10000
- assertVisible: "(?s)^Services.*" - assertVisible: "(?s)^Services.*"
- assertVisible: "(?s)^Video Playback.*" - assertVisible: "(?s)^Video Playback.*"
- assertNotVisible: "Discover" - assertNotVisible: "Discover"
# Reopening proves the host did not strand the one-shot dedup marker: a second # Reopening verifies the one-shot pop deduplication marker was not stranded.
# dismissal has to work exactly like the first.
- tapOn: "(?s)^Manage Libraries.*" - tapOn: "(?s)^Manage Libraries.*"
- extendedWaitUntil: - extendedWaitUntil:
visible: "Maestro Movies" visible: "Maestro Movies"
@@ -7,20 +7,10 @@ tags:
- playback - playback
- subtitles - subtitles
--- ---
# Guards 468d6804. When a source advertises tracks the native list has not # Guards 468d6804: a user pick must retire TrackManager's pending automatic
# produced yet, TrackManager arms an automatic selection pass with a 5s first # selection pass, or the server preference can overwrite it up to 30s later.
# attempt and a 25s deadline. A user pick made inside that window used to only # The codec fixture supplies non-default audio and subtitle choices, and clean
# persist the preference, leaving the pass armed, so the choice snapped back to # state prevents remembered selections from making the assertions vacuous.
# 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.
- runFlow: ../subflows/onboard_jellyfin.yaml - runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: - runFlow:
file: ../subflows/open_codec_sample.yaml file: ../subflows/open_codec_sample.yaml
@@ -7,16 +7,9 @@ tags:
- settings - settings
- i18n - i18n
--- ---
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in # Guards 7677d159 and 100d7729: selecting Turkish and reading root navigation
# appearance_settings_screen.dart, so a missing arm is a compile error, but a # labels proves the generated locale is wired end to end. Start clean because
# locale whose JSON never reached the generator ships a picker entry that falls # the language preference persists and affects every later selector.
# 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.
- runFlow: ../subflows/onboard_jellyfin.yaml - runFlow: ../subflows/onboard_jellyfin.yaml
- runFlow: ../subflows/open_settings.yaml - runFlow: ../subflows/open_settings.yaml
- tapOn: "(?s)^Appearance.*" - tapOn: "(?s)^Appearance.*"
@@ -54,9 +47,7 @@ tags:
- assertVisible: "Keşfet" - assertVisible: "Keşfet"
- assertNotVisible: "(?s)^Libraries.*" - assertNotVisible: "(?s)^Libraries.*"
- assertNotVisible: "Discover" - assertNotVisible: "Discover"
# Walk back through the Turkish tree and restore English. The pref outlives a # Restore English before leaving the device; the locale preference outlives launchApp.
# plain launchApp, so leaving the device in Turkish would break every later
# flow in the suite.
- repeat: - repeat:
times: 3 times: 3
while: while:
@@ -7,21 +7,11 @@ tags:
- tv - tv
- settings - settings
--- ---
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV # Guards ef310459 and the Apple-only gates touched by 6c14049e.
# settings navigation generally. # This flow intentionally does not pin 15b54e2e's row density: semantics stay
# # stable across that change, and Maestro has no DPR-relative height assertion.
# It deliberately does NOT guard the 15b54e2e density change. That commit took # Use D-pad only; a tap switches InputModeTracker to pointer mode and removes
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in # the TV number spinner.
# 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.
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml - runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
- pressKey: "Remote Dpad Left" - pressKey: "Remote Dpad Left"
- extendedWaitUntil: - extendedWaitUntil:
+4 -14
View File
@@ -1,20 +1,10 @@
appId: com.edde746.plezy appId: com.edde746.plezy
--- ---
# Reach a signed-in Home without paying for onboarding that already happened. # Reach a signed-in Home without repeating onboarding. A plain launch preserves
# # stored state (~16s versus ~59s for retyping credentials); fall back to full
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and # onboarding when no session exists so each flow remains runnable alone.
# 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.
- launchApp - launchApp
# Settle on a known screen first. Branching straight off `launchApp` races the # Wait for the splash to resolve before deciding whether onboarding is needed.
# 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.
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*" visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
timeout: 30000 timeout: 30000
+2 -4
View File
@@ -10,10 +10,8 @@ appId: com.edde746.plezy
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^(?:Connect to Jellyfin|Wait).*" visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
timeout: 30000 timeout: 30000
# A miss on `tapOn ... optional: true` still runs Maestro's full element # Optional taps still pay Maestro's full search cost when they miss; guard them
# search before giving up, which measured 3s here and 7.8s for "Sign in" # with one visibility check instead.
# on every onboarding. These taps normally find nothing, so gate them on
# a single visibility check instead.
- runFlow: - runFlow:
when: when:
visible: "Wait" visible: "Wait"
@@ -1,12 +1,7 @@
appId: com.edde746.plezy appId: com.edde746.plezy
--- ---
# Onboarding for a device that already reports itself as a TV. # 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.
# `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.
- retry: - retry:
maxRetries: 1 maxRetries: 1
commands: commands:
@@ -32,9 +27,8 @@ appId: com.edde746.plezy
- tapOn: "(?s)Server URLs.*" - tapOn: "(?s)Server URLs.*"
- inputText: ${JELLYFIN_URL} - inputText: ${JELLYFIN_URL}
- tapOn: "Find server" - tapOn: "Find server"
# A TV-sized layout offers Quick Connect first because typing on a remote is # Quick Connect is awkward on a remote and needs another device, so use the
# slow. The code path needs a second device to approve, so fall back to the # username form as fallback.
# username form the phone flow already uses.
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^(?:Username|Cancel)$" visible: "(?s)^(?:Username|Cancel)$"
timeout: 30000 timeout: 30000
+2 -4
View File
@@ -17,10 +17,8 @@ appId: com.edde746.plezy
platform: Android platform: Android
commands: commands:
- hideKeyboard - hideKeyboard
# A card announces its watch state three ways, and the codec samples keep a # Codec cards retain watch state and resume position between flows, so accept
# resume position on the server once any earlier flow has played them, so the # any valid watch-state label to keep this subflow rerunnable.
# partial form has to be accepted for this subflow to be re-runnable against a
# fixture container that is not thrown away between suites.
- extendedWaitUntil: - extendedWaitUntil:
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$" visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
timeout: 15000 timeout: 15000
+2 -6
View File
@@ -1,11 +1,7 @@
appId: com.edde746.plezy appId: com.edde746.plezy
--- ---
# Reach Settings from a signed-in Home on a phone layout. # Reach phone Settings from signed-in Home. The profile menu is an overlay
# # sheet; retry while the previous route settles.
# 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.
- extendedWaitUntil: - extendedWaitUntil:
visible: "Discover" visible: "Discover"
timeout: 30000 timeout: 30000
+4 -5
View File
@@ -15,8 +15,7 @@ linter:
prefer_final_in_for_each: true prefer_final_in_for_each: true
avoid_print: true avoid_print: true
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes # DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly.
# on Linux. CI invokes the supported check-unused commands explicitly.
dart_code_linter: dart_code_linter:
rules-exclude: rules-exclude:
- "test/**" - "test/**"
@@ -27,7 +26,7 @@ dart_code_linter:
- package:dart_code_linter/presets/recommended.yaml - package:dart_code_linter/presets/recommended.yaml
rules: rules:
# --- Flutter rules (on top of recommended) --- # Flutter-specific rules.
- avoid-border-all - avoid-border-all
- avoid-shrink-wrap-in-lists - avoid-shrink-wrap-in-lists
- avoid-expanded-as-spacer - avoid-expanded-as-spacer
@@ -37,7 +36,7 @@ dart_code_linter:
- prefer-define-hero-tag - prefer-define-hero-tag
- use-setstate-synchronously - use-setstate-synchronously
# --- Additional useful Dart rules --- # Additional Dart rules.
- avoid-cascade-after-if-null - avoid-cascade-after-if-null
- avoid-collection-methods-with-unrelated-types - avoid-collection-methods-with-unrelated-types
- avoid-unnecessary-type-assertions - avoid-unnecessary-type-assertions
@@ -48,7 +47,7 @@ dart_code_linter:
- prefer-enums-by-name - prefer-enums-by-name
- prefer-commenting-analyzer-ignores - prefer-commenting-analyzer-ignores
# --- Disable noisy rules from recommended preset --- # Disabled noisy rules.
- no-magic-number: false - no-magic-number: false
- avoid-dynamic: false - avoid-dynamic: false
- format-comment: false - format-comment: false
-2
View File
@@ -336,8 +336,6 @@ android {
defaultConfig { defaultConfig {
applicationId = "com.edde746.plezy" 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 minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode versionCode = flutter.versionCode
-13
View File
@@ -9,17 +9,14 @@
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 --> <!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" /> <uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
<!-- Internet access permissions -->
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/> <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
<!-- PIP and media session foreground service permissions -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
<uses-permission android:name="android.permission.REORDER_TASKS"/> <uses-permission android:name="android.permission.REORDER_TASKS"/>
<!-- Background download notifications and foreground service -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/> <uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
@@ -30,15 +27,11 @@
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" /> <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
<!-- Android TV support (not required, but allow detection) -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
<!-- Android TV Watch Next integration -->
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/> <uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/> <uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<!-- Touchscreen not required for TV -->
<uses-feature android:name="android.hardware.touchscreen" android:required="false" /> <uses-feature android:name="android.hardware.touchscreen" android:required="false" />
<!-- Android Automotive OS support -->
<uses-feature android:name="android.hardware.type.automotive" android:required="false" /> <uses-feature android:name="android.hardware.type.automotive" android:required="false" />
<application <application
@@ -63,10 +56,6 @@
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true" android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize"> android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data <meta-data
android:name="io.flutter.embedding.android.NormalTheme" android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme" android:resource="@style/NormalTheme"
@@ -77,7 +66,6 @@
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN"/> <action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/> <category android:name="android.intent.category.LAUNCHER"/>
<!-- Allow app to appear in Android TV launcher -->
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/> <category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
</intent-filter> </intent-filter>
<!-- Deep link handler for Watch Next items --> <!-- Deep link handler for Watch Next items -->
@@ -88,7 +76,6 @@
<data android:scheme="plezy" android:host="play"/> <data android:scheme="plezy" android:host="play"/>
</intent-filter> </intent-filter>
</activity> </activity>
<!-- FileProvider for sharing downloaded files with external players -->
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="com.edde746.plezy.fileprovider" android:authorities="com.edde746.plezy.fileprovider"
@@ -119,7 +119,6 @@ class MainActivity : FlutterActivity() {
} }
} }
// Auto PiP state
private var autoPipReady = false private var autoPipReady = false
private var autoPipWidth: Int = 16 private var autoPipWidth: Int = 16
private var autoPipHeight: Int = 9 private var autoPipHeight: Int = 9
@@ -106,7 +106,6 @@ internal class TrueHdCarrierSink(
@Volatile @Volatile
private var mismatchGeneration = -1 private var mismatchGeneration = -1
// --- Selection ---
/** /**
* True when this format should ride the carrier. * True when this format should ride the carrier.
@@ -193,7 +192,6 @@ internal class TrueHdCarrierSink(
} }
} }
// --- Stream path: active delegate only ---
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean { override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount) if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
@@ -96,7 +96,6 @@ class MpvPlayerCore private constructor(
// output (#1482). // output (#1482).
private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1) private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1)
// Frame rate matching
private var frameRateManager: FrameRateManager? = null private var frameRateManager: FrameRateManager? = null
private val handler = Handler(Looper.getMainLooper()) private val handler = Handler(Looper.getMainLooper())
@@ -110,7 +109,6 @@ class MpvPlayerCore private constructor(
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block) if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
} }
// Audio focus
private var audioFocusManager: AudioFocusManager? = null private var audioFocusManager: AudioFocusManager? = null
@Volatile private var cachedPaused: Boolean = true @Volatile private var cachedPaused: Boolean = true
@@ -275,7 +273,6 @@ class MpvPlayerCore private constructor(
Log.d(TAG, "SurfaceView added to content view") Log.d(TAG, "SurfaceView added to content view")
} }
// Create MpvPlayer on background thread via coroutine
scope.launch { scope.launch {
try { try {
if (disposing) { if (disposing) {
@@ -74,7 +74,6 @@ open class MpvPlayerPlugin(
private var initAttemptCounter = 0 private var initAttemptCounter = 0
private var activeInitAttempt: Int? = null private var activeInitAttempt: Int? = null
// FlutterPlugin
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
applicationContext = binding.applicationContext applicationContext = binding.applicationContext
@@ -104,7 +103,6 @@ open class MpvPlayerPlugin(
takeCoreForTeardown()?.dispose() takeCoreForTeardown()?.dispose()
} }
// ActivityAware
override fun onAttachedToActivity(binding: ActivityPluginBinding) { override fun onAttachedToActivity(binding: ActivityPluginBinding) {
activity = binding.activity activity = binding.activity
@@ -141,7 +139,6 @@ open class MpvPlayerPlugin(
Log.d(tag, "Detached from activity for config changes") Log.d(tag, "Detached from activity for config changes")
} }
// EventChannel.StreamHandler
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
channels.listen(events) channels.listen(events)
@@ -151,7 +148,6 @@ open class MpvPlayerPlugin(
channels.cancel() channels.cancel()
} }
// MethodChannel.MethodCallHandler
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
@@ -28,7 +28,6 @@ class BufferingStallPolicyTest {
loading = loading loading = loading
) )
// The loader's own state, for the case media3 never answers
@Test @Test
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() { fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
@@ -51,7 +50,6 @@ class BufferingStallPolicyTest {
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false)) assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
} }
// The load control's own verdict
@Test @Test
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() { fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
@@ -83,7 +81,6 @@ class BufferingStallPolicyTest {
) )
} }
// Playback speed
@Test @Test
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() { fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
@@ -107,7 +104,6 @@ class BufferingStallPolicyTest {
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f)) assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
} }
// Progress
@Test @Test
fun advancingPositionIsHealthy() { fun advancingPositionIsHealthy() {
@@ -138,7 +134,6 @@ class BufferingStallPolicyTest {
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000)) assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
} }
// Timeout
@Test @Test
fun frozenPositionWaitsOutTheTimeout() { fun frozenPositionWaitsOutTheTimeout() {
@@ -151,7 +146,6 @@ class BufferingStallPolicyTest {
assertEquals(Verdict.STALLED, evaluate()) assertEquals(Verdict.STALLED, evaluate())
} }
// Starvation — the loader's problem, not the renderer's
@Test @Test
fun emptyBufferIsStarved() { fun emptyBufferIsStarved() {
@@ -203,7 +197,6 @@ class BufferingStallPolicyTest {
) )
} }
// Stall clock ownership
@Test @Test
fun starvationAndProgressBothRestartTheStallClock() { fun starvationAndProgressBothRestartTheStallClock() {
@@ -10,7 +10,6 @@ class DvBitstreamSanitizerTest {
private val sanitizer = DvBitstreamSanitizer() private val sanitizer = DvBitstreamSanitizer()
// --- HDR10+ SEI stripping (native DV codec path) ---
@Test @Test
fun stripsHdr10PlusPrefixSeiBetweenVclNals() { fun stripsHdr10PlusPrefixSeiBetweenVclNals() {
@@ -85,7 +84,6 @@ class DvBitstreamSanitizerTest {
assertArrayEquals(original, remainingBytes(buffer)) assertArrayEquals(original, remainingBytes(buffer))
} }
// --- DV RPU/EL stripping (HEVC fallback path) ---
@Test @Test
fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() { fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() {
@@ -111,7 +109,6 @@ class DvBitstreamSanitizerTest {
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer)) assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
} }
// --- Buffer handling ---
@Test @Test
fun respectsPositionAndRestoresIt() { fun respectsPositionAndRestoresIt() {
@@ -245,7 +242,6 @@ class DvBitstreamSanitizerTest {
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer)) assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
} }
// --- Helpers ---
/** Builds an HEVC NAL unit: start code + 2-byte NAL header encoding [nalUnitType] + payload. */ /** Builds an HEVC NAL unit: start code + 2-byte NAL header encoding [nalUnitType] + payload. */
private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray { private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray {
@@ -24,7 +24,6 @@ class EndOfStreamPolicyTest {
frameStallMs = frameStallMs frameStallMs = frameStallMs
) )
// isFinishedFile
@Test @Test
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() { fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
@@ -65,7 +64,6 @@ class EndOfStreamPolicyTest {
assertFalse(isFinishedFile(hasPlaybackOutput = false)) assertFalse(isFinishedFile(hasPlaybackOutput = false))
} }
// fallbackStartPositionMs
@Test @Test
fun fallbackResumesStrictlyInsideTheMedia() { fun fallbackResumesStrictlyInsideTheMedia() {
@@ -9,7 +9,6 @@ private const val MIB = 1024 * 1024
class LoadControlPolicyTest { class LoadControlPolicyTest {
// autoTargetBufferBytes
@Test @Test
fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() { fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() {
@@ -64,7 +63,6 @@ class LoadControlPolicyTest {
) )
} }
// readAheadSeconds
@Test @Test
fun readAheadReportsSecondsAtAKnownBitrate() { fun readAheadReportsSecondsAtAKnownBitrate() {
@@ -86,7 +84,6 @@ class LoadControlPolicyTest {
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L)) assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
} }
// bufferDurations
/** /**
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than * media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
@@ -172,7 +169,6 @@ class LoadControlPolicyTest {
} }
} }
// BufferTier.fromWire
@Test @Test
fun theWireNamesMatchTheDartNativeValues() { fun theWireNamesMatchTheDartNativeValues() {
@@ -52,7 +52,6 @@ class RawPositionAudioOutputTest {
return output to listener return output to listener
} }
// Release reporting
/** /**
* A parked track is never going to release, so its flush has to be answered at once. Deferring * A parked track is never going to release, so its flush has to be answered at once. Deferring
@@ -116,7 +115,6 @@ class RawPositionAudioOutputTest {
assertEquals(1, listener.releasedCount) assertEquals(1, listener.releasedCount)
} }
// Reuse
@Test @Test
fun aParkedOutputIsHandedBackForAnIdenticalConfig() { fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
@@ -178,7 +176,6 @@ class RawPositionAudioOutputTest {
assertEquals(1, secondListener.underrunCount) assertEquals(1, secondListener.underrunCount)
} }
// Eviction — the overlap media3 itself tolerates, kept tolerable
/** /**
* The replacement is deliberately built while the evicted track is still going away. Refusing * The replacement is deliberately built while the evicted track is still going away. Refusing
@@ -221,7 +218,6 @@ class RawPositionAudioOutputTest {
assertEquals(1, listener.releasedCount) assertEquals(1, listener.releasedCount)
} }
// Fakes
private class RecordingListener : AudioOutput.Listener { private class RecordingListener : AudioOutput.Listener {
var releasedCount = 0 var releasedCount = 0
@@ -7,7 +7,6 @@ import org.junit.Test
class ResumeStallPolicyTest { class ResumeStallPolicyTest {
// checkWindowMs
@Test @Test
fun windowFloorsAtDefaultForNormalFrameRates() { fun windowFloorsAtDefaultForNormalFrameRates() {
@@ -39,7 +38,6 @@ class ResumeStallPolicyTest {
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f)) assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
} }
// evaluate
@Test @Test
fun advancingFramesAreHealthy() { fun advancingFramesAreHealthy() {
@@ -51,7 +51,6 @@ class TrueHdCarrierSinkTest {
blocked: Boolean = false blocked: Boolean = false
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked }) ) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
// --- Selection ---
/** /**
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3 * TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
@@ -154,7 +153,6 @@ class TrueHdCarrierSinkTest {
assertEquals(0, listener.capabilityInvalidations) assertEquals(0, listener.capabilityInvalidations)
} }
// --- Rate-family mismatch discovered mid-stream ---
/** /**
* Selection reads Format.sampleRate; the rate family is only certain once a major sync is parsed. * Selection reads Format.sampleRate; the rate family is only certain once a major sync is parsed.
@@ -289,7 +287,6 @@ class TrueHdCarrierSinkTest {
assertTrue(carrierSink.supportsFormat(ac3)) assertTrue(carrierSink.supportsFormat(ac3))
} }
// --- Back pressure ---
/** /**
* The regression this exists for: a delegate that refuses a burst part-way through a sample must * The regression this exists for: a delegate that refuses a burst part-way through a sample must
@@ -336,7 +333,6 @@ class TrueHdCarrierSinkTest {
assertTrue(carrierSink.hasPendingData()) assertTrue(carrierSink.hasPendingData())
} }
// --- Routing of controls ---
@Test @Test
fun persistentControlsReachBothDelegates() { fun persistentControlsReachBothDelegates() {
-3
View File
@@ -11,7 +11,6 @@ default_platform(:android)
platform :android do platform :android do
desc "Build and deploy to Google Play Store" desc "Build and deploy to Google Play Store"
lane :release do lane :release do
# Get version from pubspec.yaml
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml") pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
pubspec_content = File.read(pubspec_path) pubspec_content = File.read(pubspec_path)
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/) version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
@@ -26,7 +25,6 @@ platform :android do
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
# Build the Flutter app
sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json") sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab") sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab")
@@ -37,7 +35,6 @@ platform :android do
aab: "../build/app/outputs/bundle/release/app-release.aab" aab: "../build/app/outputs/bundle/release/app-release.aab"
) )
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json") sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json")
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk") sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
-1
View File
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> <svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3396"> <metadata id="metadata3396">
<rdf:RDF> <rdf:RDF>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

-1
View File
@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/"> <svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
<metadata id="metadata3886"> <metadata id="metadata3886">
<rdf:RDF> <rdf:RDF>

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.5 KiB

-1
View File
@@ -1,4 +1,3 @@
# Uncomment this line to define a global platform for your project
platform :ios, '15.5' platform :ios, '15.5'
# CocoaPods analytics sends network stats synchronously affecting flutter build latency. # CocoaPods analytics sends network stats synchronously affecting flutter build latency.
+1 -5
View File
@@ -14,9 +14,7 @@ import MediaPlayer
_ application: UIApplication, _ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool { ) -> Bool {
// Configure audio session for media playback. Not activated here: the // Configure the non-mixing session; activate it only when playback starts.
// session is non-mixing, so activation stops other apps' audio it is
// claimed when playback actually starts.
do { do {
let session = AVAudioSession.sharedInstance() let session = AVAudioSession.sharedInstance()
try session.setCategory(.playback, mode: .default) try session.setCategory(.playback, mode: .default)
@@ -32,12 +30,10 @@ import MediaPlayer
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) { func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry) GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
// Register MPV player plugin
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") { if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") {
MpvPlayerPlugin.register(with: registrar) MpvPlayerPlugin.register(with: registrar)
} }
// Register the audio-only MPV player plugin (music playback)
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") { if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") {
MpvAudioPlayerPlugin.register(with: registrar) MpvAudioPlayerPlugin.register(with: registrar)
} }
@@ -39,19 +39,13 @@ import UIKit
/// Delegate to notify the plugin of PiP lifecycle events /// Delegate to notify the plugin of PiP lifecycle events
protocol MpvPipDelegate: AnyObject { protocol MpvPipDelegate: AnyObject {
/// Called when PiP is about to start (system or app-initiated)
func pipWillStart() func pipWillStart()
func pipDidStart() func pipDidStart()
/// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI).
func pipDidStop(restored: Bool) func pipDidStop(restored: Bool)
func pipDidFailToStart(error: Error?) func pipDidFailToStart(error: Error?)
/// Forward play/pause commands from PiP overlay to mpv
func pipSetPlaying(_ playing: Bool) func pipSetPlaying(_ playing: Bool)
/// Forward skip forward/backward commands from PiP overlay to mpv
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void) func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
/// Query whether mpv is currently playing
var isPipPlaying: Bool { get } var isPipPlaying: Bool { get }
/// Get total duration in seconds
var pipDuration: Double { get } var pipDuration: Double { get }
} }
protocol MpvPictureInPictureControlling: AnyObject { protocol MpvPictureInPictureControlling: AnyObject {
@@ -151,7 +145,6 @@ import UIKit
createPipController() createPipController()
} }
/// Helper that conforms to the iOS 15+ delegate protocols
private var delegateHelper: AnyObject? private var delegateHelper: AnyObject?
private func createPipController() { private func createPipController() {
@@ -12,12 +12,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
private weak var registrar: FlutterPluginRegistrar? private weak var registrar: FlutterPluginRegistrar?
var nameToId: [String: Int] = [:] var nameToId: [String: Int] = [:]
// MpvPluginShared conformance
var coreBase: MpvPlayerCoreBase? { playerCore } var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) } func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
func updatePlayerFrame() { playerCore?.updateFrame() } func updatePlayerFrame() { playerCore?.updateFrame() }
// PiP
private var pipController: MpvPipController? private var pipController: MpvPipController?
private var pipChannel: FlutterMethodChannel? private var pipChannel: FlutterMethodChannel?
private var autoPipEnabled = false private var autoPipEnabled = false
@@ -207,7 +205,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
return return
} }
pip.setAutoStart(true) pip.setAutoStart(true)
// Warm the layer so the system considers PiP possible
if let pc = self.playerCore { if let pc = self.playerCore {
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused) 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) { private func cleanupPip(notify: Bool, pause: Bool = false) {
playerCore?.setPipSubtitleCompositing(false) playerCore?.setPipSubtitleCompositing(false)
playerCore?.isPipStarting = false playerCore?.isPipStarting = false
-3
View File
@@ -13,13 +13,10 @@ platform :ios do
lane :deploy_appstore do lane :deploy_appstore do
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip 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") 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") sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
# Upload to App Store
upload_to_app_store( upload_to_app_store(
ipa: "../build/ios/ipa/Plezy.ipa", ipa: "../build/ios/ipa/Plezy.ipa",
force: true, force: true,
-1
View File
@@ -119,7 +119,6 @@ class AppDatabase extends _$AppDatabase {
// migrations while failures are still covered by this close/rethrow // migrations while failures are still covered by this close/rethrow
// boundary and the caller's startup download-recovery decision. // boundary and the caller's startup download-recovery decision.
await database.customSelect('SELECT 1').get(); await database.customSelect('SELECT 1').get();
// It deliberately does not claim capacity for a later write.
} }
final outcome = await _tvosRecoveryQueue.run( final outcome = await _tvosRecoveryQueue.run(
() => store.reconcile( () => store.reconcile(
-2
View File
@@ -74,7 +74,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
final double viewportHeight = scrollController.position.viewportDimension; final double viewportHeight = scrollController.position.viewportDimension;
final double viewportBottom = viewportTop + viewportHeight; final double viewportBottom = viewportTop + viewportHeight;
// Already fully visible — skip
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return; if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
final double destination = (targetTop - viewportHeight * 0.25).clamp( final double destination = (targetTop - viewportHeight * 0.25).clamp(
@@ -104,7 +103,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
final backResult = handleBackKeyAction(event, () { final backResult = handleBackKeyAction(event, () {
if (movingIndex != null) { if (movingIndex != null) {
// Cancel move - restore original position
setState(() { setState(() {
final originalOrder = _originalOrder; final originalOrder = _originalOrder;
if (originalOrder != null) { if (originalOrder != null) {
-3
View File
@@ -42,7 +42,6 @@ class FocusMemoryTracker {
/// Restore focus to the last focused item, or fallback if provided /// Restore focus to the last focused item, or fallback if provided
/// Returns true if focus was successfully restored /// Returns true if focus was successfully restored
bool restoreFocus({String? fallbackKey}) { bool restoreFocus({String? fallbackKey}) {
// Try to restore last focused item
if (_lastFocusedKey != null) { if (_lastFocusedKey != null) {
final node = _nodes[_lastFocusedKey]; final node = _nodes[_lastFocusedKey];
if (node != null) { if (node != null) {
@@ -50,7 +49,6 @@ class FocusMemoryTracker {
return true; return true;
} }
} }
// Fallback: focus the provided key if available
if (fallbackKey != null) { if (fallbackKey != null) {
final node = _nodes[fallbackKey]; final node = _nodes[fallbackKey];
if (node != null) { if (node != null) {
@@ -69,7 +67,6 @@ class FocusMemoryTracker {
_nodes.remove(key); _nodes.remove(key);
_focused.remove(key); _focused.remove(key);
} }
// Clear last focused if it was pruned
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) { if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
_lastFocusedKey = null; _lastFocusedKey = null;
} }
-1
View File
@@ -51,7 +51,6 @@ class _FocusableButtonState extends State<FocusableButton> {
final showFocus = _isFocused && isKeyboard; final showFocus = _isFocused && isKeyboard;
final duration = FocusTheme.getAnimationDuration(context); final duration = FocusTheme.getAnimationDuration(context);
final enabled = widget.onPressed != null; final enabled = widget.onPressed != null;
// In dpad mode: focused = full opacity, unfocused = dimmed
final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0; final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0;
return FocusableWrapper( return FocusableWrapper(
+3 -22
View File
@@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox {
/// A wrapper widget that makes its child focusable with D-pad navigation support. /// 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 { class FocusableWrapper extends StatefulWidget {
/// The child widget to wrap. /// The child widget to wrap.
final Widget child; final Widget child;
@@ -287,12 +281,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
void didUpdateWidget(FocusableWrapper oldWidget) { void didUpdateWidget(FocusableWrapper oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// Handle focusNode changes
if (widget.focusNode != oldWidget.focusNode) { if (widget.focusNode != oldWidget.focusNode) {
_bindFocusNode(); _bindFocusNode();
} }
// Update canRequestFocus
if (widget.canRequestFocus != oldWidget.canRequestFocus) { if (widget.canRequestFocus != oldWidget.canRequestFocus) {
_focusNode.canRequestFocus = widget.canRequestFocus; _focusNode.canRequestFocus = widget.canRequestFocus;
} }
@@ -368,7 +360,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
final viewport = scrollable.context.findRenderObject() as RenderBox?; final viewport = scrollable.context.findRenderObject() as RenderBox?;
if (viewport == null) return; if (viewport == null) return;
// Get item's position relative to viewport
final itemBox = renderObject as RenderBox; final itemBox = renderObject as RenderBox;
final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport); final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport);
@@ -376,17 +367,14 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
final itemHeight = itemBox.size.height; final itemHeight = itemBox.size.height;
final itemVerticalCenter = itemPosition.dy + itemHeight / 2; final itemVerticalCenter = itemPosition.dy + itemHeight / 2;
// Account for focus decoration when checking item visibility
final itemTop = itemPosition.dy - _focusDecorationPadding; final itemTop = itemPosition.dy - _focusDecorationPadding;
final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding; final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding;
if (widget.useComfortableZone) { 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 comfortZoneTop = viewportHeight * 0.2;
final comfortZoneBottom = viewportHeight * 0.8; final comfortZoneBottom = viewportHeight * 0.8;
if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) { if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) {
// Item is in comfortable zone, no need to scroll
return; return;
} }
} else { } else {
@@ -394,22 +382,17 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
// close to target position (prevents jitter when navigating horizontally) // close to target position (prevents jitter when navigating horizontally)
final targetY = viewportHeight * widget.scrollAlignment; final targetY = viewportHeight * widget.scrollAlignment;
final distance = (itemVerticalCenter - targetY).abs(); final distance = (itemVerticalCenter - targetY).abs();
// Skip scroll if within half the item height of target
if (distance < itemHeight / 2) { if (distance < itemHeight / 2) {
return; return;
} }
} }
// Calculate target scroll offset for the immediate scrollable only. // Avoid Scrollable.ensureVisible, which scrolls all ancestor scrollables and
// This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables, // can move nested views (e.g. the chips bar) out of view when focusing grid items.
// which can cause issues with nested scroll views (e.g., chips bar scrolling
// out of view when focusing grid items in library browse tab).
final position = scrollable.position; final position = scrollable.position;
final currentOffset = position.pixels; 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; final targetViewportY = viewportHeight * widget.scrollAlignment;
var scrollDelta = itemVerticalCenter - targetViewportY; var scrollDelta = itemVerticalCenter - targetViewportY;
// If item would be near the top edge, add extra scroll to show focus decoration // If item would be near the top edge, add extra scroll to show focus decoration
@@ -471,7 +454,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
} }
} }
// Handle SELECT key with optional long-press detection
if (key.isSelectKey) { if (key.isSelectKey) {
if (widget.enableLongPress) { if (widget.enableLongPress) {
final result = _selectLongPress.handleKeyEvent( final result = _selectLongPress.handleKeyEvent(
@@ -547,7 +529,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
} else { } else {
final duration = FocusTheme.getAnimationDuration(context); final duration = FocusTheme.getAnimationDuration(context);
final controller = _ensureAnimationController(); final controller = _ensureAnimationController();
// Update animation duration if theme changes
if (controller.duration != duration) { if (controller.duration != duration) {
controller.duration = duration; controller.duration = duration;
} }
-2
View File
@@ -85,9 +85,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
// is identity-guarded — otherwise startup's bootstrap→app swap would leave // is identity-guarded — otherwise startup's bootstrap→app swap would leave
// the live registration cleared. // the live registration cleared.
InputModeTracker._instance = this; InputModeTracker._instance = this;
// Initialize focus highlight strategy based on starting mode
_updateFocusHighlightStrategy(_mode); _updateFocusHighlightStrategy(_mode);
// Listen to hardware keyboard events globally
HardwareKeyboard.instance.addHandler(_handleKeyEvent); HardwareKeyboard.instance.addHandler(_handleKeyEvent);
} }
-13
View File
@@ -114,13 +114,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
DownloadProvider({required this._downloadManager, required this._database}) DownloadProvider({required this._downloadManager, required this._database})
: _syncRuleExecutor = SyncRuleExecutor(database: _database) { : _syncRuleExecutor = SyncRuleExecutor(database: _database) {
_metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged); _metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged);
// Listen to progress updates from the download manager
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate); _progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
// Listen to deletion progress updates
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate); _deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
// Load persisted downloads from database
_initFuture = _loadPersistedDownloads(); _initFuture = _loadPersistedDownloads();
// Lets the diagnostics service score whether downloads actually advance // 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 // Initialize artwork directory path for synchronous access
await storageService.getArtworkDirectory(); await storageService.getArtworkDirectory();
// Load all downloads from database
final downloads = await _downloadManager.getAllDownloads(); final downloads = await _downloadManager.getAllDownloads();
// Bulk-load all pinned metadata across every backend in a single pass // 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(); await _loadSyncRules();
// Apply queued offline watch actions on top of the server-time metadata // Apply queued offline watch actions on top of the server-time metadata
@@ -844,7 +839,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
return null; return null;
} }
// Calculate aggregate statistics
int completedCount = 0; int completedCount = 0;
int downloadingCount = 0; int downloadingCount = 0;
int queuedCount = 0; int queuedCount = 0;
@@ -867,7 +861,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
} }
} }
// Determine overall status
final DownloadStatus overallStatus; final DownloadStatus overallStatus;
if (completedCount == totalEpisodes) { if (completedCount == totalEpisodes) {
overallStatus = DownloadStatus.completed; overallStatus = DownloadStatus.completed;
@@ -912,22 +905,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
/// For shows/seasons, returns aggregate progress of all child episodes /// For shows/seasons, returns aggregate progress of all child episodes
/// For episodes/movies, returns direct progress /// For episodes/movies, returns direct progress
DownloadProgress? getProgress(String globalKey) { DownloadProgress? getProgress(String globalKey) {
// First check if we have direct progress (for episodes/movies)
final directProgress = _downloads[globalKey]; final directProgress = _downloads[globalKey];
if (directProgress != null) { if (directProgress != null) {
if (!_ownsDownloadKey(globalKey)) return null; if (!_ownsDownloadKey(globalKey)) return null;
return directProgress; return directProgress;
} }
// If no direct progress, check if this is a show or season
// and calculate aggregate progress from episodes
final parsed = parseGlobalKey(globalKey); final parsed = parseGlobalKey(globalKey);
if (parsed == null) return null; if (parsed == null) return null;
final serverId = parsed.serverId; final serverId = parsed.serverId;
final ratingKey = parsed.ratingKey; final ratingKey = parsed.ratingKey;
// Try to get metadata to determine type
final meta = _metadata[globalKey]; final meta = _metadata[globalKey];
if (meta == null) { if (meta == null) {
// No metadata stored yet, might be a container (show/season/artist/ // 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); await _claimDownloadForProfile(globalKey, ownership, client);
if (!_isQueueOwnershipCurrent(ownership)) return false; if (!_isQueueOwnershipCurrent(ownership)) return false;
// Update local state immediately for UI feedback
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued); _downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
safeNotifyListeners(); safeNotifyListeners();
// Actually trigger download via DownloadManagerService
if (!_isQueueOwnershipCurrent(ownership)) return false; if (!_isQueueOwnershipCurrent(ownership)) return false;
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex); await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
return true; return true;
-3
View File
@@ -303,10 +303,8 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
return libraries; return libraries;
} }
// Create a map for quick lookup
final libraryMap = {for (final lib in libraries) lib.globalKey: lib}; final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
// Build ordered list based on saved order
final orderedLibraries = <MediaLibrary>[]; final orderedLibraries = <MediaLibrary>[];
for (final key in savedOrder) { for (final key in savedOrder) {
final lib = libraryMap.remove(key); 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); orderedLibraries.addAll(libraryMap.values);
return orderedLibraries; return orderedLibraries;
-1
View File
@@ -175,7 +175,6 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi
if (_isInitialized) return; if (_isInitialized) return;
_isInitialized = true; _isInitialized = true;
// Check initial connectivity
await _updateConnectionFlags(); await _updateConnectionFlags();
// Monitor connectivity changes — runZonedGuarded catches async errors from // Monitor connectivity changes — runZonedGuarded catches async errors from
@@ -26,7 +26,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
final DownloadProvider _downloadProvider; final DownloadProvider _downloadProvider;
OfflineWatchProvider({required this._syncService, required this._downloadProvider}) { OfflineWatchProvider({required this._syncService, required this._downloadProvider}) {
// Listen to sync service changes to update UI
_syncService.addListener(_onSyncServiceChanged); _syncService.addListener(_onSyncServiceChanged);
} }
@@ -49,13 +48,11 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
/// ///
/// Returns true if watched, false otherwise. /// Returns true if watched, false otherwise.
Future<bool> isWatched(String globalKey) async { Future<bool> isWatched(String globalKey) async {
// First check local offline action
final localStatus = await _syncService.getLocalWatchStatus(globalKey); final localStatus = await _syncService.getLocalWatchStatus(globalKey);
if (localStatus != null) { if (localStatus != null) {
return localStatus; return localStatus;
} }
// Fall back to cached metadata
final metadata = _downloadProvider.getMetadata(globalKey); final metadata = _downloadProvider.getMetadata(globalKey);
if (metadata != null) { if (metadata != null) {
return metadata.isWatched; return metadata.isWatched;
@@ -73,7 +70,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
/// Returns null if no position is available. /// Returns null if no position is available.
@visibleForTesting @visibleForTesting
Future<int?> getViewOffset(String globalKey) async { Future<int?> getViewOffset(String globalKey) async {
// First check local offline progress
final localOffset = await _syncService.getLocalViewOffset(globalKey); final localOffset = await _syncService.getLocalViewOffset(globalKey);
if (localOffset != null) { if (localOffset != null) {
return localOffset; return localOffset;
@@ -82,7 +78,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
final localStatus = await _syncService.getLocalWatchStatus(globalKey); final localStatus = await _syncService.getLocalWatchStatus(globalKey);
if (localStatus == true) return null; if (localStatus == true) return null;
// Fall back to cached metadata
final metadata = _downloadProvider.getMetadata(globalKey); final metadata = _downloadProvider.getMetadata(globalKey);
return metadata?.viewOffsetMs; return metadata?.viewOffsetMs;
} }
@@ -127,7 +122,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes); final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
// Find first unwatched episode
for (final episode in episodes) { for (final episode in episodes) {
if (!watchStatuses[episode.globalKey]!) { if (!watchStatuses[episode.globalKey]!) {
return episode; return episode;
-11
View File
@@ -103,18 +103,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
final TvSpotlightController _spotlight = TvSpotlightController(); final TvSpotlightController _spotlight = TvSpotlightController();
bool _isTabVisible = true; bool _isTabVisible = true;
// Track initial load so we can focus hero when content first appears
bool _initialLoadComplete = false; bool _initialLoadComplete = false;
bool _pendingTvBrowseRailFocus = false; bool _pendingTvBrowseRailFocus = false;
// Hub navigation keys
GlobalKey<HubSectionState>? _continueWatchingHubKey; GlobalKey<HubSectionState>? _continueWatchingHubKey;
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {}; final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
List<GlobalKey<HubSectionState>> _orderedHubKeys = const []; List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>(); final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
final _hubFocusMemory = HubFocusMemory(); final _hubFocusMemory = HubFocusMemory();
// Hero and app bar focus
late FocusNode _heroFocusNode; late FocusNode _heroFocusNode;
final _actionBarKey = GlobalKey<FocusableActionBarState>(); final _actionBarKey = GlobalKey<FocusableActionBarState>();
final _serverActivitiesButtonKey = GlobalKey<ServerActivitiesButtonState>(); final _serverActivitiesButtonKey = GlobalKey<ServerActivitiesButtonState>();
@@ -155,7 +152,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
_continueWatchingHubKey ??= GlobalKey<HubSectionState>(); _continueWatchingHubKey ??= GlobalKey<HubSectionState>();
} }
/// Get all hub states (continue watching + other hubs)
List<GlobalKey<HubSectionState>> get _allHubKeys { List<GlobalKey<HubSectionState>> get _allHubKeys {
final keys = <GlobalKey<HubSectionState>>[]; final keys = <GlobalKey<HubSectionState>>[];
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) { if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
@@ -935,7 +931,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
if (_isLoading) LoadingIndicatorBox.sliver, if (_isLoading) LoadingIndicatorBox.sliver,
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load), if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
if (!_isLoading && _errorMessage == null) ...[ if (!_isLoading && _errorMessage == null) ...[
// On Deck / Continue Watching
if (continueWatchingHub != null) if (continueWatchingHub != null)
SliverToBoxAdapter( SliverToBoxAdapter(
child: HubSection( child: HubSection(
@@ -1259,14 +1254,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], 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; final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
// Spoiler protection
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers); final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler; final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler;
// Build semantic label for hero item
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title; final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
return Semantics( return Semantics(
@@ -1420,10 +1412,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
), ),
], ],
// On small screens: show button before summary
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
// Summary with episode info (Apple TV style)
if (heroItem.summary != null && !shouldHideSpoiler) ...[ if (heroItem.summary != null && !shouldHideSpoiler) ...[
const SizedBox(height: 12), const SizedBox(height: 12),
RichText( RichText(
@@ -1468,7 +1458,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
), ),
], ],
// On large screens: show button after summary
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)], if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
], ],
), ),
@@ -42,7 +42,6 @@ class DownloadsScreen extends StatefulWidget {
class DownloadsScreenState extends State<DownloadsScreen> class DownloadsScreenState extends State<DownloadsScreen>
with TickerProviderStateMixin, TabNavigationMixin, FocusableTab { with TickerProviderStateMixin, TabNavigationMixin, FocusableTab {
// Focus nodes for tab chips
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue'); final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows'); final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies'); final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
@@ -60,7 +59,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
@override @override
void initState() { void initState() {
super.initState(); super.initState();
suppressAutoFocus = true; // Start suppressed
initTabNavigation(); initTabNavigation();
} }
@@ -92,7 +90,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
/// Focus the first item in the currently active tab /// Focus the first item in the currently active tab
void _focusCurrentTab() { void _focusCurrentTab() {
// Re-enable auto-focus since user is navigating into tab content
setState(() { setState(() {
suppressAutoFocus = false; suppressAutoFocus = false;
}); });
@@ -115,7 +112,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
/// Build the app bar title - either tabs on desktop or simple title on mobile /// Build the app bar title - either tabs on desktop or simple title on mobile
Widget _buildAppBarTitle() { Widget _buildAppBarTitle() {
// On desktop/TV with side nav, show tabs in app bar
if (PlatformDetector.shouldUseSideNavigation(context)) { if (PlatformDetector.shouldUseSideNavigation(context)) {
return TabChipStrip( return TabChipStrip(
children: [ children: [
@@ -130,7 +126,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
); );
} }
// On mobile, show simple title
return Text(t.downloads.title); return Text(t.downloads.title);
} }
@@ -177,7 +172,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
builder: (context, hasPendingDownloads, _) => builder: (context, hasPendingDownloads, _) =>
BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads), BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads),
), ),
// Tab selector chips (only on mobile - desktop has them in app bar)
if (!PlatformDetector.shouldUseSideNavigation(context)) if (!PlatformDetector.shouldUseSideNavigation(context))
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
@@ -197,7 +191,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
), ),
), ),
), ),
// Tab content
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: tabController, controller: tabController,
-6
View File
@@ -202,7 +202,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
setState(() { setState(() {
_filteredItems = List.from(_items); _filteredItems = List.from(_items);
// Apply sorting
if (_selectedSort != null) { if (_selectedSort != null) {
final sortKey = _selectedSort!.key; final sortKey = _selectedSort!.key;
_filteredItems.sort((a, b) { _filteredItems.sort((a, b) {
@@ -540,21 +539,16 @@ class _HubDetailScreenState extends State<HubDetailScreen>
final libraryDensity = svc.read(SettingsService.libraryDensity); final libraryDensity = svc.read(SettingsService.libraryDensity);
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout); final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
// Determine hub content type for layout decisions
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode)); final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
final hasNonEpisodes = _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; final isMixedHub = hasEpisodes && hasNonEpisodes;
// Episode-only = all items are episodes with thumbnails
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
final useWideLayout = final useWideLayout =
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
// Music hubs render square album/artist artwork
final isSquareHub = final isSquareHub =
_filteredItems.isNotEmpty && _filteredItems.isNotEmpty &&
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square); _filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
@@ -29,7 +29,6 @@ class AlphaJumpHelper {
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount); AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) { factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
// Collect characters with their sizes.
final entries = <({String letter, int size})>[]; final entries = <({String letter, int size})>[];
final letterSizes = <String, int>{}; final letterSizes = <String, int>{};
@@ -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)); entries.sort((a, b) => ducetCompare(a.letter, b.letter));
if (descending) { if (descending) {
entries.setAll(0, entries.reversed.toList()); entries.setAll(0, entries.reversed.toList());
} }
// Build cumulative index map in the corrected order.
final letters = <String>[]; final letters = <String>[];
final letterToIndex = <String, int>{}; final letterToIndex = <String, int>{};
int cumulative = 0; int cumulative = 0;
@@ -176,12 +176,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Loading state (only show loading indicator if items list is empty)
if (isLoading && items.isEmpty) { if (isLoading && items.isEmpty) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
} }
// Error state (only show error if items list is empty)
if (errorMessage != null && items.isEmpty) { if (errorMessage != null && items.isEmpty) {
return ErrorStateWidget( return ErrorStateWidget(
message: errorMessage!, message: errorMessage!,
@@ -191,12 +189,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
); );
} }
// Empty state
if (items.isEmpty) { if (items.isEmpty) {
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon); return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
} }
// Content state - delegate to builder
return builder(items); return builder(items);
} }
} }
@@ -62,7 +62,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
ItemUpdatable, ItemUpdatable,
TickerProviderStateMixin, TickerProviderStateMixin,
TabNavigationMixin { TabNavigationMixin {
// GlobalKeys for tabs to enable refresh
final _recommendedTabKey = GlobalKey(); final _recommendedTabKey = GlobalKey();
final _browseTabKey = GlobalKey(); final _browseTabKey = GlobalKey();
final _collectionsTabKey = GlobalKey(); final _collectionsTabKey = GlobalKey();
@@ -84,7 +83,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Key for the library dropdown menu button. /// Key for the library dropdown menu button.
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>(); final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
// Dynamic visible tabs and their focus nodes
List<LibraryTabType> _visibleTabs = LibraryTabType.values; List<LibraryTabType> _visibleTabs = LibraryTabType.values;
List<FocusNode> _tabFocusNodes = List.generate( List<FocusNode> _tabFocusNodes = List.generate(
LibraryTabType.values.length, LibraryTabType.values.length,
@@ -94,10 +92,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@override @override
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes; List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
// App bar action bar
final _actionBarKey = GlobalKey<FocusableActionBarState>(); final _actionBarKey = GlobalKey<FocusableActionBarState>();
// Scroll controller for the outer CustomScrollView
final ScrollController _outerScrollController = ScrollController(); final ScrollController _outerScrollController = ScrollController();
/// Reveal the floating header by jumping the outer NestedScrollView back /// Reveal the floating header by jumping the outer NestedScrollView back
@@ -153,25 +149,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
return; return;
} }
// Compute visible libraries for initial load
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys; final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList(); final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
// Load saved preferences
final storage = await StorageService.getInstance(); final storage = await StorageService.getInstance();
final savedLibraryKey = storage.getSelectedLibraryKey(); final savedLibraryKey = storage.getSelectedLibraryKey();
// Find the library by key in visible libraries
String? libraryGlobalKeyToLoad; String? libraryGlobalKeyToLoad;
if (savedLibraryKey != null) { if (savedLibraryKey != null) {
// Check if saved library exists and is visible
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey); final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
if (libraryExists) { if (libraryExists) {
libraryGlobalKeyToLoad = savedLibraryKey; libraryGlobalKeyToLoad = savedLibraryKey;
} }
} }
// Fallback to first visible library if saved key not found
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) { if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey; libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
} }
@@ -183,16 +174,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
@override @override
void onTabChanged() { void onTabChanged() {
// Save tab name when changed (but not when restoring from storage)
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) { if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
// Only save if this was a user-initiated tab change, not a restore
if (!_isRestoringTab) { if (!_isRestoringTab) {
StorageService.getInstance().then((storage) { StorageService.getInstance().then((storage) {
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name); 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) { if (!suppressAutoFocus) {
_focusCurrentTab(); _focusCurrentTab();
} }
@@ -306,15 +293,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
/// Handle when a tab's data has finished loading /// Handle when a tab's data has finished loading
void _handleTabDataLoaded(int tabIndex) { void _handleTabDataLoaded(int tabIndex) {
// Track that this tab has loaded
_loadedTabs.add(tabIndex); _loadedTabs.add(tabIndex);
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
if (suppressAutoFocus) return; if (suppressAutoFocus) return;
// Only focus if this is the currently active tab
if (tabController.index == tabIndex && mounted) { if (tabController.index == tabIndex && mounted) {
// Use post-frame callback to ensure the widget tree is fully built
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) { if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
_focusCurrentTab(); _focusCurrentTab();
@@ -352,21 +335,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
void _updateVisibleTabs(List<LibraryTabType> newTabs) { void _updateVisibleTabs(List<LibraryTabType> newTabs) {
if (listEquals(_visibleTabs, newTabs)) return; if (listEquals(_visibleTabs, newTabs)) return;
// Save current tab type before changing
final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null; final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
// Dispose old focus nodes and controller
for (final node in _tabFocusNodes) { for (final node in _tabFocusNodes) {
node.dispose(); node.dispose();
} }
disposeTabNavigation(); disposeTabNavigation();
// Build new
_visibleTabs = newTabs; _visibleTabs = newTabs;
_tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}')); _tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
initTabNavigation(); initTabNavigation();
// Restore tab position: find current tab type in new set, default to first
final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1; final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1;
if (newIndex > 0) { if (newIndex > 0) {
tabController.index = newIndex; tabController.index = newIndex;
@@ -49,7 +49,6 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
_currentDescending = widget.isSortDescending; _currentDescending = widget.isSortDescending;
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus'); _initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
// Scroll to selected item, then handle focus
final selectedIndex = widget.selectedSort != null final selectedIndex = widget.selectedSort != null
? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key) ? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key)
: -1; : -1;
+3 -14
View File
@@ -23,12 +23,11 @@ class PlaylistItemCard extends StatefulWidget {
final VoidCallback? onRemove; final VoidCallback? onRemove;
final VoidCallback? onTap; final VoidCallback? onTap;
final void Function(MediaItem source)? onRefresh; 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 bool isFocused;
final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button final int? focusedColumn;
final bool isMoving; // Whether this item is being moved/reordered final bool isMoving;
const PlaylistItemCard({ const PlaylistItemCard({
super.key, super.key,
@@ -56,20 +55,16 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final textMuted = tokens(context).textMuted; final textMuted = tokens(context).textMuted;
// Determine if row is focused (main content area)
final isRowFocused = widget.isFocused && widget.focusedColumn == 0; final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
// Focus states for individual elements
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1; final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2; final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
// Determine card styling based on focus/move state
Color? cardColor; Color? cardColor;
ShapeBorder? cardShape; ShapeBorder? cardShape;
if (widget.isMoving) { if (widget.isMoving) {
cardColor = colorScheme.primaryContainer; cardColor = colorScheme.primaryContainer;
} else if (isRowFocused) { } else if (isRowFocused) {
// Row is focused - use visible border like FocusableWrapper
cardColor = colorScheme.surfaceContainerHighest; cardColor = colorScheme.surfaceContainerHighest;
cardShape = RoundedRectangleBorder( cardShape = RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(12)), borderRadius: const BorderRadius.all(Radius.circular(12)),
@@ -126,18 +121,15 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
), ),
), ),
// Poster thumbnail
_buildPosterImage(context, item), _buildPosterImage(context, item),
const SizedBox(width: 12), const SizedBox(width: 12),
// Title and metadata
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: .start, crossAxisAlignment: .start,
mainAxisSize: .min, mainAxisSize: .min,
children: [ children: [
// Title
Text( Text(
item.displayTitle, item.displayTitle,
style: const TextStyle(fontSize: 15, fontWeight: .w500), style: const TextStyle(fontSize: 15, fontWeight: .w500),
@@ -147,7 +139,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
const SizedBox(height: 4), const SizedBox(height: 4),
// Subtitle (episode info or type)
Text( Text(
_buildSubtitle(item), _buildSubtitle(item),
style: TextStyle(fontSize: 13, color: textMuted), style: TextStyle(fontSize: 13, color: textMuted),
@@ -160,13 +151,11 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
const SizedBox(width: 12), const SizedBox(width: 12),
// Duration
if (item.durationMs != null) if (item.durationMs != null)
Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)), Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)),
const SizedBox(width: 8), const SizedBox(width: 8),
// Remove button
Container( Container(
decoration: isRemoveButtonFocused decoration: isRemoveButtonFocused
? BoxDecoration( ? BoxDecoration(
-2
View File
@@ -29,7 +29,6 @@ class AboutScreen extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildListDelegate([ delegate: SliverChildListDelegate([
// App Icon and Name
Center( Center(
child: Column( child: Column(
children: [ children: [
@@ -54,7 +53,6 @@ class AboutScreen extends StatelessWidget {
const SizedBox(height: 40), const SizedBox(height: 40),
// Open Source Licenses
SettingsGroup( SettingsGroup(
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
children: [ children: [
@@ -114,7 +114,6 @@ class _LicenseDetailScreen extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
sliver: SliverList( sliver: SliverList(
delegate: SliverChildListDelegate([ delegate: SliverChildListDelegate([
// Package info card
if (mergedLicense.allPackageNames.length > 1) if (mergedLicense.allPackageNames.length > 1)
Card( Card(
child: Padding( child: Padding(
@@ -134,7 +133,6 @@ class _LicenseDetailScreen extends StatelessWidget {
), ),
if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16), if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16),
// License cards
...licenseEntries.asMap().entries.map((entry) { ...licenseEntries.asMap().entries.map((entry) {
final index = entry.key; final index = entry.key;
final license = entry.value; final license = entry.value;
@@ -43,10 +43,8 @@ class AmbientLightingService {
appLogger.d('AmbientLightingService: Shader path: $_shaderPath'); appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
// Set video-aspect-override to fill the entire output area
await _player.setProperty('video-aspect-override', outputAspect.toString()); await _player.setProperty('video-aspect-override', outputAspect.toString());
// Append ambient lighting shader
await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]); await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]);
_enabled = true; _enabled = true;
-8
View File
@@ -139,7 +139,6 @@ class DiscordRPCService {
_playbackSpeed = 1.0; _playbackSpeed = 1.0;
if (_isEnabled && _isConnected) { if (_isEnabled && _isConnected) {
// Upload thumbnail in background, don't block playback
unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client)); unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client));
} }
} }
@@ -148,9 +147,7 @@ class DiscordRPCService {
void updatePosition(Duration position) { void updatePosition(Duration position) {
final isSeek = _timeline.updatePosition(position); final isSeek = _timeline.updatePosition(position);
// Update presence if position jumped significantly (seek detected)
if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) { if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) {
// Throttle updates to max once per second
final now = DateTime.now(); final now = DateTime.now();
if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) { if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) {
_lastPresenceUpdate = now; _lastPresenceUpdate = now;
@@ -172,7 +169,6 @@ class DiscordRPCService {
Future<void> resumePlayback() async { Future<void> resumePlayback() async {
if (_currentMetadata == null) return; if (_currentMetadata == null) return;
// Reset start time for elapsed time display
_playbackStartTime = DateTime.now(); _playbackStartTime = DateTime.now();
if (_isEnabled && _isConnected) { if (_isEnabled && _isConnected) {
@@ -182,7 +178,6 @@ class DiscordRPCService {
/// Pause - clear timestamp but keep showing what's playing /// Pause - clear timestamp but keep showing what's playing
Future<void> pausePlayback() async { Future<void> pausePlayback() async {
// Clear start time so Discord stops counting
_playbackStartTime = null; _playbackStartTime = null;
if (_isEnabled && _isConnected) { if (_isEnabled && _isConnected) {
@@ -309,12 +304,9 @@ class DiscordRPCService {
Future<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async { Future<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async {
try { try {
// Get the thumbnail path (prefer show poster for episodes)
final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath; final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath;
if (thumbPath == null || thumbPath.isEmpty) return null; 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 cacheKey = '${client.backend.id}:$thumbPath';
final cached = _posterUrlCache[cacheKey]; final cached = _posterUrlCache[cacheKey];
if (cached != null && !cached.isExpired) { if (cached != null && !cached.isExpired) {
-5
View File
@@ -192,25 +192,20 @@ class GamepadService with WindowListener {
_tabNavigationHandlers.clear(); _tabNavigationHandlers.clear();
} }
// Deadzone for analog sticks (0.0 to 1.0)
static const double _stickDeadzone = 0.5; 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 _repeatInitialDelay = Duration(milliseconds: 400);
static const Duration _repeatInterval = Duration(milliseconds: 80); static const Duration _repeatInterval = Duration(milliseconds: 80);
key_sim.KeyEventSimulatorController? _keyEventSimulator; key_sim.KeyEventSimulatorController? _keyEventSimulator;
// Track stick state to detect deadzone crossings
bool _leftStickUp = false; bool _leftStickUp = false;
bool _leftStickDown = false; bool _leftStickDown = false;
bool _leftStickLeft = false; bool _leftStickLeft = false;
bool _leftStickRight = false; bool _leftStickRight = false;
// Track button states to prevent repeated events from button holds
final Set<GamepadButton> _pressedButtons = {}; final Set<GamepadButton> _pressedButtons = {};
final Set<GamepadButton> _suppressedButtons = {}; final Set<GamepadButton> _suppressedButtons = {};
// Whether the app window is currently focused — ignore gamepad input when false
bool _windowFocused = true; bool _windowFocused = true;
bool _nativeKeyHandlerRegistered = false; bool _nativeKeyHandlerRegistered = false;
bool _nativeTextInputFocused = false; bool _nativeTextInputFocused = false;
@@ -250,16 +250,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
if (!isMetaPressed) modifiersMatch = false; if (!isMetaPressed) modifiersMatch = false;
break; break;
case HotKeyModifier.capsLock: case HotKeyModifier.capsLock:
// CapsLock is typically not used for shortcuts, ignore for now
break; break;
case HotKeyModifier.fn: case HotKeyModifier.fn:
// Fn key is typically not used for shortcuts, ignore for now
break; break;
} }
if (!modifiersMatch) break; if (!modifiersMatch) break;
} }
// Check that no extra modifiers are pressed
if (modifiersMatch) { if (modifiersMatch) {
final hasShift = requiredModifiers.contains(HotKeyModifier.shift); final hasShift = requiredModifiers.contains(HotKeyModifier.shift);
final hasControl = requiredModifiers.contains(HotKeyModifier.control); final hasControl = requiredModifiers.contains(HotKeyModifier.control);
@@ -372,7 +369,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge); return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge);
} }
// Check if a hotkey is already assigned to another action
String? getActionForHotkey(HotKey hotkey) { String? getActionForHotkey(HotKey hotkey) {
for (final entry in _hotkeys.entries) { for (final entry in _hotkeys.entries) {
final assignedHotkey = entry.value; final assignedHotkey = entry.value;
@@ -383,7 +379,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
return null; return null;
} }
// Helper method to compare two HotKey objects
bool _hotkeyEquals(HotKey a, HotKey b) { bool _hotkeyEquals(HotKey a, HotKey b) {
if (a.key != b.key) return false; if (a.key != b.key) return false;
-2
View File
@@ -50,8 +50,6 @@ String formatContentRating(String? contentRating) {
return ''; 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 regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false);
final match = regex.firstMatch(contentRating); final match = regex.firstMatch(contentRating);
-1
View File
@@ -68,7 +68,6 @@ class BottomSheetHeader extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final usesBackButton = leading == null && onBack != null; final usesBackButton = leading == null && onBack != null;
// Determine the leading widget based on priority: leading > onBack > icon
Widget? resolvedLeading; Widget? resolvedLeading;
if (leading != null) { if (leading != null) {
resolvedLeading = leading; resolvedLeading = leading;
-33
View File
@@ -92,7 +92,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
@override @override
void didUpdateWidget(DownloadTreeView oldWidget) { void didUpdateWidget(DownloadTreeView oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
// When suppressAutoFocus changes from true to false, focus the first item
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) { if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _firstItemFocusNode.canRequestFocus) { if (mounted && _firstItemFocusNode.canRequestFocus) {
@@ -121,13 +120,11 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
); );
} }
/// Build the download tree from flat download list
List<DownloadTreeNode> _buildTree() { List<DownloadTreeNode> _buildTree() {
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {}; final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {}; final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
final List<DownloadTreeNode> movies = []; final List<DownloadTreeNode> movies = [];
// Group downloads
for (final entry in widget.downloads.entries) { for (final entry in widget.downloads.entries) {
final globalKey = entry.key; final globalKey = entry.key;
final download = entry.value; final download = entry.value;
@@ -136,17 +133,14 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
if (meta == null) continue; if (meta == null) continue;
if (meta.isEpisode) { if (meta.isEpisode) {
// Group episodes by show
final showKey = meta.grandparentId ?? 'unknown'; final showKey = meta.grandparentId ?? 'unknown';
showGroups.putIfAbsent(showKey, () => []); showGroups.putIfAbsent(showKey, () => []);
showGroups[showKey]!.add(entry); showGroups[showKey]!.add(entry);
} else if (meta.kind == MediaKind.track) { } else if (meta.kind == MediaKind.track) {
// Group tracks by album (single level — no per-disc tier)
final albumKey = meta.parentId ?? 'unknown'; final albumKey = meta.parentId ?? 'unknown';
albumGroups.putIfAbsent(albumKey, () => []); albumGroups.putIfAbsent(albumKey, () => []);
albumGroups[albumKey]!.add(entry); albumGroups[albumKey]!.add(entry);
} else if (meta.isMovie) { } else if (meta.isMovie) {
// Movies go at top level
movies.add( movies.add(
DownloadTreeNode( DownloadTreeNode(
key: globalKey, key: globalKey,
@@ -161,7 +155,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
} }
} }
// Build show nodes
final List<DownloadTreeNode> shows = []; final List<DownloadTreeNode> shows = [];
for (final showEntry in showGroups.entries) { for (final showEntry in showGroups.entries) {
final showKey = showEntry.key; final showKey = showEntry.key;
@@ -169,11 +162,9 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
if (episodes.isEmpty) continue; if (episodes.isEmpty) continue;
// Get show metadata from first episode
final firstEpisode = widget.metadata[episodes.first.key]; final firstEpisode = widget.metadata[episodes.first.key];
final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow; final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow;
// Group episodes by season
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {}; final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
for (final episode in episodes) { for (final episode in episodes) {
final meta = widget.metadata[episode.key]; final meta = widget.metadata[episode.key];
@@ -184,7 +175,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
seasonGroups[seasonKey]!.add(episode); seasonGroups[seasonKey]!.add(episode);
} }
// Build season nodes
final List<DownloadTreeNode> seasons = []; final List<DownloadTreeNode> seasons = [];
for (final seasonEntry in seasonGroups.entries) { for (final seasonEntry in seasonGroups.entries) {
final seasonKey = seasonEntry.key; final seasonKey = seasonEntry.key;
@@ -192,7 +182,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
if (seasonEpisodes.isEmpty) continue; if (seasonEpisodes.isEmpty) continue;
// Get season metadata from first episode
final firstEpisode = widget.metadata[seasonEpisodes.first.key]; final firstEpisode = widget.metadata[seasonEpisodes.first.key];
final seasonNumber = firstEpisode?.parentIndex; final seasonNumber = firstEpisode?.parentIndex;
final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true
@@ -201,7 +190,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
? t.common.seasonNumber(number: seasonNumber) ? t.common.seasonNumber(number: seasonNumber)
: t.downloads.unknownSeason; : t.downloads.unknownSeason;
// Build episode nodes
final List<DownloadTreeNode> episodeNodes = []; final List<DownloadTreeNode> episodeNodes = [];
for (final episodeEntry in seasonEpisodes) { for (final episodeEntry in seasonEpisodes) {
final globalKey = episodeEntry.key; final globalKey = episodeEntry.key;
@@ -228,14 +216,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
); );
} }
// Sort episodes by episode number only (not by status)
episodeNodes.sort((a, b) { episodeNodes.sort((a, b) {
final aIndex = a.metadata?.index ?? 0; final aIndex = a.metadata?.index ?? 0;
final bIndex = b.metadata?.index ?? 0; final bIndex = b.metadata?.index ?? 0;
return aIndex.compareTo(bIndex); return aIndex.compareTo(bIndex);
}); });
// Calculate aggregate season progress
final seasonProgress = episodeNodes.isEmpty final seasonProgress = episodeNodes.isEmpty
? 0.0 ? 0.0
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length; : episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length;
@@ -255,14 +241,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
seasons.removeWhere((s) => s.children.isEmpty); seasons.removeWhere((s) => s.children.isEmpty);
// Sort seasons by season number
seasons.sort((a, b) { seasons.sort((a, b) {
final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0; final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0;
final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0; final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0;
return aSeasonNum.compareTo(bSeasonNum); return aSeasonNum.compareTo(bSeasonNum);
}); });
// Calculate aggregate show progress
final showProgress = seasons.isEmpty final showProgress = seasons.isEmpty
? 0.0 ? 0.0
: seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length; : seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length;
@@ -280,14 +264,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
); );
} }
// Build album nodes (album -> tracks)
final List<DownloadTreeNode> albums = []; final List<DownloadTreeNode> albums = [];
for (final albumEntry in albumGroups.entries) { for (final albumEntry in albumGroups.entries) {
final albumKey = albumEntry.key; final albumKey = albumEntry.key;
final tracks = albumEntry.value; final tracks = albumEntry.value;
if (tracks.isEmpty) continue; if (tracks.isEmpty) continue;
// Album/artist names from any track's parent fields
final firstTrack = widget.metadata[tracks.first.key]; final firstTrack = widget.metadata[tracks.first.key];
final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum; final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum;
final artistTitle = firstTrack?.albumArtistTitle; final artistTitle = firstTrack?.albumArtistTitle;
@@ -314,7 +296,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
} }
if (trackNodes.isEmpty) continue; if (trackNodes.isEmpty) continue;
// Sort tracks by disc then track number
trackNodes.sort((a, b) { trackNodes.sort((a, b) {
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1); final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
if (byDisc != 0) return byDisc; if (byDisc != 0) return byDisc;
@@ -336,17 +317,13 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
); );
} }
// Sort shows, albums, and movies by status and title
_sortNodesByStatusAndTitle(shows); _sortNodesByStatusAndTitle(shows);
_sortNodesByStatusAndTitle(albums); _sortNodesByStatusAndTitle(albums);
_sortNodesByStatusAndTitle(movies); _sortNodesByStatusAndTitle(movies);
// Combine movies, shows, and albums
return [...movies, ...shows, ...albums]; return [...movies, ...shows, ...albums];
} }
/// Determine aggregate status from child statuses
/// Priority: downloading > queued > paused > completed > failed
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) { DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
if (statuses.isEmpty) return DownloadStatus.queued; if (statuses.isEmpty) return DownloadStatus.queued;
@@ -365,7 +342,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
return DownloadStatus.completed; return DownloadStatus.completed;
} }
/// Compare statuses for sorting (downloading first, then queued, etc.)
int _compareByStatus(DownloadStatus a, DownloadStatus b) { int _compareByStatus(DownloadStatus a, DownloadStatus b) {
const statusOrder = { const statusOrder = {
DownloadStatus.downloading: 0, DownloadStatus.downloading: 0,
@@ -378,7 +354,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99); return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
} }
/// Sort nodes by status (downloading first) then by title
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) { void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
nodes.sort((a, b) { nodes.sort((a, b) {
final statusCompare = _compareByStatus(a.status, b.status); final statusCompare = _compareByStatus(a.status, b.status);
@@ -414,7 +389,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
}); });
} }
/// Build a tree item widget
Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) { Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) {
return _DownloadTreeItem( return _DownloadTreeItem(
node: node, node: node,
@@ -590,9 +564,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
return widget.node.status; return widget.node.status;
} }
// Focus node for row content (only created if not provided externally)
FocusNode? _ownedRowFocusNode; FocusNode? _ownedRowFocusNode;
// Focus nodes for action buttons (up to 3 buttons max)
final List<FocusNode> _buttonFocusNodes = []; final List<FocusNode> _buttonFocusNodes = [];
FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!; FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!;
@@ -676,10 +648,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row( child: Row(
children: [ children: [
// Row content
Expanded(child: _buildRowContent(theme, canExpand)), Expanded(child: _buildRowContent(theme, canExpand)),
// Action buttons
if (actions.isNotEmpty) if (actions.isNotEmpty)
Row( Row(
mainAxisSize: .min, mainAxisSize: .min,
@@ -696,7 +666,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
Widget _buildRowContent(ThemeData theme, bool canExpand) { Widget _buildRowContent(ThemeData theme, bool canExpand) {
return Row( return Row(
children: [ children: [
// Expand/collapse icon
if (canExpand) if (canExpand)
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20) AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
else else
@@ -704,12 +673,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
const SizedBox(width: 8), const SizedBox(width: 8),
// Status icon
DownloadStatusIcon(status: _effectiveStatus, size: 20), DownloadStatusIcon(status: _effectiveStatus, size: 20),
const SizedBox(width: 12), const SizedBox(width: 12),
// Title and info
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: .start, crossAxisAlignment: .start,
-1
View File
@@ -275,7 +275,6 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
} }
} }
// Handle key down and repeat events
if (!event.isActionable) { if (!event.isActionable) {
return KeyEventResult.ignored; return KeyEventResult.ignored;
} }
@@ -289,7 +289,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
final ScrollController _dialogScrollController = ScrollController(); final ScrollController _dialogScrollController = ScrollController();
final ScrollController _sheetScrollController = ScrollController(); final ScrollController _sheetScrollController = ScrollController();
// Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button.
@override @override
List<MediaLibrary> get reorderItems => _tempLibraries; List<MediaLibrary> get reorderItems => _tempLibraries;
@@ -468,16 +467,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
final isHidden = hiddenLibraryKeys.contains(library.globalKey); final isHidden = hiddenLibraryKeys.contains(library.globalKey);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
// Determine background color based on state
Color? tileColor; Color? tileColor;
if (isMoving) { if (isMoving) {
tileColor = colorScheme.primaryContainer; tileColor = colorScheme.primaryContainer;
} else if (isFocused && focusedColumn == 0) { } else if (isFocused && focusedColumn == 0) {
// Only highlight row when row itself is focused (column 0)
tileColor = colorScheme.surfaceContainerHighest; tileColor = colorScheme.surfaceContainerHighest;
} }
// Button focus states
final isVisibilityButtonFocused = isFocused && focusedColumn == 1; final isVisibilityButtonFocused = isFocused && focusedColumn == 1;
final isOptionsButtonFocused = isFocused && focusedColumn == 2; final isOptionsButtonFocused = isFocused && focusedColumn == 2;
-8
View File
@@ -615,13 +615,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
mainAxisSize: .min, mainAxisSize: .min,
crossAxisAlignment: .start, crossAxisAlignment: .start,
children: [ children: [
// Poster with overlay
if (posterHeight != null) if (posterHeight != null)
SizedBox(width: double.infinity, height: posterHeight, child: poster) SizedBox(width: double.infinity, height: posterHeight, child: poster)
else else
Expanded(child: poster), Expanded(child: poster),
const SizedBox(height: 2), const SizedBox(height: 2),
// Title (flattened — no inner Column)
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item)) if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
_ClickableText( _ClickableText(
text: item.displayTitle, text: item.displayTitle,
@@ -637,7 +635,6 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1), style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
), ),
), ),
// Subtitle
if (item is MediaPlaylist) if (item is MediaPlaylist)
_MediaCardHelpers.buildPlaylistMeta(context, item) _MediaCardHelpers.buildPlaylistMeta(context, item)
else if (item is MediaItem) else if (item is MediaItem)
@@ -1131,7 +1128,6 @@ class _MediaCardHelpers {
} }
} }
// For collections, show item count
if (mi.kind == MediaKind.collection) { if (mi.kind == MediaKind.collection) {
final count = mi.childCount ?? mi.leafCount; final count = mi.childCount ?? mi.leafCount;
if (count != null && count > 0) { if (count != null && count > 0) {
@@ -1146,14 +1142,12 @@ class _MediaCardHelpers {
} }
} }
// For albums, show the album artist
if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) { if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) {
return ExcludeSemantics( return ExcludeSemantics(
child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle), child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
); );
} }
// For tracks, show "Artist • duration"
if (mi.kind == MediaKind.track) { if (mi.kind == MediaKind.track) {
final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)]; final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)];
if (parts.isNotEmpty) { 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 (mi.isEpisode && mi.parentIndex != null) {
if (enableDetailLinks && mi.parentId != null) { if (enableDetailLinks && mi.parentId != null) {
return _buildEpisodeSubtitleRow( return _buildEpisodeSubtitleRow(
@@ -1185,7 +1178,6 @@ class _MediaCardHelpers {
); );
} }
// For other media types, show subtitle/parent/year
if (mi.displaySubtitle != null) { if (mi.displaySubtitle != null) {
return ExcludeSemantics( return ExcludeSemantics(
child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle), child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
+1 -1
View File
@@ -130,7 +130,7 @@ class MediaContextMenu extends StatefulWidget {
final Object item; final Object item;
final void Function(MediaItem source)? onRefresh; final void Function(MediaItem source)? onRefresh;
final VoidCallback? onRemoveFromContinueWatching; final VoidCallback? onRemoveFromContinueWatching;
final VoidCallback? onListRefresh; // For refreshing list after deletion final VoidCallback? onListRefresh;
final VoidCallback? onTap; final VoidCallback? onTap;
/// Plays the item's trailer. When non-null a "Play trailer" item is added to /// Plays the item's trailer. When non-null a "Play trailer" item is added to
@@ -173,7 +173,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
bool get _canControl => _trackControlsState.canControl; bool get _canControl => _trackControlsState.canControl;
bool get _isLive => _trackControlsState.isLive; bool get _isLive => _trackControlsState.isLive;
// Focus nodes for playback control buttons
late final FocusNode _prevItemFocusNode; late final FocusNode _prevItemFocusNode;
late final FocusNode _prevChapterFocusNode; late final FocusNode _prevChapterFocusNode;
late final FocusNode _skipBackFocusNode; late final FocusNode _skipBackFocusNode;
@@ -184,30 +183,23 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
late final FocusNode _goToLiveFocusNode; late final FocusNode _goToLiveFocusNode;
late final FocusNode _timelineFocusNode; late final FocusNode _timelineFocusNode;
// Focus node for volume control
late final FocusNode _volumeFocusNode; late final FocusNode _volumeFocusNode;
// Focus nodes for track/chapter controls (max 8 buttons possible)
late final List<FocusNode> _trackControlFocusNodes; late final List<FocusNode> _trackControlFocusNodes;
// List of button focus nodes for horizontal navigation
late final List<FocusNode> _buttonFocusNodes; late final List<FocusNode> _buttonFocusNodes;
// Progressive seek acceleration state
LogicalKeyboardKey? _seekDirection; // Current direction being held LogicalKeyboardKey? _seekDirection; // Current direction being held
int _seekRepeatCount = 0; // Consecutive key repeats for acceleration int _seekRepeatCount = 0; // Consecutive key repeats for acceleration
// Preview thumbnail during sustained dpad/keyboard seeking
bool _showKeyRepeatThumbnail = false; bool _showKeyRepeatThumbnail = false;
Timer? _keyRepeatThumbnailTimer; Timer? _keyRepeatThumbnailTimer;
late final DebouncedSeekAccumulator _timelineSeek; late final DebouncedSeekAccumulator _timelineSeek;
static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400); static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400);
// Content strip state
bool _contentStripVisible = false; bool _contentStripVisible = false;
final GlobalKey<ContentStripState> _contentStripKey = GlobalKey<ContentStripState>(); final GlobalKey<ContentStripState> _contentStripKey = GlobalKey<ContentStripState>();
// Track which button was last focused (for returning from content strip)
FocusNode? _lastFocusedButtonNode; FocusNode? _lastFocusedButtonNode;
/// Whether the content strip has any content to show /// Whether the content strip has any content to show
@@ -714,7 +706,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
child: Column( child: Column(
children: [ children: [
// Row 1: Timeline (LiveTimelineBar for time-shifted live, VideoTimelineBar for VOD)
if (_isLive && widget.captureBuffer != null) ...[ if (_isLive && widget.captureBuffer != null) ...[
LiveTimelineBar( LiveTimelineBar(
player: widget.player, player: widget.player,
@@ -748,14 +739,12 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
previewPosition: _timelineSeek.pendingPosition, previewPosition: _timelineSeek.pendingPosition,
), ),
], ],
// Row 2: Playback controls and options
Focus( Focus(
onFocusChange: _onButtonRowFocusChange, onFocusChange: _onButtonRowFocusChange,
skipTraversal: true, skipTraversal: true,
child: Row( child: Row(
children: [ children: [
if (!_isLive) ...[ if (!_isLive) ...[
// Previous item
Opacity( Opacity(
opacity: _canControl ? 1.0 : 0.5, opacity: _canControl ? 1.0 : 0.5,
child: _buildFocusableButton( child: _buildFocusableButton(
@@ -354,7 +354,6 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
mainAxisAlignment: .center, mainAxisAlignment: .center,
children: [ children: [
if (!widget.isLive) ...[ if (!widget.isLive) ...[
// Previous episode button (greyed out when unavailable)
CircularControlButton( CircularControlButton(
semanticLabel: t.videoControls.previousButton, semanticLabel: t.videoControls.previousButton,
icon: Symbols.skip_previous_rounded, icon: Symbols.skip_previous_rounded,
@@ -378,7 +377,6 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
), ),
if (!widget.isLive) ...[ if (!widget.isLive) ...[
const SizedBox(width: 24), const SizedBox(width: 24),
// Next episode button (greyed out when unavailable)
CircularControlButton( CircularControlButton(
semanticLabel: t.videoControls.nextButton, semanticLabel: t.videoControls.nextButton,
icon: Symbols.skip_next_rounded, icon: Symbols.skip_next_rounded,
@@ -24,7 +24,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
} }
final currentTrack = widget.player.state.track.subtitle; 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; if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
_setSubtitleVisibility(false); _setSubtitleVisibility(false);
@@ -51,7 +50,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
} }
void _onSubtitleTrackChanged(SubtitleTrack track) { void _onSubtitleTrackChanged(SubtitleTrack track) {
// Reset visibility when user explicitly picks a new subtitle track
if (track.id != 'no' && !_subtitlesVisible) { if (track.id != 'no' && !_subtitlesVisible) {
_setSubtitleVisibility(true); _setSubtitleVisibility(true);
} }
@@ -770,7 +770,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
}, },
), ),
// Audio Sync
_SettingsMenuItem( _SettingsMenuItem(
icon: Symbols.sync_rounded, icon: Symbols.sync_rounded,
title: t.videoSettings.audioSync, title: t.videoSettings.audioSync,
@@ -779,7 +778,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onTap: () => _navigateTo(_SettingsView.audioSync), onTap: () => _navigateTo(_SettingsView.audioSync),
), ),
// Subtitle Sync
_SettingsMenuItem( _SettingsMenuItem(
icon: Symbols.subtitles_rounded, icon: Symbols.subtitles_rounded,
title: t.videoSettings.subtitleSync, title: t.videoSettings.subtitleSync,
@@ -788,7 +786,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onTap: () => _navigateTo(_SettingsView.subtitleSync), onTap: () => _navigateTo(_SettingsView.subtitleSync),
), ),
// HDR Toggle
if (_supportsHdrControl) if (_supportsHdrControl)
_SettingsToggleItem( _SettingsToggleItem(
pref: SettingsService.enableHDR, pref: SettingsService.enableHDR,
@@ -808,14 +805,12 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onTap: () => _navigateTo(_SettingsView.hdrToneMapping), onTap: () => _navigateTo(_SettingsView.hdrToneMapping),
), ),
// Auto-Play Next Episode Toggle
_SettingsToggleItem( _SettingsToggleItem(
pref: SettingsService.autoPlayNextEpisode, pref: SettingsService.autoPlayNextEpisode,
icon: Symbols.skip_next_rounded, icon: Symbols.skip_next_rounded,
title: t.videoControls.autoPlayNext, title: t.videoControls.autoPlayNext,
), ),
// Audio Output Device (Desktop only)
if (isDesktop) if (isDesktop)
StreamBuilder<AudioDevice>( StreamBuilder<AudioDevice>(
stream: widget.player.streams.audioDevice, stream: widget.player.streams.audioDevice,
@@ -845,7 +840,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
// "not Dolby" when the system reports notApplicable. // "not Dolby" when the system reports notApplicable.
if (PlatformDetector.isAppleTV()) _AudioRenderingModeItem(player: widget.player), if (PlatformDetector.isAppleTV()) _AudioRenderingModeItem(player: widget.player),
// Audio Normalization
_SettingsToggleItem( _SettingsToggleItem(
pref: SettingsService.audioNormalization, pref: SettingsService.audioNormalization,
icon: Symbols.graphic_eq_rounded, icon: Symbols.graphic_eq_rounded,
@@ -853,7 +847,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
onAfterWrite: widget.player.setAudioNormalization, onAfterWrite: widget.player.setAudioNormalization,
), ),
// Stereo Downmix
_SettingsToggleItem( _SettingsToggleItem(
pref: SettingsService.audioDownmix, pref: SettingsService.audioDownmix,
icon: Symbols.headphones_rounded, icon: Symbols.headphones_rounded,
@@ -62,7 +62,6 @@ class VideoControlButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Determine the effective color: explicit color > active amber > default white
final effectiveColor = color ?? (isActive ? Colors.amber : Colors.white); final effectiveColor = color ?? (isActive ? Colors.amber : Colors.white);
final button = IconButton( final button = IconButton(
@@ -85,9 +84,9 @@ class VideoControlButton extends StatelessWidget {
semanticLabel: effectiveSemanticLabel, semanticLabel: effectiveSemanticLabel,
semanticValue: semanticValue, semanticValue: semanticValue,
checked: checked, checked: checked,
borderRadius: 20, // Circular for icon buttons borderRadius: 20,
autoScroll: false, // Video controls don't scroll autoScroll: false,
useBackgroundFocus: true, // Use background highlight for video controls useBackgroundFocus: true,
child: result, child: result,
); );
} else if (effectiveSemanticLabel != null) { } else if (effectiveSemanticLabel != null) {
@@ -5,10 +5,8 @@ import '../../../../i18n/strings.g.dart';
/// Contains metrics queried from the video player (MPV or ExoPlayer) /// Contains metrics queried from the video player (MPV or ExoPlayer)
/// including video/audio codec info, playback performance, and buffer state. /// including video/audio codec info, playback performance, and buffer state.
class PerformanceStats { class PerformanceStats {
// Player info final String playerType;
final String playerType; // 'mpv' or 'exoplayer'
// Video metrics
final String? videoCodec; final String? videoCodec;
final int? videoWidth; final int? videoWidth;
final int? videoHeight; final int? videoHeight;
@@ -19,38 +17,32 @@ class PerformanceStats {
final int? rotate; final int? rotate;
final String? videoDecoderName; final String? videoDecoderName;
// Color/Format metrics
final String? pixelformat; final String? pixelformat;
final String? hwPixelformat; final String? hwPixelformat;
final String? colormatrix; final String? colormatrix;
final String? primaries; final String? primaries;
final String? gamma; final String? gamma;
// HDR metadata
final double? maxLuma; final double? maxLuma;
final double? minLuma; final double? minLuma;
final double? maxCll; final double? maxCll;
final double? maxFall; final double? maxFall;
// Audio metrics
final String? audioCodec; final String? audioCodec;
final int? audioSamplerate; final int? audioSamplerate;
final String? audioChannels; final String? audioChannels;
final int? audioBitrate; final int? audioBitrate;
final String? audioDecoderName; final String? audioDecoderName;
// Tunneling
final bool tunneledPlayback; final bool tunneledPlayback;
final String? tunnelingStatus; final String? tunnelingStatus;
// Performance metrics
final double? actualFps; final double? actualFps;
final double? avsyncChange; final double? avsyncChange;
final double? displayFps; final double? displayFps;
final int? frameDropCount; final int? frameDropCount;
final int? decoderFrameDropCount; final int? decoderFrameDropCount;
// Buffer metrics
final int? cacheUsed; final int? cacheUsed;
final int? cacheLimit; final int? cacheLimit;
final double? cacheSpeed; final double? cacheSpeed;
@@ -58,9 +50,8 @@ class PerformanceStats {
final int? bufferTargetBytes; final int? bufferTargetBytes;
final int? bufferMaxMs; final int? bufferMaxMs;
// DV conversion
final bool dvConversionActive; final bool dvConversionActive;
final String dvConversionMode; // "DV81", "HEVC_STRIP", "DISABLED" final String dvConversionMode;
final int? dvConvertedRpus; final int? dvConvertedRpus;
final int? dvRpuConversionFailures; final int? dvRpuConversionFailures;
final int? dvRpuOutputTooSmall; final int? dvRpuOutputTooSmall;
@@ -70,7 +61,6 @@ class PerformanceStats {
final String? dvPlaybackPath; final String? dvPlaybackPath;
final String? dvPlaybackReason; final String? dvPlaybackReason;
// App metrics
final int? appMemoryBytes; final int? appMemoryBytes;
final double? uiFps; final double? uiFps;
@@ -69,7 +69,6 @@ class TrackChapterControls extends StatelessWidget {
final key = event.logicalKey; final key = event.logicalKey;
// LEFT arrow - move to previous button or exit to volume
if (key == LogicalKeyboardKey.arrowLeft) { if (key == LogicalKeyboardKey.arrowLeft) {
if (index > 0 && focusNodes != null && focusNodes!.length > index - 1) { if (index > 0 && focusNodes != null && focusNodes!.length > index - 1) {
focusNodes![index - 1].requestFocus(); focusNodes![index - 1].requestFocus();
@@ -81,23 +80,19 @@ class TrackChapterControls extends StatelessWidget {
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// RIGHT arrow - move to next button
if (key == LogicalKeyboardKey.arrowRight) { if (key == LogicalKeyboardKey.arrowRight) {
if (index < totalButtons - 1 && focusNodes != null && focusNodes!.length > index + 1) { if (index < totalButtons - 1 && focusNodes != null && focusNodes!.length > index + 1) {
focusNodes![index + 1].requestFocus(); focusNodes![index + 1].requestFocus();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// At end, consume to prevent bubbling
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// UP arrow - navigate up (e.g., to timeline)
if (key == LogicalKeyboardKey.arrowUp) { if (key == LogicalKeyboardKey.arrowUp) {
onNavigateUp?.call(); onNavigateUp?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
} }
// DOWN arrow - navigate down (e.g., to content strip)
if (key == LogicalKeyboardKey.arrowDown) { if (key == LogicalKeyboardKey.arrowDown) {
onNavigateDown?.call(); onNavigateDown?.call();
return KeyEventResult.handled; return KeyEventResult.handled;
@@ -146,11 +141,9 @@ class TrackChapterControls extends StatelessWidget {
final isMobile = PlatformDetector.isMobile(context); final isMobile = PlatformDetector.isMobile(context);
final isDesktop = PlatformDetector.isDesktopOS(); final isDesktop = PlatformDetector.isDesktopOS();
// Build list of buttons dynamically to track indices
final buttons = <Widget>[]; final buttons = <Widget>[];
int buttonIndex = 0; int buttonIndex = 0;
// Settings button (always shown)
buttons.add( buttons.add(
ListenableBuilder( ListenableBuilder(
listenable: SleepTimerService(), listenable: SleepTimerService(),
@@ -193,7 +186,6 @@ class TrackChapterControls extends StatelessWidget {
); );
buttonIndex++; buttonIndex++;
// Combined audio & subtitles button
{ {
final currentIndex = buttonIndex; final currentIndex = buttonIndex;
buttons.add( buttons.add(
@@ -232,7 +224,6 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++; buttonIndex++;
} }
// Chapters button (hidden on mobile when content strip is available)
if (chapters.isNotEmpty && !hideChaptersAndQueue) { if (chapters.isNotEmpty && !hideChaptersAndQueue) {
final currentIndex = buttonIndex; final currentIndex = buttonIndex;
buttons.add( buttons.add(
@@ -264,7 +255,6 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++; buttonIndex++;
} }
// Queue button (hidden on mobile when content strip is available)
if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) { if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) {
final currentIndex = buttonIndex; final currentIndex = buttonIndex;
buttons.add( buttons.add(
@@ -286,7 +276,6 @@ class TrackChapterControls extends StatelessWidget {
buttonIndex++; buttonIndex++;
} }
// Picture-in-Picture mode
if (state.onTogglePIPMode != null) { if (state.onTogglePIPMode != null) {
final currentIndex = buttonIndex; final currentIndex = buttonIndex;
buttons.add( buttons.add(
-5
View File
@@ -1,4 +1,3 @@
# Project-level configuration.
cmake_minimum_required(VERSION 3.13) cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX) project(runner LANGUAGES CXX)
@@ -6,8 +5,6 @@ project(runner LANGUAGES CXX)
# registered by the runner subdirectory. # registered by the runner subdirectory.
include(CTest) 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") set(BINARY_NAME "plezy")
# The unique GTK application identifier for this application. See: # The unique GTK application identifier for this application. See:
# https://wiki.gnome.org/HowDoI/ChooseApplicationID # https://wiki.gnome.org/HowDoI/ChooseApplicationID
@@ -30,7 +27,6 @@ if(FLUTTER_TARGET_PLATFORM_SYSROOT)
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY) set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
endif() endif()
# Define build configuration options.
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
set(CMAKE_BUILD_TYPE "Debug" CACHE set(CMAKE_BUILD_TYPE "Debug" CACHE
STRING "Flutter build mode" FORCE) STRING "Flutter build mode" FORCE)
@@ -63,7 +59,6 @@ function(APPLY_STANDARD_SETTINGS TARGET)
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>") target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
endfunction() endfunction()
# Flutter library and tool build rules.
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter") set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
add_subdirectory(${FLUTTER_MANAGED_DIR}) add_subdirectory(${FLUTTER_MANAGED_DIR})
-3
View File
@@ -1,15 +1,12 @@
#!/bin/bash #!/bin/bash
# Ensure binaries are executable
chmod +x /usr/bin/plezy chmod +x /usr/bin/plezy
chmod +x /opt/plezy/plezy chmod +x /opt/plezy/plezy
chmod +x /opt/plezy/lib/crashpad_handler chmod +x /opt/plezy/lib/crashpad_handler
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true
fi fi
# Update desktop database
if command -v update-desktop-database &> /dev/null; then if command -v update-desktop-database &> /dev/null; then
update-desktop-database /usr/share/applications || true update-desktop-database /usr/share/applications || true
fi fi
-2
View File
@@ -1,10 +1,8 @@
#!/bin/bash #!/bin/bash
# Update icon cache
if command -v gtk-update-icon-cache &> /dev/null; then if command -v gtk-update-icon-cache &> /dev/null; then
gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true
fi fi
# Update desktop database
if command -v update-desktop-database &> /dev/null; then if command -v update-desktop-database &> /dev/null; then
update-desktop-database /usr/share/applications || true update-desktop-database /usr/share/applications || true
fi fi
-5
View File
@@ -6,14 +6,12 @@ import shutil
import sys import sys
from pathlib import Path from pathlib import Path
# Paths
SCRIPT_DIR = Path(__file__).parent.resolve() SCRIPT_DIR = Path(__file__).parent.resolve()
PROJECT_ROOT = SCRIPT_DIR.parent.parent 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" 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)) OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", PROJECT_ROOT))
ARCH_SUFFIX = os.environ.get("ARCH_SUFFIX", "x64") ARCH_SUFFIX = os.environ.get("ARCH_SUFFIX", "x64")
# Package metadata
METADATA = { METADATA = {
"name": "plezy", "name": "plezy",
"license": "GPL-3.0", "license": "GPL-3.0",
@@ -23,16 +21,13 @@ METADATA = {
"description": "A modern Plex and Jellyfin client for desktop and mobile", "description": "A modern Plex and Jellyfin client for desktop and mobile",
} }
# Icon sizes to generate
ICON_SIZES = [16, 32, 48, 64, 128, 256, 512] ICON_SIZES = [16, 32, 48, 64, 128, 256, 512]
# Architecture mappings per package format
ARCH_MAP = { ARCH_MAP = {
"x64": {"deb": "amd64", "rpm": "x86_64", "pacman": "x86_64"}, "x64": {"deb": "amd64", "rpm": "x86_64", "pacman": "x86_64"},
"arm64": {"deb": "arm64", "rpm": "aarch64", "pacman": "aarch64"}, "arm64": {"deb": "arm64", "rpm": "aarch64", "pacman": "aarch64"},
} }
# Distro-specific configuration
DISTROS = { DISTROS = {
"deb": { "deb": {
"type": "deb", "type": "deb",
+2 -16
View File
@@ -1,11 +1,6 @@
cmake_minimum_required(VERSION 3.13) cmake_minimum_required(VERSION 3.13)
project(runner LANGUAGES CXX) 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} add_executable(${BINARY_NAME}
"main.cc" "main.cc"
"my_application.cc" "my_application.cc"
@@ -15,8 +10,6 @@ add_executable(${BINARY_NAME}
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc" "${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(${BINARY_NAME})
# apply_standard_settings asks for cxx_std_14, which current compilers satisfy # 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. # compiler happens to default to.
target_compile_features(${BINARY_NAME} PRIVATE cxx_std_17) target_compile_features(${BINARY_NAME} PRIVATE cxx_std_17)
# Add preprocessor definitions for the application ID.
add_definitions(-DAPPLICATION_ID="${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 # 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 # 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_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl) pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET 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 # 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 # 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_link_libraries(wayland_protocols PUBLIC PkgConfig::WAYLAND_CLIENT)
target_compile_options(wayland_protocols PRIVATE -w) 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") add_library(simdutf STATIC "${simdutf_SOURCE_DIR}/simdutf.cpp")
target_include_directories(simdutf PUBLIC "${simdutf_SOURCE_DIR}") target_include_directories(simdutf PUBLIC "${simdutf_SOURCE_DIR}")
target_compile_features(simdutf PUBLIC cxx_std_14) target_compile_features(simdutf PUBLIC cxx_std_14)
# Suppress warnings in third-party code
target_compile_options(simdutf PRIVATE -w) 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 flutter)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::MPV) target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::MPV)
-8
View File
@@ -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 PRODUCT_NAME = Plezy
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2025 edde746 PRODUCT_COPYRIGHT = Copyright © 2025 edde746
+1 -7
View File
@@ -16,8 +16,7 @@ class MainFlutterWindow: NSWindow {
self.contentViewController = flutterViewController self.contentViewController = flutterViewController
self.setFrame(windowFrame, display: true) self.setFrame(windowFrame, display: true)
// Apply initial window configuration BEFORE frame restoration // Configure window chrome before restoring the saved frame.
// This prevents the window from shrinking on launch
self.titlebarAppearsTransparent = true self.titlebarAppearsTransparent = true
self.titleVisibility = .hidden self.titleVisibility = .hidden
self.styleMask.insert(.fullSizeContentView) self.styleMask.insert(.fullSizeContentView)
@@ -26,28 +25,23 @@ class MainFlutterWindow: NSWindow {
let toolbar = ForwardingToolbar(flutterViewController: flutterViewController) let toolbar = ForwardingToolbar(flutterViewController: flutterViewController)
self.toolbar = toolbar self.toolbar = toolbar
// Register MPV player plugin for video playback
MpvPlayerPlugin.register( MpvPlayerPlugin.register(
with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin")) with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin"))
// Register the audio-only MPV player plugin for music playback
MpvAudioPlayerPlugin.register( MpvAudioPlayerPlugin.register(
with: flutterViewController.registrar(forPlugin: "MpvAudioPlayerPlugin")) with: flutterViewController.registrar(forPlugin: "MpvAudioPlayerPlugin"))
// Register window utils plugin for dynamic titlebar/fullscreen control from Dart
WindowUtilsPlugin.register( WindowUtilsPlugin.register(
with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin")) with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin"))
WindowUtilsPlugin.setWindow(self) WindowUtilsPlugin.setWindow(self)
WindowUtilsPlugin.installWindowDelegate() WindowUtilsPlugin.installWindowDelegate()
WindowUtilsPlugin.syncWindowChrome() WindowUtilsPlugin.syncWindowChrome()
// Register Launch Services lookups used to detect installed external players
AppLookupPlugin.register( AppLookupPlugin.register(
with: flutterViewController.registrar(forPlugin: "AppLookupPlugin")) with: flutterViewController.registrar(forPlugin: "AppLookupPlugin"))
RegisterGeneratedPlugins(registry: flutterViewController) RegisterGeneratedPlugins(registry: flutterViewController)
// Enable window position/size persistence
self.setFrameAutosaveName("com.edde746.plezy.MainWindow") self.setFrameAutosaveName("com.edde746.plezy.MainWindow")
WindowUtilsPlugin.syncWindowChrome() WindowUtilsPlugin.syncWindowChrome()
@@ -47,8 +47,6 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
sourceWindow = window 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)) let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio))
videoView.wantsLayer = true videoView.wantsLayer = true
videoView.layer = metalLayer videoView.layer = metalLayer
@@ -57,14 +55,12 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
// Without this, the explicit main-window drawableSize persists in PiP. // Without this, the explicit main-window drawableSize persists in PiP.
metalLayer.drawableSize = .zero metalLayer.drawableSize = .zero
// Create a view controller for PIPViewController
let vc = NSViewController() let vc = NSViewController()
vc.view = videoView vc.view = videoView
pipVideoVC = vc pipVideoVC = vc
pipVideoView = videoView pipVideoView = videoView
// Configure PiP
pip.playing = delegate?.isPipPlaying ?? false pip.playing = delegate?.isPipPlaying ?? false
pip.aspectRatio = aspectRatio pip.aspectRatio = aspectRatio
pip.replacementWindow = window pip.replacementWindow = window
@@ -72,7 +68,6 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
delegate?.pipWillStart() delegate?.pipWillStart()
// Present PiP
pip.presentAsPicture(inPicture: vc) pip.presentAsPicture(inPicture: vc)
isActive = true isActive = true
delegate?.pipDidStart() delegate?.pipDidStart()
+1 -17
View File
@@ -11,14 +11,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
private weak var registrar: FlutterPluginRegistrar? private weak var registrar: FlutterPluginRegistrar?
var nameToId: [String: Int] = [:] var nameToId: [String: Int] = [:]
// MpvPluginShared conformance
var coreBase: MpvPlayerCoreBase? { playerCore } var coreBase: MpvPlayerCoreBase? { playerCore }
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) { func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {
playerCore?.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible) playerCore?.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible)
} }
func updatePlayerFrame() { playerCore?.updateFrame() } func updatePlayerFrame() { playerCore?.updateFrame() }
// PiP
private var pipController: MpvPipController? private var pipController: MpvPipController?
private var pipChannel: FlutterMethodChannel? private var pipChannel: FlutterMethodChannel?
private var autoPipEnabled = false private var autoPipEnabled = false
@@ -27,13 +25,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
// MARK: - FlutterPlugin Registration // MARK: - FlutterPlugin Registration
static func register(with registrar: FlutterPluginRegistrar) { static func register(with registrar: FlutterPluginRegistrar) {
// Method channel for commands
let methodChannel = FlutterMethodChannel( let methodChannel = FlutterMethodChannel(
name: "com.plezy/mpv_player", name: "com.plezy/mpv_player",
binaryMessenger: registrar.messenger binaryMessenger: registrar.messenger
) )
// Event channel for state updates
let eventChannel = FlutterEventChannel( let eventChannel = FlutterEventChannel(
name: "com.plezy/mpv_player/events", name: "com.plezy/mpv_player/events",
binaryMessenger: registrar.messenger binaryMessenger: registrar.messenger
@@ -110,7 +106,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
} }
} }
// MARK: - PiP
private func ensurePipController() -> MpvPipController { private func ensurePipController() -> MpvPipController {
if let existing = pipController { return existing } if let existing = pipController { return existing }
@@ -135,13 +130,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
let pip = ensurePipController() let pip = ensurePipController()
pip.setAutoStart(ready) pip.setAutoStart(ready)
if ready { if ready {
// Observe app resigning active to auto-enter PiP
NotificationCenter.default.removeObserver( NotificationCenter.default.removeObserver(
self, name: NSApplication.didResignActiveNotification, object: nil) self, name: NSApplication.didResignActiveNotification, object: nil)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
self, selector: #selector(appDidResignActive), self, selector: #selector(appDidResignActive),
name: NSApplication.didResignActiveNotification, object: nil) name: NSApplication.didResignActiveNotification, object: nil)
// Observe app becoming active to auto-exit PiP
NotificationCenter.default.removeObserver( NotificationCenter.default.removeObserver(
self, name: NSApplication.didBecomeActiveNotification, object: nil) self, name: NSApplication.didBecomeActiveNotification, object: nil)
NotificationCenter.default.addObserver( NotificationCenter.default.addObserver(
@@ -184,8 +177,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
return return
} }
// Get video dimensions for aspect ratio var aspectRatio = NSSize(width: 16, height: 9)
var aspectRatio = NSSize(width: 16, height: 9) // default
if let videoSize = playerCore.videoSize { if let videoSize = playerCore.videoSize {
aspectRatio = NSSize(width: videoSize.width, height: videoSize.height) aspectRatio = NSSize(width: videoSize.width, height: videoSize.height)
} }
@@ -226,14 +218,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
return return
} }
// Check if already initialized
if self.playerCore?.isInitialized == true { if self.playerCore?.isInitialized == true {
print("[MpvPlayerPlugin] Already initialized") print("[MpvPlayerPlugin] Already initialized")
result(true) result(true)
return return
} }
// Find the Flutter window
guard let (window, _, _) = self.findFlutterWindow() else { guard let (window, _, _) = self.findFlutterWindow() else {
print("[MpvPlayerPlugin] Failed to find Flutter window") print("[MpvPlayerPlugin] Failed to find Flutter window")
result( result(
@@ -242,7 +232,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
return return
} }
// Create and initialize player core
let core = MpvPlayerCore() let core = MpvPlayerCore()
core.delegate = self core.delegate = self
@@ -256,7 +245,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
self.playerCore = core self.playerCore = core
// Start hidden
core.setVisible(false) core.setVisible(false)
print("[MpvPlayerPlugin] Initialized successfully") print("[MpvPlayerPlugin] Initialized successfully")
@@ -303,7 +291,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
} }
} }
// Fallback
for window in NSApplication.shared.windows { for window in NSApplication.shared.windows {
if let contentView = window.contentView, if let contentView = window.contentView,
let contentVC = window.contentViewController let contentVC = window.contentViewController
@@ -334,13 +321,10 @@ extension MpvPlayerPlugin: MpvPipDelegate {
playerCore?.isPipActive = false playerCore?.isPipActive = false
enteredPipViaAuto = false enteredPipViaAuto = false
// Detach the Metal layer from the PiP wrapper view
pipController?.detachLayer() pipController?.detachLayer()
// Re-attach the Metal layer to the main window
playerCore?.reattachMetalLayer() playerCore?.reattachMetalLayer()
// Force a redraw if paused (prevents black frame after PiP exit)
if playerCore?.isPaused == true { if playerCore?.isPaused == true {
playerCore?.forceDraw() playerCore?.forceDraw()
} }
-4
View File
@@ -5,8 +5,6 @@ class WindowDelegate: NSObject, NSWindowDelegate {
weak var channel: FlutterMethodChannel? weak var channel: FlutterMethodChannel?
weak var window: NSWindow? 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 = [ private let fullScreenPresentationOptions: NSApplication.PresentationOptions = [
.fullScreen, .fullScreen,
.autoHideToolbar, .autoHideToolbar,
@@ -62,7 +60,6 @@ class WindowDelegate: NSObject, NSWindowDelegate {
func windowWillEnterFullScreen(_ notification: Notification) { func windowWillEnterFullScreen(_ notification: Notification) {
guard let window = window else { return } guard let window = window else { return }
applyFullScreenChrome(to: window) applyFullScreenChrome(to: window)
// Notify Dart for state management only
emit("windowWillEnterFullScreen") emit("windowWillEnterFullScreen")
} }
@@ -72,7 +69,6 @@ class WindowDelegate: NSObject, NSWindowDelegate {
func windowWillExitFullScreen(_ notification: Notification) { func windowWillExitFullScreen(_ notification: Notification) {
guard let window = window else { return } guard let window = window else { return }
// Hide title and make titlebar transparent BEFORE exiting
window.titleVisibility = .hidden window.titleVisibility = .hidden
window.titlebarAppearsTransparent = true window.titlebarAppearsTransparent = true
emit("windowWillExitFullScreen") emit("windowWillExitFullScreen")
+1 -12
View File
@@ -1,8 +1,6 @@
import Cocoa import Cocoa
import FlutterMacOS import FlutterMacOS
// MARK: - ForwardingView
// A view that forwards mouse events to the Flutter view controller
class ForwardingView: NSView { class ForwardingView: NSView {
weak var flutterViewController: NSViewController? 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 { class ForwardingToolbar: NSToolbar, NSToolbarDelegate {
let flutterViewController: NSViewController let flutterViewController: NSViewController
@@ -26,7 +22,6 @@ class ForwardingToolbar: NSToolbar, NSToolbarDelegate {
self.delegate = self self.delegate = self
self.showsBaselineSeparator = false self.showsBaselineSeparator = false
// Prevent toolbar customization UI (the "rounded box")
self.allowsUserCustomization = false self.allowsUserCustomization = false
self.allowsExtensionItems = false self.allowsExtensionItems = false
if #available(macOS 15.0, *) { if #available(macOS 15.0, *) {
@@ -48,11 +43,11 @@ class ForwardingToolbar: NSToolbar, NSToolbarDelegate {
) -> NSToolbarItem? { ) -> NSToolbarItem? {
if itemIdentifier == NSToolbarItem.Identifier("ForwardingItem") { if itemIdentifier == NSToolbarItem.Identifier("ForwardingItem") {
let item = NSToolbarItem(itemIdentifier: itemIdentifier) let item = NSToolbarItem(itemIdentifier: itemIdentifier)
item.isBordered = false // Remove the rounded box appearance
let view = ForwardingView() let view = ForwardingView()
view.flutterViewController = flutterViewController view.flutterViewController = flutterViewController
view.widthAnchor.constraint(lessThanOrEqualToConstant: 100000).isActive = true view.widthAnchor.constraint(lessThanOrEqualToConstant: 100000).isActive = true
view.widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true view.widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true
item.isBordered = false
item.view = view item.view = view
return item return item
} }
@@ -68,7 +63,6 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin {
private var windowDelegate: WindowDelegate? private var windowDelegate: WindowDelegate?
private var originalButtonConstraints: [NSWindow.ButtonType: [NSLayoutConstraint]] = [:] private var originalButtonConstraints: [NSWindow.ButtonType: [NSLayoutConstraint]] = [:]
// Centralized traffic light positions - the single source of truth
private static let customButtonPositions: [(NSWindow.ButtonType, CGPoint)] = [ private static let customButtonPositions: [(NSWindow.ButtonType, CGPoint)] = [
(.closeButton, CGPoint(x: 20, y: 21)), (.closeButton, CGPoint(x: 20, y: 21)),
(.miniaturizeButton, CGPoint(x: 40, y: 21)), (.miniaturizeButton, CGPoint(x: 40, y: 21)),
@@ -224,7 +218,6 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin {
window: NSWindow, buttonType: NSWindow.ButtonType, offset: CGPoint window: NSWindow, buttonType: NSWindow.ButtonType, offset: CGPoint
) { ) {
withButton(buttonType, in: window) { button, superview in withButton(buttonType, in: window) { button, superview in
// Store original constraints if not already stored
if originalButtonConstraints[buttonType] == nil { if originalButtonConstraints[buttonType] == nil {
let constraints = superview.constraints.filter { constraint in let constraints = superview.constraints.filter { constraint in
(constraint.firstItem as? NSButton) == button (constraint.firstItem as? NSButton) == button
@@ -233,12 +226,10 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin {
originalButtonConstraints[buttonType] = constraints originalButtonConstraints[buttonType] = constraints
} }
// Remove existing position constraints
superview.removeConstraints(positionConstraints(for: button, in: superview)) superview.removeConstraints(positionConstraints(for: button, in: superview))
button.translatesAutoresizingMaskIntoConstraints = false button.translatesAutoresizingMaskIntoConstraints = false
// Add new positioning constraints
superview.addConstraints([ superview.addConstraints([
button.leftAnchor.constraint(equalTo: superview.leftAnchor, constant: offset.x), button.leftAnchor.constraint(equalTo: superview.leftAnchor, constant: offset.x),
button.topAnchor.constraint(equalTo: superview.topAnchor, constant: offset.y), 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) { private func resetButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType) {
withButton(buttonType, in: window) { button, superview in withButton(buttonType, in: window) { button, superview in
// Remove custom constraints
superview.removeConstraints(positionConstraints(for: button, in: superview)) superview.removeConstraints(positionConstraints(for: button, in: superview))
// Restore original constraints if we have them
if let originalConstraints = originalButtonConstraints[buttonType] { if let originalConstraints = originalButtonConstraints[buttonType] {
superview.addConstraints(originalConstraints) superview.addConstraints(originalConstraints)
originalButtonConstraints.removeValue(forKey: buttonType) originalButtonConstraints.removeValue(forKey: buttonType)
@@ -25,15 +25,10 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
/** SafUtilPlugin */
class SafUtilPlugin : class SafUtilPlugin :
FlutterPlugin, FlutterPlugin,
MethodCallHandler, MethodCallHandler,
ActivityAware { 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 channel: MethodChannel
private lateinit var context: Context private lateinit var context: Context
@@ -138,10 +133,8 @@ class SafUtilPlugin :
val lastModified = cursor.getLong(4) val lastModified = cursor.getLong(4)
val documentUri = DocumentsContract.buildDocumentUriUsingTree(mUri, documentId) 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 val isDirectory = DocumentsContract.Document.MIME_TYPE_DIR == mimeType
// Create a dictionary (map) for each file with its details
val fileInfo = val fileInfo =
fileObjMap( fileObjMap(
documentUri, documentUri,
@@ -325,8 +318,7 @@ class SafUtilPlugin :
if (findRes == null) { if (findRes == null) {
val createRes = curDocument.createDirectory(curName) val createRes = curDocument.createDirectory(curName)
if (createRes != null && createRes.name != curName) { if (createRes != null && createRes.name != curName) {
// There are cases where the created directory has a different name due to concurrent operations. // SAF may rename a concurrently created directory; resolve the requested name.
// In this case, we need to find the directory with the correct name again.
val findRes2 = findDirectChild(curDocument.uri, curName) val findRes2 = findDirectChild(curDocument.uri, curName)
nextDocument = nextDocument =
if (findRes2 != null) { if (findRes2 != null) {
@@ -504,7 +496,6 @@ class SafUtilPlugin :
return return
} }
// Store the result to return the URI later
pendingResult = result pendingResult = result
pendingArguments = PendingDirArguments(writePermission, persistablePermission) pendingArguments = PendingDirArguments(writePermission, persistablePermission)
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
@@ -548,7 +539,6 @@ class SafUtilPlugin :
return return
} }
// Store the result to return the URI later
pendingResult = result pendingResult = result
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT) val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
intent.addCategory(Intent.CATEGORY_OPENABLE) 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( private fun onActivityResult(
requestCode: Int, requestCode: Int,
resultCode: Int, resultCode: Int,
+8 -19
View File
@@ -48,22 +48,15 @@ dependencies:
cryptography: ^2.9.0 cryptography: ^2.9.0
file_picker: ^11.0.2 file_picker: ^11.0.2
saf_util: saf_util:
# Vendored upstream 3.1.0 with a pending Result lifecycle fix (CHANGELOG) — # Vendored 3.1.0: fixes upstream's duplicate MethodChannel result.
# upstream still double-replies MethodChannel results and crashes the Activity.
path: packages/saf_util path: packages/saf_util
saf_stream: ^3.1.0 saf_stream: ^3.1.0
material_symbols_icons: ^4.2951.0 material_symbols_icons: ^4.2951.0
web_socket_channel: ^3.0.1 web_socket_channel: ^3.0.1
dart_discord_presence: ^1.2.0 dart_discord_presence: ^1.2.0
flutter_svg: ^2.2.3 flutter_svg: ^2.2.3
# Fork note: 41c16be creates the desktop temp parent before opening the # Fork 41c16be fixes upstream #649's missing desktop temp parent.
# partial file, independently fixing upstream #649's missing-directory case. # Keep the same-filesystem move/resume cleanup; no per-host TLS bypass.
# 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.
background_downloader: background_downloader:
git: git:
url: https://github.com/edde746/background_downloader url: https://github.com/edde746/background_downloader
@@ -87,7 +80,7 @@ dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
flutter_lints: ^6.0.0 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 mgenware_dart_lints: ^8.0.0
fake_async: ^1.3.3 fake_async: ^1.3.3
stream_channel: ^2.1.4 stream_channel: ^2.1.4
@@ -95,15 +88,13 @@ dev_dependencies:
json_serializable: ^6.7.1 json_serializable: ^6.7.1
slang_build_runner: ^4.14.0 slang_build_runner: ^4.14.0
sentry_dart_plugin: ^3.3.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 dart_code_linter: ^4.1.8
drift_dev: ^2.28.3 drift_dev: ^2.28.3
shared_preferences_platform_interface: ^2.4.0 shared_preferences_platform_interface: ^2.4.0
path_provider_platform_interface: ^2.1.0 path_provider_platform_interface: ^2.1.0
# Drive the real desktop preference backends against a temp file in tests, so # Exercise vendored desktop preference stores against real file I/O,
# the #1732 corrupt-store repair loop and the vendored atomic write are both # including #1732 repair and atomic writes.
# covered against genuine file I/O. All four expose `fs`/`pathProvider`.
# The two shared_preferences packages resolve to the vendored copies below.
shared_preferences_linux: ^2.4.1 shared_preferences_linux: ^2.4.1
shared_preferences_windows: ^2.4.1 shared_preferences_windows: ^2.4.1
path_provider_linux: ^2.2.1 path_provider_linux: ^2.2.1
@@ -128,9 +119,7 @@ dependency_overrides:
url: https://github.com/edde746/auto_updater url: https://github.com/edde746/auto_updater
ref: 9e150f7 ref: 9e150f7
path: packages/auto_updater_windows path: packages/auto_updater_windows
# Vendored 2.4.1 with an atomic store replacement (CHANGELOG, provenance.json) # Vendored 2.4.1: atomic replacement prevents upstream #1732 corruption.
# — upstream truncates the live preference document on every write, which is
# the corruption behind #1732.
shared_preferences_windows: shared_preferences_windows:
path: packages/shared_preferences_windows path: packages/shared_preferences_windows
shared_preferences_linux: shared_preferences_linux:
+5 -10
View File
@@ -15,9 +15,8 @@ FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
if len(sys.argv) > 2: if len(sys.argv) > 2:
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]") 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 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. # Resolve the shared bootstrap beside the workflow so fixture checks use their
# Resolved beside the workflow rather than from ROOT so that checking a fixture # local action rather than the checkout's real action.
# tree exercises this rule instead of silently re-reading the real action.
SETUP_FLUTTER_GIT = WORKFLOW.parents[1] / "actions/setup-flutter-git/action.yml" SETUP_FLUTTER_GIT = WORKFLOW.parents[1] / "actions/setup-flutter-git/action.yml"
text = WORKFLOW.read_text(encoding="utf-8") text = WORKFLOW.read_text(encoding="utf-8")
errors: list[str] = [] errors: list[str] = []
@@ -168,9 +167,7 @@ require(bool(setup_flutter_git), "missing .github/actions/setup-flutter-git/acti
for expected in ( for expected in (
f'$version = "{FLUTTER_VERSION}"', f'$version = "{FLUTTER_VERSION}"',
f'$expectedCommit = "{FLUTTER_COMMIT}"', f'$expectedCommit = "{FLUTTER_COMMIT}"',
# Fetch the release tag rather than the bare commit: the commit is only # Fetch and verify the release tag so moved tags cannot change the SDK.
# 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.
'git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"', 'git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"',
'git -C $root checkout --detach "refs/tags/$version"', 'git -C $root checkout --detach "refs/tags/$version"',
"$actualCommit = git -C $root rev-parse HEAD", "$actualCommit = git -C $root rev-parse HEAD",
@@ -336,10 +333,8 @@ require(
"every Flutter SDK cache must define its trusted cache key", "every Flutter SDK cache must define its trusted cache key",
) )
# check_workflow_action_pins.py owns the SHA-pin rule for every workflow, this # Action-pin checks run elsewhere; this guard adds the checkout credential
# one included; build.yml only adds the credential invariant on top, because it # invariant for the workflow-dispatch-only build.
# is workflow_dispatch-only and so escapes the pull-request rule in
# check_workflow_security.py.
remote_actions = [ remote_actions = [
reference.rpartition("@")[0] reference.rpartition("@")[0]
for _, reference in iter_uses_references(text) for _, reference in iter_uses_references(text)
+5 -17
View File
@@ -40,11 +40,7 @@ _UI_ARGUMENT_RE = re.compile(r"(?<![A-Za-z0-9_])(?:" + "|".join(_UI_ARGUMENTS) +
_TEXT_RE = re.compile(r"(?:\bText|\bSelectableText)\s*\(\s*$") _TEXT_RE = re.compile(r"(?:\bText|\bSelectableText)\s*\(\s*$")
_EXCLUDED_ARGUMENT_RE = re.compile(r"(?:debugLabel|fontFamily)\s*:\s*$") _EXCLUDED_ARGUMENT_RE = re.compile(r"(?:debugLabel|fontFamily)\s*:\s*$")
_KEY_RE = re.compile(r"(?:\bKey|\bValueKey)\s*\(\s*$") _KEY_RE = re.compile(r"(?:\bKey|\bValueKey)\s*\(\s*$")
# `createdLog:`/`log:` name a diagnostic sink, never rendered copy. Matched # `log:`/`createdLog:` are diagnostic sinks, including inside lambdas.
# unanchored because the literal is usually behind a lambda
# (`createdLog: (playlist) => '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.
_DIAGNOSTIC_RE = re.compile( _DIAGNOSTIC_RE = re.compile(
r"(?:\bappLogger\.|\bSentry\.|\bassert\s*\(|\bthrow\b|(?:\blog|[a-z]Log)\s*:)" 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*\()" r"\$\{\s*(?:t\.|context\.t\b|Translations\.of\s*\()"
) )
_T_PARAMETER_RE = re.compile(r"(?:\(\s*t\s*\)|\bt)\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 # Rule 4 catches phrases assigned or returned before a widget renders them.
# straight to a widget. This is the shape issue #1856 actually had -- # Restrict it to multi-word phrases to avoid confusing identifiers with UI copy;
# `baseButtonText = 'Skip Intro';` a few lines above the `Text(buttonText)` # single-word labels remain indistinguishable without dataflow analysis.
# 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.
_PHRASE_RE = re.compile(r"[A-Za-z]\s+[A-Za-z]") _PHRASE_RE = re.compile(r"[A-Za-z]\s+[A-Za-z]")
_BOUND_LITERAL_RE = re.compile(r"(?:\breturn|=>|(?<![=!<>+\-*/%&|^~])=)\s*$") _BOUND_LITERAL_RE = re.compile(r"(?:\breturn|=>|(?<![=!<>+\-*/%&|^~])=)\s*$")
_RENDERS_UI_RE = re.compile( _RENDERS_UI_RE = re.compile(
@@ -326,7 +314,7 @@ def scan(
seen_literals: dict[str, set[str]] = {} seen_literals: dict[str, set[str]] = {}
for path in sorted(lib_dir.rglob("*.dart")): for path in sorted(lib_dir.rglob("*.dart")):
relative = path.relative_to(root).as_posix() 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 ( if (
relative.startswith(("lib/i18n/", "lib/dev/")) relative.startswith(("lib/i18n/", "lib/dev/"))
or path.name.endswith((".g.dart", ".freezed.dart")) or path.name.endswith((".g.dart", ".freezed.dart"))
+18 -64
View File
@@ -1,32 +1,9 @@
#!/usr/bin/env python3 #!/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 The runner's CMake link graph maps through pkg-config modules to the hand-maintained
driver-coupled libraries (libEGL, libwayland-*, libGL, libdrm ...): they must package lists. This catches undeclared runtime libraries before they fail on users'
come from the host or the app will not talk to the compositor it is running machines; the built-bundle check covers plugin links unavailable before `pub get`.
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.
""" """
from pathlib import Path 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" RUNNER_CMAKE = LINUX / "runner/CMakeLists.txt"
PACKAGES_PY = LINUX / "packaging/build-packages.py" PACKAGES_PY = LINUX / "packaging/build-packages.py"
BUNDLE_SH = LINUX / "packaging/bundle-libs.sh" 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") CMAKE_FILES = (RUNNER_CMAKE, LINUX / "CMakeLists.txt", LINUX / "flutter/CMakeLists.txt")
# pkg-config modules whose library ships *inside* the package instead of being # Bundled modules have no host dependency; their excluded runtime links are
# depended on. libmpv is pinned and Wayland-enabled because the video plane needs # checked separately against the built bundle.
# 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 = {"mpv"} BUNDLED_MODULES = {"mpv"}
# pkg-config module -> the package that ships its runtime library, per distro. # pkg-config module -> runtime package name for each distro.
# Only modules the runner actually links are consulted, so an unused entry here
# is harmless; a missing one is an error.
RUNTIME_PACKAGES = { RUNTIME_PACKAGES = {
"gtk+-3.0": {"deb": "libgtk-3-0", "rpm": "gtk3", "pacman": "gtk3"}, "gtk+-3.0": {"deb": "libgtk-3-0", "rpm": "gtk3", "pacman": "gtk3"},
"epoxy": {"deb": "libepoxy0", "rpm": "libepoxy", "pacman": "libepoxy"}, "epoxy": {"deb": "libepoxy0", "rpm": "libepoxy", "pacman": "libepoxy"},
# Reached through the `flutter` INTERFACE target rather than named by the # Reached through the `flutter` INTERFACE target.
# runner, which is why the graph has to cross file boundaries to see them.
"glib-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"}, "glib-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
"gio-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"}, "gio-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
"wayland-client": { "wayland-client": {
"deb": "libwayland-client0", "deb": "libwayland-client0",
"rpm": "libwayland-client", "rpm": "libwayland-client",
# Arch ships every libwayland-* in the one `wayland` package. # Arch ships all libwayland-* libraries in `wayland`.
"pacman": "wayland", "pacman": "wayland",
}, },
"wayland-egl": { "wayland-egl": {
@@ -75,7 +45,7 @@ RUNTIME_PACKAGES = {
"rpm": "libwayland-egl", "rpm": "libwayland-egl",
"pacman": "wayland", "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"}, "egl": {"deb": "libegl1", "rpm": "libglvnd-egl", "pacman": "libglvnd"},
} }
@@ -95,9 +65,8 @@ def read(path: Path) -> str:
return "" return ""
# Keywords that carry no target name.
LINK_KEYWORDS = {"PRIVATE", "PUBLIC", "INTERFACE", "optimized", "debug", "general"} 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") 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) options = "|".join(PKG_OPTIONS)
for path in CMAKE_FILES: for path in CMAKE_FILES:
for match in re.finditer( for match in re.finditer(
# The options may precede the module names, so skip any run of them # Skip options before module names.
# 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.
r"pkg_check_modules\(\s*(\w+)\b[^)]*?IMPORTED_TARGET\s+((?:(?:" + options + r")\s+)*[^)]*)\)", r"pkg_check_modules\(\s*(\w+)\b[^)]*?IMPORTED_TARGET\s+((?:(?:" + options + r")\s+)*[^)]*)\)",
strip_comments(read(path)), strip_comments(read(path)),
): ):
# A moduleSpec is `<name>` or `<name><op><version>`, so the version # Strip version constraints before package lookup.
# 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.
names = [ names = [
re.split(r"[<>=!]", t, maxsplit=1)[0] for t in match.group(2).split() if t not in PKG_OPTIONS 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 {} return {}
# Every file, not just the runner's: `flutter` is defined in flutter/CMakeLists.txt # Include every owned CMake input; missing files fail closed instead of shrinking
# and propagates GTK, GLIB and GIO to whatever links it, so a graph built from one # the graph and silently passing.
# 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.
absent = [path for path in CMAKE_FILES if not path.is_file()] absent = [path for path in CMAKE_FILES if not path.is_file()]
if absent: if absent:
for path in 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") require(bool(depends), "no distro depends lists were found, so nothing was checked")
# The exclusion list is what makes declaring these mandatory rather than # These libraries must remain host-provided; bundling them would invalidate this guard.
# optional. If bundling ever starts covering them, this guard is the wrong shape.
bundle = read(BUNDLE_SH) bundle = read(BUNDLE_SH)
for pattern in (r"libEGL\.so", r"libwayland.*\.so"): for pattern in (r"libEGL\.so", r"libwayland.*\.so"):
require( require(
@@ -253,9 +209,7 @@ for target in sorted(linked):
for module in target_modules: for module in target_modules:
checked_modules += 1 checked_modules += 1
if module in BUNDLED_MODULES: if module in BUNDLED_MODULES:
# Shipped inside the package, so there is no dependency to find. Still # Bundled modules have no host dependency but remain part of the summary.
# counted, so the summary keeps naming everything the walk reached and
# a module going missing is a drop rather than a silent skip.
continue continue
packages = RUNTIME_PACKAGES.get(module) packages = RUNTIME_PACKAGES.get(module)
if packages is None: if packages is None:
+4 -8
View File
@@ -24,11 +24,9 @@ PROGUARD_RULES = Path("android/app/proguard-rules.pro")
APP_JAVA_ROOT = Path("android/app/src/main/java") APP_JAVA_ROOT = Path("android/app/src/main/java")
CPP_ROOT = Path("android/app/src/main/cpp") CPP_ROOT = Path("android/app/src/main/cpp")
NATIVE_SUFFIXES = {".c", ".cc", ".cpp", ".h", ".hpp"} NATIVE_SUFFIXES = {".c", ".cc", ".cpp", ".h", ".hpp"}
# Namespaces the app borrows from a dependency purely so that dependency can reflect on # Dependency namespaces reached only through reflection have no direct callers.
# them. A class under one of these has no direct caller by construction.
REFLECTED_NAMESPACES = ("androidx/media3/",) REFLECTED_NAMESPACES = ("androidx/media3/",)
# Framework types live on the bootclasspath, never in the app's dex, so R8 cannot rename # Bootclasspath framework types are not in the app dex and need no keep rule.
# them and they need no keep.
PLATFORM_PREFIXES = ("java.", "javax.", "android.") PLATFORM_PREFIXES = ("java.", "javax.", "android.")
_STRING_LITERAL = re.compile(r'"((?:[^"\\]|\\.)*)"') _STRING_LITERAL = re.compile(r'"((?:[^"\\]|\\.)*)"')
@@ -42,9 +40,7 @@ _MEMBER_LOOKUP = re.compile(
+ r"\s*\)" + r"\s*\)"
) )
_DESCRIPTOR_CLASS = re.compile(r"L([\w/$]+);") _DESCRIPTOR_CLASS = re.compile(r"L([\w/$]+);")
# Only -keep and -keepclasseswithmembers protect a class from both shrinking and # Only -keep variants without allowshrinking/allowobfuscation keep classes and names.
# renaming. -keepclassmembers/-keepclassmembernames cover members alone, and the
# -keepnames family allows shrinking, so none of them save a class nothing references.
_KEEP = re.compile( _KEEP = re.compile(
r"^-(?:keep|keepclasseswithmembers)((?:\s*,\s*\w+)*)\s+(?:class|interface|enum)\s+(\S+)" r"^-(?:keep|keepclasseswithmembers)((?:\s*,\s*\w+)*)\s+(?:class|interface|enum)\s+(\S+)"
r"(?:\s*\{(.*?)\})?", r"(?:\s*\{(.*?)\})?",
@@ -62,7 +58,7 @@ class Keep:
self.members = members self.members = members
self._regex = re.compile( self._regex = re.compile(
"".join( "".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)) {"**": r".*", "*": r"[^.]*", "?": r"."}.get(token, re.escape(token))
for token in re.findall(r"\*\*|[*?]|[^*?]+", pattern) for token in re.findall(r"\*\*|[*?]|[^*?]+", pattern)
) )
+4 -7
View File
@@ -40,8 +40,7 @@ require(
) )
iss = template() iss = template()
# The script used to carry two near-identical copies of the whole .iss, one per # A single template prevents architecture copies from drifting.
# architecture shape. Anything that appears twice again has drifted apart.
for once in ( for once in (
r"^\[Setup\]$", r"^\[Setup\]$",
r"^\[Code\]$", r"^\[Code\]$",
@@ -76,9 +75,7 @@ require(
"the dual-architecture [Files] entries must keep their architecture checks", "the dual-architecture [Files] entries must keep their architecture checks",
) )
# A fresh install stays per-user and prompts for nothing; only an existing # Fresh installs stay per-user; only /ALLUSERS may trigger elevation.
# machine-wide copy pulls in elevation, and only via /ALLUSERS, which Inno
# ignores unless the commandline override is allowed.
require( require(
re.search(r"(?m)^PrivilegesRequired=lowest\s*$", iss) is not None, re.search(r"(?m)^PrivilegesRequired=lowest\s*$", iss) is not None,
"a fresh install must stay per-user; PrivilegesRequired=lowest", "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", "allowing dialog makes a silent install with no previous copy prompt; winget installs that way",
) )
# The elevation path itself. # Verify the elevation path.
require( require(
"IsAdminInstallMode" in iss, "IsAdminInstallMode" in iss,
"the elevation path must be skipped once Setup already runs in administrative install mode", "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", "a refused elevation must explain itself instead of failing silently",
) )
# Behavior other tooling already depends on. # Preserve behavior required by release tooling.
require( require(
"{param:WINGET|0}" in iss and "{app}\\.winget" in iss, "{param:WINGET|0}" in iss and "{app}\\.winget" in iss,
"the winget marker file gates UpdateService.useNativeUpdater", "the winget marker file gates UpdateService.useNativeUpdater",
+12 -31
View File
@@ -36,18 +36,14 @@ PUBSPEC = ROOT / "pubspec.yaml"
WORKFLOW = ROOT / ".github/workflows/build.yml" WORKFLOW = ROOT / ".github/workflows/build.yml"
MSIX_STEP = "Build Store package (MSIX)" MSIX_STEP = "Build Store package (MSIX)"
BUNDLE = "plezy-windows.msixbundle" BUNDLE = "plezy-windows.msixbundle"
# The identity reserved in Partner Center. All three are pinned here because a # Partner Center reserves these identity values and derives the package family name.
# 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.
IDENTITY_NAME = "edde746.Plezy" IDENTITY_NAME = "edde746.Plezy"
PUBLISHER = "CN=AA9C53CB-AD3C-48DA-B3E3-D1E8986D4E25" PUBLISHER = "CN=AA9C53CB-AD3C-48DA-B3E3-D1E8986D4E25"
PUBLISHER_DISPLAY_NAME = "edde746" PUBLISHER_DISPLAY_NAME = "edde746"
PACKAGE_FAMILY_SUFFIX = "13q3sv6jzathm" PACKAGE_FAMILY_SUFFIX = "13q3sv6jzathm"
FOUNDATION = "http://schemas.microsoft.com/appx/manifest/foundation/windows10" FOUNDATION = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"
ASSET_REFERENCE = re.compile(r"assets\\([A-Za-z0-9._-]+\.png)") 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 # Package child order is fixed by the foundation schema.
# subset of these, but never a different order.
SCHEMA_ORDER = ( SCHEMA_ORDER = (
"Identity", "Identity",
"PhoneIdentity", "PhoneIdentity",
@@ -68,12 +64,10 @@ REQUIRED_ELEMENTS = (
"Applications", "Applications",
) )
REQUIRED_CAPABILITIES = ("runFullTrust", "internetClient", "privateNetworkClientServer") REQUIRED_CAPABILITIES = ("runFullTrust", "internetClient", "privateNetworkClientServer")
# Certification requires these three. The optional tile and splash assets are # Certification requires these capabilities; optional assets are checked only when referenced.
# only checked once referenced, since dropping them is a legitimate choice.
REQUIRED_ASSETS = ("StoreLogo.png", "Square150x150Logo.png", "Square44x44Logo.png") REQUIRED_ASSETS = ("StoreLogo.png", "Square150x150Logo.png", "Square44x44Logo.png")
# Normalized so that every pattern below can anchor on \n and $ regardless of # Normalize line endings so subsequent patterns are portable.
# whether this checkout stores the PowerShell scripts with CRLF endings.
text = SCRIPT.read_text(encoding="utf-8").replace("\r\n", "\n") text = SCRIPT.read_text(encoding="utf-8").replace("\r\n", "\n")
errors: list[str] = [] errors: list[str] = []
@@ -144,8 +138,7 @@ require(
prelude = declarations() prelude = declarations()
manifest_template = template() manifest_template = template()
# build-installer.ps1 once carried one whole .iss per architecture shape and the # A single manifest template prevents architecture copies from drifting.
# copies drifted apart. Anything that appears twice here has drifted too.
for once in ( for once in (
r'^ return @"$', r'^ return @"$',
r"^<Package ", r"^<Package ",
@@ -158,9 +151,7 @@ for once in (
f"{once} must match exactly one line; a second copy of the template will drift", f"{once} must match exactly one line; a second copy of the template will drift",
) )
# The two values the caller supplies, plus every identity string the function # Caller parameters and declared identity values must cover every interpolation.
# declares. Together they must cover the whole template, so an interpolation
# added there has to be declared here before it can pass.
for parameter in ("MsixVersion", "Architecture"): for parameter in ("MsixVersion", "Architecture"):
require( require(
f"[Parameter(Mandatory)][string]${parameter}" in prelude, f"[Parameter(Mandatory)][string]${parameter}" in prelude,
@@ -198,8 +189,7 @@ if package is not None:
if identity is None: if identity is None:
require(False, "the manifest must declare an Identity element") require(False, "the manifest must declare an Identity element")
else: else:
# Both patterns are schema constraints: makeappx refuses the package # makeappx rejects either schema violation before reading payloads.
# before reading a single payload file when either is violated.
require( require(
re.fullmatch(r"[-.A-Za-z0-9]{3,50}", identity.get("Name") or "") is not None, re.fullmatch(r"[-.A-Za-z0-9]{3,50}", identity.get("Name") or "") is not None,
"Identity/@Name must match the schema's [-.A-Za-z0-9]+ pattern; an underscore " "Identity/@Name must match the schema's [-.A-Za-z0-9]+ pattern; an underscore "
@@ -263,8 +253,7 @@ if package is not None:
"a packaged Win32 app must enter through Windows.FullTrustApplication", "a packaged Win32 app must enter through Windows.FullTrustApplication",
) )
# Assets are named in attributes (the tile logos) and in element text (the # Asset paths appear in attributes and element text.
# Properties/Logo), so both are scanned.
referenced = { referenced = {
match.group(1) match.group(1)
for element in package.iter() for element in package.iter()
@@ -280,9 +269,7 @@ if package is not None:
"tree; packaging fails on a missing asset", "tree; packaging fails on a missing asset",
) )
# Independent of the manifest: Partner Center reports the package family name, # Recompute the Partner Center package-family suffix to catch publisher drift.
# so recomputing it from the pinned publisher proves that string is byte-exact.
# A mistyped publisher otherwise only surfaces as a rejected upload.
require( require(
package_family_suffix(PUBLISHER) == PACKAGE_FAMILY_SUFFIX, package_family_suffix(PUBLISHER) == PACKAGE_FAMILY_SUFFIX,
f"the pinned publisher must hash to the package family name reported by Partner " f"the pinned publisher must hash to the package family name reported by Partner "
@@ -314,8 +301,7 @@ require(
"a version that is not major.minor.patch must be rejected instead of packaged", "a version that is not major.minor.patch must be rejected instead of packaged",
) )
# Native exit codes are invisible to $ErrorActionPreference, so the one # PowerShell does not surface native exit codes; centralize the $LASTEXITCODE check.
# invocation that checks $LASTEXITCODE has to be the only one.
require( require(
len(re.findall(r"(?m)^ & \$Tool @Arguments$", text)) == 1 len(re.findall(r"(?m)^ & \$Tool @Arguments$", text)) == 1
and "& $MakeAppx" not in text and "& $MakeAppx" not in text
@@ -324,11 +310,7 @@ require(
"every SDK tool call must go through the single invocation that checks $LASTEXITCODE", "every SDK tool call must go through the single invocation that checks $LASTEXITCODE",
) )
# The taskbar, task view and Alt-Tab draw the small logo on a plate filled with # Unplated variants prevent the shell's accent-colored plate; resources.pri resolves them.
# BackgroundColor, and the manifest's transparent background leaves the shell
# painting the user's accent colour behind the icon. Only an altform-unplated
# variant suppresses that plate, and qualified variants resolve solely through
# resources.pri - as plain payload files they are inert.
for form in ("altform-unplated", "altform-lightunplated"): for form in ("altform-unplated", "altform-lightunplated"):
require( require(
any(ASSETS.glob(f"Square44x44Logo.targetsize-*_{form}.png")), any(ASSETS.glob(f"Square44x44Logo.targetsize-*_{form}.png")),
@@ -351,8 +333,7 @@ require(
"fails publisher-identity validation", "fails publisher-identity validation",
) )
# The version the manifest carries is passed in by the workflow, so the link # The workflow supplies the manifest version from pubspec.yaml.
# back to pubspec.yaml lives there rather than in the script.
workflow = WORKFLOW.read_text(encoding="utf-8") workflow = WORKFLOW.read_text(encoding="utf-8")
package_windows = job_block(workflow, "package-windows") package_windows = job_block(workflow, "package-windows")
require(bool(package_windows), "missing package-windows job") require(bool(package_windows), "missing package-windows job")
+2 -16
View File
@@ -1,12 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -uo pipefail set -uo pipefail
# Git sets GIT_DIR (and friends) for hook invocations. Inside `flutter pub # Hook-invoked Flutter commands inherit GIT_* variables and can misreport the SDK
# run`, that leaks into Flutter's own SDK-version probe (`git describe` from # version; clear them so hooks and direct invocations behave identically.
# Flutter's checkout) and makes Flutter misreport its version as
# `1.35.1-0.0.pre-1`, which then fails dependency resolution. Strip those
# vars so the script behaves the same when invoked from a hook as it does
# from a plain shell.
unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_PREFIX unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_PREFIX
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -37,7 +33,6 @@ have_dart_code_linter() {
FAILED=0 FAILED=0
# 1. dart format (mirrors ci.yml "Verify formatting")
section "dart format" section "dart format"
files=() files=()
while IFS= read -r -d '' f; do files+=("$f"); done < <( while IFS= read -r -d '' f; do files+=("$f"); done < <(
@@ -59,7 +54,6 @@ else
rm -f "$out" rm -f "$out"
fi fi
# 2. Codegen freshness
section "codegen freshness" section "codegen freshness"
out="$(mktemp)" out="$(mktemp)"
if scripts/codegen.sh --check >"$out" 2>&1; then if scripts/codegen.sh --check >"$out" 2>&1; then
@@ -71,7 +65,6 @@ else
fi fi
rm -f "$out" rm -f "$out"
# 3. Translation hygiene
section "translation hygiene" section "translation hygiene"
if python3 scripts/clean_translations.py --check --strict; then if python3 scripts/clean_translations.py --check --strict; then
ok "locale files normalized and no unused keys found" ok "locale files normalized and no unused keys found"
@@ -80,7 +73,6 @@ else
FAILED=1 FAILED=1
fi fi
# 4. Hardcoded user-facing strings
section "hardcoded UI strings" section "hardcoded UI strings"
if python3 scripts/check_hardcoded_strings.py; then if python3 scripts/check_hardcoded_strings.py; then
ok "user-facing strings use the translation layer" ok "user-facing strings use the translation layer"
@@ -89,7 +81,6 @@ else
FAILED=1 FAILED=1
fi fi
# 5. Workflow and script regression guards
section "workflow and script guards" section "workflow and script guards"
if bash scripts/ci_guard_checks.sh; then if bash scripts/ci_guard_checks.sh; then
ok "workflow and script guards passed" ok "workflow and script guards passed"
@@ -98,7 +89,6 @@ else
FAILED=1 FAILED=1
fi fi
# 6. Icon consistency
section "icon consistency" section "icon consistency"
if dart run scripts/check_icon_consistency.dart; then if dart run scripts/check_icon_consistency.dart; then
ok "production icons use AppIcon and rounded Symbols" ok "production icons use AppIcon and rounded Symbols"
@@ -107,7 +97,6 @@ else
FAILED=1 FAILED=1
fi fi
# 7. Native formatting
section "native format" section "native format"
out="$(mktemp)" out="$(mktemp)"
if scripts/format_native.sh --check >"$out" 2>&1; then if scripts/format_native.sh --check >"$out" 2>&1; then
@@ -119,7 +108,6 @@ else
fi fi
rm -f "$out" rm -f "$out"
# 8. Dart analyzer (mirrors ci.yml "Analyze code")
section "Dart analyzer" section "Dart analyzer"
if dart run scripts/check_analyzer.dart; then if dart run scripts/check_analyzer.dart; then
ok "no unapproved diagnostics" ok "no unapproved diagnostics"
@@ -128,7 +116,6 @@ else
FAILED=1 FAILED=1
fi fi
# 9. Unused code (mirrors ci.yml "Check for unused code")
section "dart_code_linter: unused code" section "dart_code_linter: unused code"
if ! have_dart_code_linter; then if ! have_dart_code_linter; then
skip "dart_code_linter unresolved — run 'flutter pub get'" skip "dart_code_linter unresolved — run 'flutter pub get'"
@@ -145,7 +132,6 @@ else
rm -f "$out" rm -f "$out"
fi fi
# 10. Unused files (mirrors ci.yml "Check for unused files")
section "dart_code_linter: unused files" section "dart_code_linter: unused files"
if ! have_dart_code_linter; then if ! have_dart_code_linter; then
skip "dart_code_linter unresolved — run 'flutter pub get'" skip "dart_code_linter unresolved — run 'flutter pub get'"
+4 -11
View File
@@ -1,15 +1,8 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Workflow and script regression guards. # 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
# Single source of truth for the guard roster, shared by the "Verify workflow # picked up automatically; checkers requiring Bun or built packages run in
# and script guards" step in .github/workflows/ci.yml and section 4 of # their owning jobs as well.
# 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.
set -euo pipefail set -euo pipefail
shopt -s nullglob shopt -s nullglob
+5 -21
View File
@@ -1,23 +1,18 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Android Icon Generator Script # Generate Android notification and monochrome icons from assets/plezy.svg.
# Generates notification icons and monochrome launcher icons from SVG source
# Usage: ./generate_android_icons.sh
set -e set -e
# Configuration
SVG_SOURCE="assets/plezy.svg" SVG_SOURCE="assets/plezy.svg"
ANDROID_RES="android/app/src/main/res" ANDROID_RES="android/app/src/main/res"
TEMP_DIR="/tmp/android_icons_$$" TEMP_DIR="/tmp/android_icons_$$"
# Check if source SVG exists
if [ ! -f "$SVG_SOURCE" ]; then if [ ! -f "$SVG_SOURCE" ]; then
echo "Error: $SVG_SOURCE not found" echo "Error: $SVG_SOURCE not found"
exit 1 exit 1
fi fi
# Check for required tools
if ! command -v rsvg-convert &> /dev/null; then if ! command -v rsvg-convert &> /dev/null; then
echo "Error: rsvg-convert not found. Install with: brew install librsvg" echo "Error: rsvg-convert not found. Install with: brew install librsvg"
exit 1 exit 1
@@ -37,27 +32,24 @@ if ! command -v bc &> /dev/null; then
exit 1 exit 1
fi fi
# Create temp directory
mkdir -p "$TEMP_DIR" mkdir -p "$TEMP_DIR"
echo "🎨 Generating Android icons from $SVG_SOURCE..." echo "🎨 Generating Android icons from $SVG_SOURCE..."
# Function to generate white silhouette icon
generate_white_icon() { generate_white_icon() {
local size=$1 local size=$1
local output=$2 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_viewbox=$(grep -o 'viewBox="[^"]*"' "$SVG_SOURCE" | sed 's/viewBox="//;s/"//')
local svg_width=$(echo "$svg_viewbox" | awk '{print $3}') local svg_width=$(echo "$svg_viewbox" | awk '{print $3}')
local svg_height=$(echo "$svg_viewbox" | awk '{print $4}') 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 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" 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" \ "$IMAGEMAGICK" "$TEMP_DIR/temp_unpadded.png" \
-resize "${size}x${size}" \ -resize "${size}x${size}" \
-gravity center \ -gravity center \
@@ -65,11 +57,10 @@ generate_white_icon() {
-extent "${size}x${size}" \ -extent "${size}x${size}" \
"$TEMP_DIR/temp.png" "$TEMP_DIR/temp.png"
else else
# Square SVG: convert directly
rsvg-convert -w "$size" -h "$size" --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp.png" rsvg-convert -w "$size" -h "$size" --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp.png"
fi 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" \ "$IMAGEMAGICK" "$TEMP_DIR/temp.png" \
-alpha extract \ -alpha extract \
"$TEMP_DIR/alpha_mask.png" "$TEMP_DIR/alpha_mask.png"
@@ -85,12 +76,9 @@ generate_white_icon() {
echo " ✓ Generated $(basename $output) (${size}x${size}px)" echo " ✓ Generated $(basename $output) (${size}x${size}px)"
} }
# Generate Notification Icons (24dp base)
echo "" echo ""
echo "📱 Generating notification icons (ic_stat_notification.png)..." 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=( declare -A NOTIF_SIZES=(
["mdpi"]=24 ["mdpi"]=24
["hdpi"]=36 ["hdpi"]=36
@@ -106,12 +94,9 @@ for density in "${!NOTIF_SIZES[@]}"; do
generate_white_icon "$size" "$output_dir/ic_stat_notification.png" generate_white_icon "$size" "$output_dir/ic_stat_notification.png"
done done
# Generate Monochrome Launcher Icons (108dp base)
echo "" echo ""
echo "🚀 Generating monochrome launcher icons (ic_launcher_monochrome.png)..." 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=( declare -A MONO_SIZES=(
["mdpi"]=108 ["mdpi"]=108
["hdpi"]=162 ["hdpi"]=162
@@ -127,7 +112,6 @@ for density in "${!MONO_SIZES[@]}"; do
generate_white_icon "$size" "$output_dir/ic_launcher_monochrome.png" generate_white_icon "$size" "$output_dir/ic_launcher_monochrome.png"
done done
# Clean up
rm -rf "$TEMP_DIR" rm -rf "$TEMP_DIR"
echo "" echo ""
+6 -20
View File
@@ -1,9 +1,5 @@
#!/usr/bin/env pwsh #!/usr/bin/env pwsh
# Windows MSIX Asset Generator # Generate committed MSIX assets from assets/plezy.png.
# 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.
$ErrorActionPreference = "Stop" $ErrorActionPreference = "Stop"
@@ -16,9 +12,7 @@ Add-Type -AssemblyName System.Drawing
$Source = Resolve-Path "assets\plezy.png" $Source = Resolve-Path "assets\plezy.png"
$OutputDir = Join-Path $ProjectRoot "windows\msix\assets" $OutputDir = Join-Path $ProjectRoot "windows\msix\assets"
# Sizes and names come from the uap:VisualElements attributes in the manifest # Names and dimensions mirror the manifest's VisualElements.
# 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.
$Targets = @( $Targets = @(
@{ Name = "Square44x44Logo.png"; Width = 44; Height = 44 } @{ Name = "Square44x44Logo.png"; Width = 44; Height = 44 }
@{ Name = "Square150x150Logo.png"; Width = 150; Height = 150 } @{ Name = "Square150x150Logo.png"; Width = 150; Height = 150 }
@@ -26,25 +20,19 @@ $Targets = @(
@{ Name = "Wide310x150Logo.png"; Width = 310; Height = 150 } @{ Name = "Wide310x150Logo.png"; Width = 310; Height = 150 }
@{ Name = "Square310x310Logo.png"; Width = 310; Height = 310 } @{ Name = "Square310x310Logo.png"; Width = 310; Height = 310 }
@{ Name = "SplashScreen.png"; Width = 620; Height = 300 } @{ Name = "SplashScreen.png"; Width = 620; Height = 300 }
# The base logos are scale-100. Without a 200 the shell upscales them on the # Scale-200 variants avoid shell upscaling on high-DPI displays.
# high-DPI displays most laptops ship with.
@{ Name = "Square44x44Logo.scale-200.png"; Width = 88; Height = 88 } @{ Name = "Square44x44Logo.scale-200.png"; Width = 88; Height = 88 }
@{ Name = "Square150x150Logo.scale-200.png"; Width = 300; Height = 300 } @{ 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 # Resources.pri resolves these unplated variants; plain payload files are inert.
# 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.
foreach ($Size in 16, 24, 32, 48, 256) { foreach ($Size in 16, 24, 32, 48, 256) {
foreach ($Form in "", "_altform-unplated", "_altform-lightunplated") { foreach ($Form in "", "_altform-unplated", "_altform-lightunplated") {
$Targets += @{ Name = "Square44x44Logo.targetsize-${Size}${Form}.png"; Width = $Size; Height = $Size } $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 } if (Test-Path $OutputDir) { Remove-Item (Join-Path $OutputDir "*.png") -Force }
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
@@ -58,9 +46,7 @@ try {
try { try {
$Graphics = [System.Drawing.Graphics]::FromImage($Canvas) $Graphics = [System.Drawing.Graphics]::FromImage($Canvas)
try { try {
# SourceCopy keeps the icon's own alpha instead of blending it # Preserve alpha for the manifest's transparent tile background.
# into the canvas; the manifest declares a transparent tile
# background and expects the padding to stay transparent.
$Graphics.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy $Graphics.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy
$Graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic $Graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
$Graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality $Graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
+3 -7
View File
@@ -578,12 +578,8 @@ class MaestroRunner:
check=False, check=False,
quiet=True, quiet=True,
) )
# Maestro waits for the view hierarchy to settle after every tap, input, # Disable animations to avoid Maestro's per-action transition waits;
# and key press. With animations at their default 1.0 scale each of # cleanup() restores the captured settings.
# 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.
for key in ANIMATION_SCALES: for key in ANIMATION_SCALES:
self._adb_run("shell", "settings", "put", "global", key, "0", check=False, quiet=True) 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) self._adb_run("shell", "input", "keyevent", "KEYCODE_BACK", check=False, quiet=True)
@@ -621,7 +617,7 @@ class MaestroRunner:
) as response: ) as response:
output.write(response.read().decode(errors="replace")) output.write(response.read().decode(errors="replace"))
output.write("\n") 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") output.write(f"health_error={error}\n")
if self.container_name: if self.container_name:
+6 -19
View File
@@ -15,9 +15,8 @@ ANDROID_15_INSTRUMENTATION_CLASSES = (
"com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest" "com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest"
) )
ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation" ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation"
# Kept separate from the suites above: only one build type can host androidTest, and # Separate R8 reachability from instrumentation: these suites exercise APIs the app
# those suites drive media3 builder APIs the app itself never calls, which R8 shrinks # does not call directly, so shrinking them is expected. Covers name-based reachability (#1703).
# legitimately. This class asserts only name-based reachability (#1703).
ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest" ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest"
ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability" ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability"
@@ -126,9 +125,7 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
"android-9": ( "android-9": (
( (
"basic", "basic",
# API 28's emulator routing to the 10.0.2.2 host alias is unreliable # API 28 uses adb reverse because 10.0.2.2 routing is unreliable here.
# on this image, so reach Jellyfin over an adb reverse mapping the
# way the media suite already does.
"--adb-reverse", "--adb-reverse",
"--flow", "--flow",
".maestro/flows/05_playback.yaml", ".maestro/flows/05_playback.yaml",
@@ -138,13 +135,7 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
"build/maestro-legacy/diagnostics", "build/maestro-legacy/diagnostics",
), ),
), ),
# Real Android TV hardware only, so no workflow dispatches it. The three # Manual Android TV hardware target; workflow runs phone-emulator TV regressions.
# `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.
"android-tv-device": ( "android-tv-device": (
( (
"basic", "basic",
@@ -201,12 +192,8 @@ def run_android_15_instrumentation() -> None:
def run_android_r8_reachability() -> None: def run_android_r8_reachability() -> None:
print("==> Android R8 reachability", flush=True) print("==> Android R8 reachability", flush=True)
# The `minified` build type runs R8 over the app under test, so a keep rule that stops # Run minified instrumentation to catch reflective, JNI, and native-load keep
# covering a reflective lookup, a JNI callback or a native library load fails here # regressions. Do not exclude the Flutter build; CI only prebuilds debug.
# 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_maestro._run_checked( run_maestro._run_checked(
( (
"android/gradlew", "android/gradlew",

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