chore: clean up code comments
This commit is contained in:
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,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,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,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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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})
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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)
|
||||||
|
|||||||
+1
-12
@@ -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
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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
@@ -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'"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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 ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user