chore: clean up code comments
This commit is contained in:
@@ -271,13 +271,11 @@ jobs:
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Import certificate to keychain
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
|
||||
+1
-3
@@ -18,9 +18,7 @@ migrate_working_dir/
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
# Keep .vscode/ available for contributors' local configuration.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
|
||||
@@ -22,8 +22,8 @@ tags:
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
# The grid row must be matchable before the sort sheet opens, otherwise the
|
||||
# occlusion assertion below would pass for the wrong reason.
|
||||
# Wait for the row before opening the sheet so occlusion is tested against the
|
||||
# grid, not an absent or stale match.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
@@ -31,9 +31,8 @@ tags:
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
# The host renders the grid, the barrier, and the sheet in one Stack and never
|
||||
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
|
||||
# an occluded row still read as visible to accessibility and to Maestro.
|
||||
# The grid remains mounted under the Stack; BlockSemantics is required for
|
||||
# occluded rows to disappear from Maestro's accessibility tree.
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
|
||||
@@ -16,13 +16,9 @@ tags:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Center"
|
||||
# The TV player opens with its chrome down so the OSD and timebar never sit
|
||||
# over the opening seconds of the picture (#1765), so the Pause button can no
|
||||
# longer stand in for "the player has finished loading". Wait for the detail
|
||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
||||
# player route owns the frame, and it only clears once playback reports its
|
||||
# first frame or gives up, so this can never advance before the media opens.
|
||||
# A give-up lands on the transport assertions below, which is where it belongs.
|
||||
# TV playback starts with chrome hidden (#1765), so wait for its loading label
|
||||
# rather than treating the Pause button as readiness. A failed open reaches the
|
||||
# transport assertions below.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 30000
|
||||
@@ -30,18 +26,14 @@ tags:
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s)^(Play|Pause)$"
|
||||
# Hardware transport keys drive playback without raising the chrome (#1676):
|
||||
# the player answers with a centred disc (pause) or a side readout (seek), never the
|
||||
# full chrome, so subtitles stay readable.
|
||||
# Pause first — it also freezes the clip, so the seek assertions below cannot
|
||||
# race the end of a short fixture item.
|
||||
# Transport keys keep chrome hidden (#1676); pause first so seeks cannot race
|
||||
# the end of this short fixture.
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Paused"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
# D-pad seeking reports through the skip badge, never the scrub bar, and
|
||||
# consecutive presses in one direction stack into a running total.
|
||||
# D-pad seeks use the cumulative skip badge, not the scrub bar.
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Seek forward 10 seconds$"
|
||||
@@ -56,7 +48,7 @@ tags:
|
||||
visible: "Playing"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Pause$"
|
||||
# Select stays the deliberate way to bring the chrome back.
|
||||
# Select is the explicit way to restore the chrome.
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(Play|Pause)$"
|
||||
|
||||
@@ -7,10 +7,8 @@ tags:
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A
|
||||
# tap here flips InputModeTracker into pointer mode, which collapses the rail
|
||||
# to icons and leaves no "Search" label to hit — and a percentage coordinate
|
||||
# does not survive the jump from a phone in forced TV mode to a 4K television.
|
||||
# Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label,
|
||||
# while percentage coordinates are not portable across TV sizes.
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search"
|
||||
@@ -38,11 +36,8 @@ tags:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn: "(?s)^Play S1E1$"
|
||||
# The TV player opens with its chrome down (#1765), so the Pause button can no
|
||||
# longer stand in for "the player has finished loading". Wait for the detail
|
||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
||||
# player route owns the frame, and it only clears once playback reports its
|
||||
# first frame or gives up, so this can never advance before the media opens.
|
||||
# The player starts with chrome hidden (#1765); wait for its loading label
|
||||
# rather than using Pause as readiness.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Play S1E1$"
|
||||
timeout: 30000
|
||||
@@ -51,9 +46,7 @@ tags:
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
# Gate on the prompt's own Cancel action, the label the iOS branch below taps.
|
||||
# "Next Episode" is the credits skip button at this point in the episode, so
|
||||
# waiting on it would let Back fire before the prompt ever opened.
|
||||
# Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Cancel$"
|
||||
timeout: 20000
|
||||
|
||||
@@ -8,10 +8,8 @@ tags:
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
# Every seeded alphabet title carries the same dateadded, so which of them the
|
||||
# "Recently Added" rail returns is not stable across scans. This flow only
|
||||
# needs some playable movie, so accept any of them rather than pinning one and
|
||||
# failing whenever the tie-break lands elsewhere.
|
||||
# All seeded titles share dateadded, so accept any playable Recently Added movie
|
||||
# instead of relying on an unstable tie-break.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
timeout: 15000
|
||||
|
||||
@@ -7,20 +7,9 @@ tags:
|
||||
- settings
|
||||
- sheets
|
||||
---
|
||||
# Guards 8e1904dd and c48cbf70 together.
|
||||
#
|
||||
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
|
||||
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
|
||||
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
|
||||
# answered the platform pop with a TV-only dedup marker that could already be
|
||||
# spent, stranding the sheet open with no way out. Either defect fails the
|
||||
# "Manage Libraries is still there after one Back" assertion below.
|
||||
#
|
||||
# The same commit blocks semantics behind the barrier, so the occlusion
|
||||
# assertions pin that an open sheet actually hides the rows underneath.
|
||||
# Pristine state on purpose. The TV regressions in the same CI group leave
|
||||
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
|
||||
# so it must not inherit whichever layout the previous flow left behind.
|
||||
# Guards 8e1904dd and c48cbf70: phone Settings must host the sheet so one Back
|
||||
# dismisses only the sheet, while BlockSemantics hides rows behind the barrier.
|
||||
# Start clean because preceding TV flows may leave Force TV mode enabled.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
@@ -29,10 +18,8 @@ tags:
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The barrier swallows every pointer event, so the rows behind it must leave
|
||||
# the semantics tree too. Without BlockSemantics an occluded row still reads as
|
||||
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
|
||||
# excluded on purpose: it is also the sheet's own title.
|
||||
# The barrier removes covered rows from semantics; the sheet title remains
|
||||
# visible because it belongs to the sheet.
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "(?s)^Appearance.*"
|
||||
@@ -41,15 +28,13 @@ tags:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
# One Back closed only the sheet: Settings is still the current route.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Reopening proves the host did not strand the one-shot dedup marker: a second
|
||||
# dismissal has to work exactly like the first.
|
||||
# Reopening verifies the one-shot pop deduplication marker was not stranded.
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
|
||||
@@ -7,20 +7,10 @@ tags:
|
||||
- playback
|
||||
- subtitles
|
||||
---
|
||||
# Guards 468d6804. When a source advertises tracks the native list has not
|
||||
# produced yet, TrackManager arms an automatic selection pass with a 5s first
|
||||
# attempt and a 25s deadline. A user pick made inside that window used to only
|
||||
# persist the preference, leaving the pass armed, so the choice snapped back to
|
||||
# the server/profile preferred track up to ~30s later. The pick now retires the
|
||||
# pending pass, and the wait below is long enough to outlive both timers.
|
||||
#
|
||||
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
|
||||
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
|
||||
# streams, so both picks below are genuine changes away from the automatic one.
|
||||
# Pristine state on purpose, for two reasons: the TV regressions in the same
|
||||
# CI group leave "Force TV mode" enabled, and a remembered track selection
|
||||
# from an earlier run would pre-select the very rows this flow picks, making
|
||||
# every assertion below vacuous.
|
||||
# Guards 468d6804: a user pick must retire TrackManager's pending automatic
|
||||
# selection pass, or the server preference can overwrite it up to 30s later.
|
||||
# The codec fixture supplies non-default audio and subtitle choices, and clean
|
||||
# state prevents remembered selections from making the assertions vacuous.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
|
||||
@@ -7,16 +7,9 @@ tags:
|
||||
- settings
|
||||
- i18n
|
||||
---
|
||||
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
|
||||
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
|
||||
# locale whose JSON never reached the generator ships a picker entry that falls
|
||||
# back to English. Selecting Turkish and reading root-level navigation labels
|
||||
# proves the generated locale is wired end to end.
|
||||
#
|
||||
# This flow mutates a persisted pref, and the app language decides every
|
||||
# selector after the tap, so it starts from a cleared install rather than
|
||||
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
|
||||
# hunting for English strings in a Turkish tree.
|
||||
# Guards 7677d159 and 100d7729: selecting Turkish and reading root navigation
|
||||
# labels proves the generated locale is wired end to end. Start clean because
|
||||
# the language preference persists and affects every later selector.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
@@ -54,9 +47,7 @@ tags:
|
||||
- assertVisible: "Keşfet"
|
||||
- assertNotVisible: "(?s)^Libraries.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Walk back through the Turkish tree and restore English. The pref outlives a
|
||||
# plain launchApp, so leaving the device in Turkish would break every later
|
||||
# flow in the suite.
|
||||
# Restore English before leaving the device; the locale preference outlives launchApp.
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
|
||||
@@ -7,21 +7,11 @@ tags:
|
||||
- tv
|
||||
- settings
|
||||
---
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
|
||||
# settings navigation generally.
|
||||
#
|
||||
# It deliberately does NOT guard the 15b54e2e density change. That commit took
|
||||
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
|
||||
# the semantics tree either way, so this flow passes identically on both sides
|
||||
# of it — measured, not assumed. A real guard would need a pixel `height`
|
||||
# assertion, and Maestro has no DPR-relative form, so pinning one here would
|
||||
# only hold for the box it was written on. Row geometry belongs to
|
||||
# test/widgets/setting_tile_test.dart.
|
||||
#
|
||||
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
|
||||
# mode on the first touch event and the TV number spinner only builds in
|
||||
# keyboard mode, so a single tapOn anywhere above would silently swap the
|
||||
# spinner for a plain text field.
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e.
|
||||
# This flow intentionally does not pin 15b54e2e's row density: semantics stay
|
||||
# stable across that change, and Maestro has no DPR-relative height assertion.
|
||||
# Use D-pad only; a tap switches InputModeTracker to pointer mode and removes
|
||||
# the TV number spinner.
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach a signed-in Home without paying for onboarding that already happened.
|
||||
#
|
||||
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
|
||||
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
|
||||
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
|
||||
# data and cold-starts straight back to Home, which measures ~16s.
|
||||
#
|
||||
# The guard keeps each flow runnable on its own: when no session is stored --
|
||||
# a fresh install, or a run after the logout flow -- "Discover" is absent and
|
||||
# this falls through to the real onboarding. Flows that must prove onboarding
|
||||
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
|
||||
# Reach a signed-in Home without repeating onboarding. A plain launch preserves
|
||||
# stored state (~16s versus ~59s for retyping credentials); fall back to full
|
||||
# onboarding when no session exists so each flow remains runnable alone.
|
||||
- launchApp
|
||||
# Settle on a known screen first. Branching straight off `launchApp` races the
|
||||
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
|
||||
# every flow pays for a full re-onboarding it did not need.
|
||||
# Wait for the splash to resolve before deciding whether onboarding is needed.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
|
||||
@@ -10,10 +10,8 @@ appId: com.edde746.plezy
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
# A miss on `tapOn ... optional: true` still runs Maestro's full element
|
||||
# search before giving up, which measured 3s here and 7.8s for "Sign in"
|
||||
# on every onboarding. These taps normally find nothing, so gate them on
|
||||
# a single visibility check instead.
|
||||
# Optional taps still pay Maestro's full search cost when they miss; guard them
|
||||
# with one visibility check instead.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Onboarding for a device that already reports itself as a TV.
|
||||
#
|
||||
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
|
||||
# onboards through the handheld layout and then walks Settings to flip
|
||||
# "Force TV mode". A real Android TV box takes the TV layout from the first
|
||||
# frame, so that walk has no handheld chrome to tap and the toggle would only
|
||||
# re-assert what PlatformDetector already reports.
|
||||
# Onboard a device that reports itself as a TV; unlike the phone flow, it starts
|
||||
# in TV layout and needs no Force TV mode walk.
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
@@ -32,9 +27,8 @@ appId: com.edde746.plezy
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
# A TV-sized layout offers Quick Connect first because typing on a remote is
|
||||
# slow. The code path needs a second device to approve, so fall back to the
|
||||
# username form the phone flow already uses.
|
||||
# Quick Connect is awkward on a remote and needs another device, so use the
|
||||
# username form as fallback.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Username|Cancel)$"
|
||||
timeout: 30000
|
||||
|
||||
@@ -17,10 +17,8 @@ appId: com.edde746.plezy
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
# A card announces its watch state three ways, and the codec samples keep a
|
||||
# resume position on the server once any earlier flow has played them, so the
|
||||
# partial form has to be accepted for this subflow to be re-runnable against a
|
||||
# fixture container that is not thrown away between suites.
|
||||
# Codec cards retain watch state and resume position between flows, so accept
|
||||
# any valid watch-state label to keep this subflow rerunnable.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
timeout: 15000
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach Settings from a signed-in Home on a phone layout.
|
||||
#
|
||||
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
|
||||
# issued while the previous route is still settling opens nothing. The guarded
|
||||
# repeat re-opens the menu instead of failing the flow, matching the pattern
|
||||
# already used by flows/07_profiles_settings.yaml.
|
||||
# Reach phone Settings from signed-in Home. The profile menu is an overlay
|
||||
# sheet; retry while the previous route settles.
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
|
||||
@@ -15,8 +15,7 @@ linter:
|
||||
prefer_final_in_for_each: true
|
||||
avoid_print: true
|
||||
|
||||
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
|
||||
# on Linux. CI invokes the supported check-unused commands explicitly.
|
||||
# DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly.
|
||||
dart_code_linter:
|
||||
rules-exclude:
|
||||
- "test/**"
|
||||
@@ -27,7 +26,7 @@ dart_code_linter:
|
||||
- package:dart_code_linter/presets/recommended.yaml
|
||||
|
||||
rules:
|
||||
# --- Flutter rules (on top of recommended) ---
|
||||
# Flutter-specific rules.
|
||||
- avoid-border-all
|
||||
- avoid-shrink-wrap-in-lists
|
||||
- avoid-expanded-as-spacer
|
||||
@@ -37,7 +36,7 @@ dart_code_linter:
|
||||
- prefer-define-hero-tag
|
||||
- use-setstate-synchronously
|
||||
|
||||
# --- Additional useful Dart rules ---
|
||||
# Additional Dart rules.
|
||||
- avoid-cascade-after-if-null
|
||||
- avoid-collection-methods-with-unrelated-types
|
||||
- avoid-unnecessary-type-assertions
|
||||
@@ -48,7 +47,7 @@ dart_code_linter:
|
||||
- prefer-enums-by-name
|
||||
- prefer-commenting-analyzer-ignores
|
||||
|
||||
# --- Disable noisy rules from recommended preset ---
|
||||
# Disabled noisy rules.
|
||||
- no-magic-number: false
|
||||
- avoid-dynamic: false
|
||||
- format-comment: false
|
||||
|
||||
@@ -336,8 +336,6 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
|
||||
@@ -9,17 +9,14 @@
|
||||
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
||||
|
||||
<!-- Internet access permissions -->
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<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_MEDIA_PLAYBACK"/>
|
||||
<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.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||
|
||||
@@ -30,15 +27,11 @@
|
||||
<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" />
|
||||
<!-- Android TV Watch Next integration -->
|
||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
<!-- Touchscreen not required for TV -->
|
||||
<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" />
|
||||
|
||||
<application
|
||||
@@ -63,10 +56,6 @@
|
||||
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||
android:hardwareAccelerated="true"
|
||||
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
|
||||
android:name="io.flutter.embedding.android.NormalTheme"
|
||||
android:resource="@style/NormalTheme"
|
||||
@@ -77,7 +66,6 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN"/>
|
||||
<category android:name="android.intent.category.LAUNCHER"/>
|
||||
<!-- Allow app to appear in Android TV launcher -->
|
||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||
</intent-filter>
|
||||
<!-- Deep link handler for Watch Next items -->
|
||||
@@ -88,7 +76,6 @@
|
||||
<data android:scheme="plezy" android:host="play"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<!-- FileProvider for sharing downloaded files with external players -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="com.edde746.plezy.fileprovider"
|
||||
|
||||
@@ -119,7 +119,6 @@ class MainActivity : FlutterActivity() {
|
||||
}
|
||||
}
|
||||
|
||||
// Auto PiP state
|
||||
private var autoPipReady = false
|
||||
private var autoPipWidth: Int = 16
|
||||
private var autoPipHeight: Int = 9
|
||||
|
||||
@@ -106,7 +106,6 @@ internal class TrueHdCarrierSink(
|
||||
@Volatile
|
||||
private var mismatchGeneration = -1
|
||||
|
||||
// --- Selection ---
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
|
||||
|
||||
@@ -96,7 +96,6 @@ class MpvPlayerCore private constructor(
|
||||
// output (#1482).
|
||||
private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
|
||||
// Frame rate matching
|
||||
private var frameRateManager: FrameRateManager? = null
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
|
||||
@@ -110,7 +109,6 @@ class MpvPlayerCore private constructor(
|
||||
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
||||
}
|
||||
|
||||
// Audio focus
|
||||
private var audioFocusManager: AudioFocusManager? = null
|
||||
|
||||
@Volatile private var cachedPaused: Boolean = true
|
||||
@@ -275,7 +273,6 @@ class MpvPlayerCore private constructor(
|
||||
Log.d(TAG, "SurfaceView added to content view")
|
||||
}
|
||||
|
||||
// Create MpvPlayer on background thread via coroutine
|
||||
scope.launch {
|
||||
try {
|
||||
if (disposing) {
|
||||
|
||||
@@ -74,7 +74,6 @@ open class MpvPlayerPlugin(
|
||||
private var initAttemptCounter = 0
|
||||
private var activeInitAttempt: Int? = null
|
||||
|
||||
// FlutterPlugin
|
||||
|
||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||
applicationContext = binding.applicationContext
|
||||
@@ -104,7 +103,6 @@ open class MpvPlayerPlugin(
|
||||
takeCoreForTeardown()?.dispose()
|
||||
}
|
||||
|
||||
// ActivityAware
|
||||
|
||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||
activity = binding.activity
|
||||
@@ -141,7 +139,6 @@ open class MpvPlayerPlugin(
|
||||
Log.d(tag, "Detached from activity for config changes")
|
||||
}
|
||||
|
||||
// EventChannel.StreamHandler
|
||||
|
||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||
channels.listen(events)
|
||||
@@ -151,7 +148,6 @@ open class MpvPlayerPlugin(
|
||||
channels.cancel()
|
||||
}
|
||||
|
||||
// MethodChannel.MethodCallHandler
|
||||
|
||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||
when (call.method) {
|
||||
|
||||
@@ -28,7 +28,6 @@ class BufferingStallPolicyTest {
|
||||
loading = loading
|
||||
)
|
||||
|
||||
// The loader's own state, for the case media3 never answers
|
||||
|
||||
@Test
|
||||
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
|
||||
@@ -51,7 +50,6 @@ class BufferingStallPolicyTest {
|
||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
|
||||
}
|
||||
|
||||
// The load control's own verdict
|
||||
|
||||
@Test
|
||||
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
|
||||
@@ -83,7 +81,6 @@ class BufferingStallPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
// Playback speed
|
||||
|
||||
@Test
|
||||
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
|
||||
@@ -107,7 +104,6 @@ class BufferingStallPolicyTest {
|
||||
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
|
||||
}
|
||||
|
||||
// Progress
|
||||
|
||||
@Test
|
||||
fun advancingPositionIsHealthy() {
|
||||
@@ -138,7 +134,6 @@ class BufferingStallPolicyTest {
|
||||
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
|
||||
}
|
||||
|
||||
// Timeout
|
||||
|
||||
@Test
|
||||
fun frozenPositionWaitsOutTheTimeout() {
|
||||
@@ -151,7 +146,6 @@ class BufferingStallPolicyTest {
|
||||
assertEquals(Verdict.STALLED, evaluate())
|
||||
}
|
||||
|
||||
// Starvation — the loader's problem, not the renderer's
|
||||
|
||||
@Test
|
||||
fun emptyBufferIsStarved() {
|
||||
@@ -203,7 +197,6 @@ class BufferingStallPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
// Stall clock ownership
|
||||
|
||||
@Test
|
||||
fun starvationAndProgressBothRestartTheStallClock() {
|
||||
|
||||
@@ -10,7 +10,6 @@ class DvBitstreamSanitizerTest {
|
||||
|
||||
private val sanitizer = DvBitstreamSanitizer()
|
||||
|
||||
// --- HDR10+ SEI stripping (native DV codec path) ---
|
||||
|
||||
@Test
|
||||
fun stripsHdr10PlusPrefixSeiBetweenVclNals() {
|
||||
@@ -85,7 +84,6 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(original, remainingBytes(buffer))
|
||||
}
|
||||
|
||||
// --- DV RPU/EL stripping (HEVC fallback path) ---
|
||||
|
||||
@Test
|
||||
fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() {
|
||||
@@ -111,7 +109,6 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||
}
|
||||
|
||||
// --- Buffer handling ---
|
||||
|
||||
@Test
|
||||
fun respectsPositionAndRestoresIt() {
|
||||
@@ -245,7 +242,6 @@ class DvBitstreamSanitizerTest {
|
||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
/** 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 {
|
||||
|
||||
@@ -24,7 +24,6 @@ class EndOfStreamPolicyTest {
|
||||
frameStallMs = frameStallMs
|
||||
)
|
||||
|
||||
// isFinishedFile
|
||||
|
||||
@Test
|
||||
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
|
||||
@@ -65,7 +64,6 @@ class EndOfStreamPolicyTest {
|
||||
assertFalse(isFinishedFile(hasPlaybackOutput = false))
|
||||
}
|
||||
|
||||
// fallbackStartPositionMs
|
||||
|
||||
@Test
|
||||
fun fallbackResumesStrictlyInsideTheMedia() {
|
||||
|
||||
@@ -9,7 +9,6 @@ private const val MIB = 1024 * 1024
|
||||
|
||||
class LoadControlPolicyTest {
|
||||
|
||||
// autoTargetBufferBytes
|
||||
|
||||
@Test
|
||||
fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() {
|
||||
@@ -64,7 +63,6 @@ class LoadControlPolicyTest {
|
||||
)
|
||||
}
|
||||
|
||||
// readAheadSeconds
|
||||
|
||||
@Test
|
||||
fun readAheadReportsSecondsAtAKnownBitrate() {
|
||||
@@ -86,7 +84,6 @@ class LoadControlPolicyTest {
|
||||
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
|
||||
}
|
||||
|
||||
// bufferDurations
|
||||
|
||||
/**
|
||||
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
|
||||
@@ -172,7 +169,6 @@ class LoadControlPolicyTest {
|
||||
}
|
||||
}
|
||||
|
||||
// BufferTier.fromWire
|
||||
|
||||
@Test
|
||||
fun theWireNamesMatchTheDartNativeValues() {
|
||||
|
||||
@@ -52,7 +52,6 @@ class RawPositionAudioOutputTest {
|
||||
return output to listener
|
||||
}
|
||||
|
||||
// Release reporting
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
// Reuse
|
||||
|
||||
@Test
|
||||
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
|
||||
@@ -178,7 +176,6 @@ class RawPositionAudioOutputTest {
|
||||
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
|
||||
@@ -221,7 +218,6 @@ class RawPositionAudioOutputTest {
|
||||
assertEquals(1, listener.releasedCount)
|
||||
}
|
||||
|
||||
// Fakes
|
||||
|
||||
private class RecordingListener : AudioOutput.Listener {
|
||||
var releasedCount = 0
|
||||
|
||||
@@ -7,7 +7,6 @@ import org.junit.Test
|
||||
|
||||
class ResumeStallPolicyTest {
|
||||
|
||||
// checkWindowMs
|
||||
|
||||
@Test
|
||||
fun windowFloorsAtDefaultForNormalFrameRates() {
|
||||
@@ -39,7 +38,6 @@ class ResumeStallPolicyTest {
|
||||
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
|
||||
}
|
||||
|
||||
// evaluate
|
||||
|
||||
@Test
|
||||
fun advancingFramesAreHealthy() {
|
||||
|
||||
@@ -51,7 +51,6 @@ class TrueHdCarrierSinkTest {
|
||||
blocked: Boolean = false
|
||||
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
|
||||
|
||||
// --- Selection ---
|
||||
|
||||
/**
|
||||
* 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)
|
||||
}
|
||||
|
||||
// --- Rate-family mismatch discovered mid-stream ---
|
||||
|
||||
/**
|
||||
* 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))
|
||||
}
|
||||
|
||||
// --- Back pressure ---
|
||||
|
||||
/**
|
||||
* 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())
|
||||
}
|
||||
|
||||
// --- Routing of controls ---
|
||||
|
||||
@Test
|
||||
fun persistentControlsReachBothDelegates() {
|
||||
|
||||
@@ -11,7 +11,6 @@ default_platform(:android)
|
||||
platform :android do
|
||||
desc "Build and deploy to Google Play Store"
|
||||
lane :release do
|
||||
# Get version from pubspec.yaml
|
||||
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
|
||||
pubspec_content = File.read(pubspec_path)
|
||||
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
|
||||
|
||||
# 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} && 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"
|
||||
)
|
||||
|
||||
# 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} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?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/">
|
||||
<metadata id="metadata3396">
|
||||
<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"?>
|
||||
<!-- 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/">
|
||||
<metadata id="metadata3886">
|
||||
<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'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
|
||||
@@ -14,9 +14,7 @@ import MediaPlayer
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
// Configure audio session for media playback. Not activated here: the
|
||||
// session is non-mixing, so activation stops other apps' audio — it is
|
||||
// claimed when playback actually starts.
|
||||
// Configure the non-mixing session; activate it only when playback starts.
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
@@ -32,12 +30,10 @@ import MediaPlayer
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
|
||||
// Register MPV player plugin
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") {
|
||||
MpvPlayerPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
// Register the audio-only MPV player plugin (music playback)
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") {
|
||||
MpvAudioPlayerPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
@@ -39,19 +39,13 @@ import UIKit
|
||||
|
||||
/// Delegate to notify the plugin of PiP lifecycle events
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
/// Called when PiP is about to start (system or app-initiated)
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
/// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI).
|
||||
func pipDidStop(restored: Bool)
|
||||
func pipDidFailToStart(error: Error?)
|
||||
/// Forward play/pause commands from PiP overlay to mpv
|
||||
func pipSetPlaying(_ playing: Bool)
|
||||
/// Forward skip forward/backward commands from PiP overlay to mpv
|
||||
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
|
||||
/// Query whether mpv is currently playing
|
||||
var isPipPlaying: Bool { get }
|
||||
/// Get total duration in seconds
|
||||
var pipDuration: Double { get }
|
||||
}
|
||||
protocol MpvPictureInPictureControlling: AnyObject {
|
||||
@@ -151,7 +145,6 @@ import UIKit
|
||||
createPipController()
|
||||
}
|
||||
|
||||
/// Helper that conforms to the iOS 15+ delegate protocols
|
||||
private var delegateHelper: AnyObject?
|
||||
|
||||
private func createPipController() {
|
||||
|
||||
@@ -12,12 +12,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
@@ -207,7 +205,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
return
|
||||
}
|
||||
pip.setAutoStart(true)
|
||||
// Warm the layer so the system considers PiP possible
|
||||
if let pc = self.playerCore {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
@@ -287,7 +284,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified cleanup for all PiP exit paths
|
||||
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
||||
playerCore?.setPipSubtitleCompositing(false)
|
||||
playerCore?.isPipStarting = false
|
||||
|
||||
@@ -13,13 +13,10 @@ platform :ios do
|
||||
lane :deploy_appstore do
|
||||
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
||||
|
||||
# Build the Flutter app
|
||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
|
||||
|
||||
# Upload debug symbols
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
|
||||
|
||||
# Upload to App Store
|
||||
upload_to_app_store(
|
||||
ipa: "../build/ios/ipa/Plezy.ipa",
|
||||
force: true,
|
||||
|
||||
@@ -119,7 +119,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
// migrations while failures are still covered by this close/rethrow
|
||||
// boundary and the caller's startup download-recovery decision.
|
||||
await database.customSelect('SELECT 1').get();
|
||||
// It deliberately does not claim capacity for a later write.
|
||||
}
|
||||
final outcome = await _tvosRecoveryQueue.run(
|
||||
() => store.reconcile(
|
||||
|
||||
@@ -74,7 +74,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
||||
final double viewportHeight = scrollController.position.viewportDimension;
|
||||
final double viewportBottom = viewportTop + viewportHeight;
|
||||
|
||||
// Already fully visible — skip
|
||||
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
||||
|
||||
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
||||
@@ -104,7 +103,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
if (movingIndex != null) {
|
||||
// Cancel move - restore original position
|
||||
setState(() {
|
||||
final originalOrder = _originalOrder;
|
||||
if (originalOrder != null) {
|
||||
|
||||
@@ -42,7 +42,6 @@ class FocusMemoryTracker {
|
||||
/// Restore focus to the last focused item, or fallback if provided
|
||||
/// Returns true if focus was successfully restored
|
||||
bool restoreFocus({String? fallbackKey}) {
|
||||
// Try to restore last focused item
|
||||
if (_lastFocusedKey != null) {
|
||||
final node = _nodes[_lastFocusedKey];
|
||||
if (node != null) {
|
||||
@@ -50,7 +49,6 @@ class FocusMemoryTracker {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Fallback: focus the provided key if available
|
||||
if (fallbackKey != null) {
|
||||
final node = _nodes[fallbackKey];
|
||||
if (node != null) {
|
||||
@@ -69,7 +67,6 @@ class FocusMemoryTracker {
|
||||
_nodes.remove(key);
|
||||
_focused.remove(key);
|
||||
}
|
||||
// Clear last focused if it was pruned
|
||||
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
|
||||
_lastFocusedKey = null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ class _FocusableButtonState extends State<FocusableButton> {
|
||||
final showFocus = _isFocused && isKeyboard;
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
final enabled = widget.onPressed != null;
|
||||
// In dpad mode: focused = full opacity, unfocused = dimmed
|
||||
final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0;
|
||||
|
||||
return FocusableWrapper(
|
||||
|
||||
@@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox {
|
||||
|
||||
/// A wrapper widget that makes its child focusable with D-pad navigation support.
|
||||
///
|
||||
/// Provides:
|
||||
/// - Visual focus indicator (border + scale animation)
|
||||
/// - Keyboard/D-pad event handling (Enter/Select to activate)
|
||||
/// - Optional auto-scroll to keep focused item visible
|
||||
/// - Long-press detection for SELECT key
|
||||
/// - Navigation callbacks (UP, BACK)
|
||||
class FocusableWrapper extends StatefulWidget {
|
||||
/// The child widget to wrap.
|
||||
final Widget child;
|
||||
@@ -287,12 +281,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
void didUpdateWidget(FocusableWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Handle focusNode changes
|
||||
if (widget.focusNode != oldWidget.focusNode) {
|
||||
_bindFocusNode();
|
||||
}
|
||||
|
||||
// Update canRequestFocus
|
||||
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
|
||||
_focusNode.canRequestFocus = widget.canRequestFocus;
|
||||
}
|
||||
@@ -368,7 +360,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
final viewport = scrollable.context.findRenderObject() as RenderBox?;
|
||||
if (viewport == null) return;
|
||||
|
||||
// Get item's position relative to viewport
|
||||
final itemBox = renderObject as RenderBox;
|
||||
final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport);
|
||||
|
||||
@@ -376,17 +367,14 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
final itemHeight = itemBox.size.height;
|
||||
final itemVerticalCenter = itemPosition.dy + itemHeight / 2;
|
||||
|
||||
// Account for focus decoration when checking item visibility
|
||||
final itemTop = itemPosition.dy - _focusDecorationPadding;
|
||||
final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding;
|
||||
|
||||
if (widget.useComfortableZone) {
|
||||
// Define comfortable zone - if item (including focus decoration) is within middle 60% of viewport, don't scroll
|
||||
final comfortZoneTop = viewportHeight * 0.2;
|
||||
final comfortZoneBottom = viewportHeight * 0.8;
|
||||
|
||||
if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) {
|
||||
// Item is in comfortable zone, no need to scroll
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -394,22 +382,17 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
// close to target position (prevents jitter when navigating horizontally)
|
||||
final targetY = viewportHeight * widget.scrollAlignment;
|
||||
final distance = (itemVerticalCenter - targetY).abs();
|
||||
// Skip scroll if within half the item height of target
|
||||
if (distance < itemHeight / 2) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate target scroll offset for the immediate scrollable only.
|
||||
// This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables,
|
||||
// which can cause issues with nested scroll views (e.g., chips bar scrolling
|
||||
// out of view when focusing grid items in library browse tab).
|
||||
// Avoid Scrollable.ensureVisible, which scrolls all ancestor scrollables and
|
||||
// can move nested views (e.g. the chips bar) out of view when focusing grid items.
|
||||
final position = scrollable.position;
|
||||
final currentOffset = position.pixels;
|
||||
|
||||
// Target: item center should be at scrollAlignment of viewport
|
||||
// Add padding to ensure focus decoration is fully visible
|
||||
final targetViewportY = viewportHeight * widget.scrollAlignment;
|
||||
|
||||
var scrollDelta = itemVerticalCenter - targetViewportY;
|
||||
|
||||
// If item would be near the top edge, add extra scroll to show focus decoration
|
||||
@@ -471,7 +454,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
}
|
||||
}
|
||||
|
||||
// Handle SELECT key with optional long-press detection
|
||||
if (key.isSelectKey) {
|
||||
if (widget.enableLongPress) {
|
||||
final result = _selectLongPress.handleKeyEvent(
|
||||
@@ -547,7 +529,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
} else {
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
final controller = _ensureAnimationController();
|
||||
// Update animation duration if theme changes
|
||||
if (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
|
||||
// the live registration cleared.
|
||||
InputModeTracker._instance = this;
|
||||
// Initialize focus highlight strategy based on starting mode
|
||||
_updateFocusHighlightStrategy(_mode);
|
||||
// Listen to hardware keyboard events globally
|
||||
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,13 +114,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
DownloadProvider({required this._downloadManager, required this._database})
|
||||
: _syncRuleExecutor = SyncRuleExecutor(database: _database) {
|
||||
_metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged);
|
||||
// Listen to progress updates from the download manager
|
||||
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
||||
|
||||
// Listen to deletion progress updates
|
||||
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
||||
|
||||
// Load persisted downloads from database
|
||||
_initFuture = _loadPersistedDownloads();
|
||||
|
||||
// Lets the diagnostics service score whether downloads actually advance
|
||||
@@ -400,7 +397,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// Initialize artwork directory path for synchronous access
|
||||
await storageService.getArtworkDirectory();
|
||||
|
||||
// Load all downloads from database
|
||||
final downloads = await _downloadManager.getAllDownloads();
|
||||
|
||||
// Bulk-load all pinned metadata across every backend in a single pass
|
||||
@@ -427,7 +423,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
|
||||
// Load sync rules from database
|
||||
await _loadSyncRules();
|
||||
|
||||
// Apply queued offline watch actions on top of the server-time metadata
|
||||
@@ -844,7 +839,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate aggregate statistics
|
||||
int completedCount = 0;
|
||||
int downloadingCount = 0;
|
||||
int queuedCount = 0;
|
||||
@@ -867,7 +861,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
final DownloadStatus overallStatus;
|
||||
if (completedCount == totalEpisodes) {
|
||||
overallStatus = DownloadStatus.completed;
|
||||
@@ -912,22 +905,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// For shows/seasons, returns aggregate progress of all child episodes
|
||||
/// For episodes/movies, returns direct progress
|
||||
DownloadProgress? getProgress(String globalKey) {
|
||||
// First check if we have direct progress (for episodes/movies)
|
||||
final directProgress = _downloads[globalKey];
|
||||
if (directProgress != null) {
|
||||
if (!_ownsDownloadKey(globalKey)) return null;
|
||||
return directProgress;
|
||||
}
|
||||
|
||||
// If no direct progress, check if this is a show or season
|
||||
// and calculate aggregate progress from episodes
|
||||
final parsed = parseGlobalKey(globalKey);
|
||||
if (parsed == null) return null;
|
||||
|
||||
final serverId = parsed.serverId;
|
||||
final ratingKey = parsed.ratingKey;
|
||||
|
||||
// Try to get metadata to determine type
|
||||
final meta = _metadata[globalKey];
|
||||
if (meta == null) {
|
||||
// No metadata stored yet, might be a container (show/season/artist/
|
||||
@@ -1294,11 +1283,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
await _claimDownloadForProfile(globalKey, ownership, client);
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||
|
||||
// Update local state immediately for UI feedback
|
||||
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
||||
safeNotifyListeners();
|
||||
|
||||
// Actually trigger download via DownloadManagerService
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
|
||||
return true;
|
||||
|
||||
@@ -303,10 +303,8 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
return libraries;
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
|
||||
|
||||
// Build ordered list based on saved order
|
||||
final orderedLibraries = <MediaLibrary>[];
|
||||
for (final key in savedOrder) {
|
||||
final lib = libraryMap.remove(key);
|
||||
@@ -315,7 +313,6 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new libraries that weren't in the saved order
|
||||
orderedLibraries.addAll(libraryMap.values);
|
||||
|
||||
return orderedLibraries;
|
||||
|
||||
@@ -175,7 +175,6 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
// Check initial connectivity
|
||||
await _updateConnectionFlags();
|
||||
|
||||
// Monitor connectivity changes — runZonedGuarded catches async errors from
|
||||
|
||||
@@ -26,7 +26,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
final DownloadProvider _downloadProvider;
|
||||
|
||||
OfflineWatchProvider({required this._syncService, required this._downloadProvider}) {
|
||||
// Listen to sync service changes to update UI
|
||||
_syncService.addListener(_onSyncServiceChanged);
|
||||
}
|
||||
|
||||
@@ -49,13 +48,11 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
///
|
||||
/// Returns true if watched, false otherwise.
|
||||
Future<bool> isWatched(String globalKey) async {
|
||||
// First check local offline action
|
||||
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
||||
if (localStatus != null) {
|
||||
return localStatus;
|
||||
}
|
||||
|
||||
// Fall back to cached metadata
|
||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||
if (metadata != null) {
|
||||
return metadata.isWatched;
|
||||
@@ -73,7 +70,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
/// Returns null if no position is available.
|
||||
@visibleForTesting
|
||||
Future<int?> getViewOffset(String globalKey) async {
|
||||
// First check local offline progress
|
||||
final localOffset = await _syncService.getLocalViewOffset(globalKey);
|
||||
if (localOffset != null) {
|
||||
return localOffset;
|
||||
@@ -82,7 +78,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
||||
if (localStatus == true) return null;
|
||||
|
||||
// Fall back to cached metadata
|
||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||
return metadata?.viewOffsetMs;
|
||||
}
|
||||
@@ -127,7 +122,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
|
||||
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
||||
|
||||
// Find first unwatched episode
|
||||
for (final episode in episodes) {
|
||||
if (!watchStatuses[episode.globalKey]!) {
|
||||
return episode;
|
||||
|
||||
@@ -103,18 +103,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final TvSpotlightController _spotlight = TvSpotlightController();
|
||||
bool _isTabVisible = true;
|
||||
|
||||
// Track initial load so we can focus hero when content first appears
|
||||
bool _initialLoadComplete = false;
|
||||
bool _pendingTvBrowseRailFocus = false;
|
||||
|
||||
// Hub navigation keys
|
||||
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
||||
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
|
||||
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
final _hubFocusMemory = HubFocusMemory();
|
||||
|
||||
// Hero and app bar focus
|
||||
late FocusNode _heroFocusNode;
|
||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||
final _serverActivitiesButtonKey = GlobalKey<ServerActivitiesButtonState>();
|
||||
@@ -155,7 +152,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
||||
}
|
||||
|
||||
/// Get all hub states (continue watching + other hubs)
|
||||
List<GlobalKey<HubSectionState>> get _allHubKeys {
|
||||
final keys = <GlobalKey<HubSectionState>>[];
|
||||
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
|
||||
@@ -935,7 +931,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_isLoading) LoadingIndicatorBox.sliver,
|
||||
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
||||
if (!_isLoading && _errorMessage == null) ...[
|
||||
// On Deck / Continue Watching
|
||||
if (continueWatchingHub != null)
|
||||
SliverToBoxAdapter(
|
||||
child: HubSection(
|
||||
@@ -1259,14 +1254,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
|
||||
);
|
||||
|
||||
// Determine content type label for chip
|
||||
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
|
||||
|
||||
// Spoiler protection
|
||||
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
|
||||
final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler;
|
||||
|
||||
// Build semantic label for hero item
|
||||
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
|
||||
|
||||
return Semantics(
|
||||
@@ -1420,10 +1412,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
],
|
||||
|
||||
// On small screens: show button before summary
|
||||
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
|
||||
// Summary with episode info (Apple TV style)
|
||||
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
||||
const SizedBox(height: 12),
|
||||
RichText(
|
||||
@@ -1468,7 +1458,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
],
|
||||
|
||||
// On large screens: show button after summary
|
||||
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -42,7 +42,6 @@ class DownloadsScreen extends StatefulWidget {
|
||||
|
||||
class DownloadsScreenState extends State<DownloadsScreen>
|
||||
with TickerProviderStateMixin, TabNavigationMixin, FocusableTab {
|
||||
// Focus nodes for tab chips
|
||||
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
|
||||
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
|
||||
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
|
||||
@@ -60,7 +59,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
suppressAutoFocus = true; // Start suppressed
|
||||
initTabNavigation();
|
||||
}
|
||||
|
||||
@@ -92,7 +90,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
|
||||
/// Focus the first item in the currently active tab
|
||||
void _focusCurrentTab() {
|
||||
// Re-enable auto-focus since user is navigating into tab content
|
||||
setState(() {
|
||||
suppressAutoFocus = false;
|
||||
});
|
||||
@@ -115,7 +112,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
|
||||
/// Build the app bar title - either tabs on desktop or simple title on mobile
|
||||
Widget _buildAppBarTitle() {
|
||||
// On desktop/TV with side nav, show tabs in app bar
|
||||
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
||||
return TabChipStrip(
|
||||
children: [
|
||||
@@ -130,7 +126,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// On mobile, show simple title
|
||||
return Text(t.downloads.title);
|
||||
}
|
||||
|
||||
@@ -177,7 +172,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
builder: (context, hasPendingDownloads, _) =>
|
||||
BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads),
|
||||
),
|
||||
// Tab selector chips (only on mobile - desktop has them in app bar)
|
||||
if (!PlatformDetector.shouldUseSideNavigation(context))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -197,7 +191,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tab content
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
|
||||
@@ -202,7 +202,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
setState(() {
|
||||
_filteredItems = List.from(_items);
|
||||
|
||||
// Apply sorting
|
||||
if (_selectedSort != null) {
|
||||
final sortKey = _selectedSort!.key;
|
||||
_filteredItems.sort((a, b) {
|
||||
@@ -540,21 +539,16 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||
|
||||
// Determine hub content type for layout decisions
|
||||
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
|
||||
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
|
||||
|
||||
// Mixed hub = has both episodes AND non-episodes
|
||||
final isMixedHub = hasEpisodes && hasNonEpisodes;
|
||||
|
||||
// Episode-only = all items are episodes with thumbnails
|
||||
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
|
||||
|
||||
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
|
||||
final useWideLayout =
|
||||
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
|
||||
|
||||
// Music hubs render square album/artist artwork
|
||||
final isSquareHub =
|
||||
_filteredItems.isNotEmpty &&
|
||||
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
|
||||
|
||||
@@ -29,7 +29,6 @@ class AlphaJumpHelper {
|
||||
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
|
||||
|
||||
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
|
||||
// Collect characters with their sizes.
|
||||
final entries = <({String letter, int size})>[];
|
||||
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));
|
||||
if (descending) {
|
||||
entries.setAll(0, entries.reversed.toList());
|
||||
}
|
||||
|
||||
// Build cumulative index map in the corrected order.
|
||||
final letters = <String>[];
|
||||
final letterToIndex = <String, int>{};
|
||||
int cumulative = 0;
|
||||
|
||||
@@ -176,12 +176,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Loading state (only show loading indicator if items list is empty)
|
||||
if (isLoading && items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// Error state (only show error if items list is empty)
|
||||
if (errorMessage != null && items.isEmpty) {
|
||||
return ErrorStateWidget(
|
||||
message: errorMessage!,
|
||||
@@ -191,12 +189,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (items.isEmpty) {
|
||||
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
|
||||
}
|
||||
|
||||
// Content state - delegate to builder
|
||||
return builder(items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
ItemUpdatable,
|
||||
TickerProviderStateMixin,
|
||||
TabNavigationMixin {
|
||||
// GlobalKeys for tabs to enable refresh
|
||||
final _recommendedTabKey = GlobalKey();
|
||||
final _browseTabKey = GlobalKey();
|
||||
final _collectionsTabKey = GlobalKey();
|
||||
@@ -84,7 +83,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
/// Key for the library dropdown menu button.
|
||||
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
|
||||
|
||||
// Dynamic visible tabs and their focus nodes
|
||||
List<LibraryTabType> _visibleTabs = LibraryTabType.values;
|
||||
List<FocusNode> _tabFocusNodes = List.generate(
|
||||
LibraryTabType.values.length,
|
||||
@@ -94,10 +92,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
@override
|
||||
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
|
||||
|
||||
// App bar action bar
|
||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||
|
||||
// Scroll controller for the outer CustomScrollView
|
||||
final ScrollController _outerScrollController = ScrollController();
|
||||
|
||||
/// Reveal the floating header by jumping the outer NestedScrollView back
|
||||
@@ -153,25 +149,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute visible libraries for initial load
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
||||
|
||||
// Load saved preferences
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedLibraryKey = storage.getSelectedLibraryKey();
|
||||
|
||||
// Find the library by key in visible libraries
|
||||
String? libraryGlobalKeyToLoad;
|
||||
if (savedLibraryKey != null) {
|
||||
// Check if saved library exists and is visible
|
||||
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
|
||||
if (libraryExists) {
|
||||
libraryGlobalKeyToLoad = savedLibraryKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first visible library if saved key not found
|
||||
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
|
||||
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
|
||||
}
|
||||
@@ -183,16 +174,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
@override
|
||||
void onTabChanged() {
|
||||
// Save tab name when changed (but not when restoring from storage)
|
||||
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
|
||||
// Only save if this was a user-initiated tab change, not a restore
|
||||
if (!_isRestoringTab) {
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name);
|
||||
});
|
||||
|
||||
// Focus first item in the current tab (only for user-initiated changes)
|
||||
// But not when navigating via tab bar (suppressAutoFocus is true)
|
||||
if (!suppressAutoFocus) {
|
||||
_focusCurrentTab();
|
||||
}
|
||||
@@ -306,15 +293,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
/// Handle when a tab's data has finished loading
|
||||
void _handleTabDataLoaded(int tabIndex) {
|
||||
// Track that this tab has loaded
|
||||
_loadedTabs.add(tabIndex);
|
||||
|
||||
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
|
||||
if (suppressAutoFocus) return;
|
||||
|
||||
// Only focus if this is the currently active tab
|
||||
if (tabController.index == tabIndex && mounted) {
|
||||
// Use post-frame callback to ensure the widget tree is fully built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
|
||||
_focusCurrentTab();
|
||||
@@ -352,21 +335,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
void _updateVisibleTabs(List<LibraryTabType> newTabs) {
|
||||
if (listEquals(_visibleTabs, newTabs)) return;
|
||||
|
||||
// Save current tab type before changing
|
||||
final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
|
||||
|
||||
// Dispose old focus nodes and controller
|
||||
for (final node in _tabFocusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
disposeTabNavigation();
|
||||
|
||||
// Build new
|
||||
_visibleTabs = newTabs;
|
||||
_tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
|
||||
initTabNavigation();
|
||||
|
||||
// Restore tab position: find current tab type in new set, default to first
|
||||
final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1;
|
||||
if (newIndex > 0) {
|
||||
tabController.index = newIndex;
|
||||
|
||||
@@ -49,7 +49,6 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
_currentDescending = widget.isSortDescending;
|
||||
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
|
||||
|
||||
// Scroll to selected item, then handle focus
|
||||
final selectedIndex = widget.selectedSort != null
|
||||
? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key)
|
||||
: -1;
|
||||
|
||||
@@ -23,12 +23,11 @@ class PlaylistItemCard extends StatefulWidget {
|
||||
final VoidCallback? onRemove;
|
||||
final VoidCallback? onTap;
|
||||
final void Function(MediaItem source)? onRefresh;
|
||||
final bool canReorder; // Whether drag handle should be shown
|
||||
final bool canReorder;
|
||||
|
||||
// Focus state for keyboard/D-pad navigation
|
||||
final bool isFocused;
|
||||
final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button
|
||||
final bool isMoving; // Whether this item is being moved/reordered
|
||||
final int? focusedColumn;
|
||||
final bool isMoving;
|
||||
|
||||
const PlaylistItemCard({
|
||||
super.key,
|
||||
@@ -56,20 +55,16 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textMuted = tokens(context).textMuted;
|
||||
|
||||
// Determine if row is focused (main content area)
|
||||
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
|
||||
|
||||
// Focus states for individual elements
|
||||
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
|
||||
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
|
||||
|
||||
// Determine card styling based on focus/move state
|
||||
Color? cardColor;
|
||||
ShapeBorder? cardShape;
|
||||
if (widget.isMoving) {
|
||||
cardColor = colorScheme.primaryContainer;
|
||||
} else if (isRowFocused) {
|
||||
// Row is focused - use visible border like FocusableWrapper
|
||||
cardColor = colorScheme.surfaceContainerHighest;
|
||||
cardShape = RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
@@ -126,18 +121,15 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
),
|
||||
),
|
||||
|
||||
// Poster thumbnail
|
||||
_buildPosterImage(context, item),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and metadata
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
item.displayTitle,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: .w500),
|
||||
@@ -147,7 +139,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Subtitle (episode info or type)
|
||||
Text(
|
||||
_buildSubtitle(item),
|
||||
style: TextStyle(fontSize: 13, color: textMuted),
|
||||
@@ -160,13 +151,11 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
if (item.durationMs != null)
|
||||
Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Remove button
|
||||
Container(
|
||||
decoration: isRemoveButtonFocused
|
||||
? BoxDecoration(
|
||||
|
||||
@@ -29,7 +29,6 @@ class AboutScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// App Icon and Name
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -54,7 +53,6 @@ class AboutScreen extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Open Source Licenses
|
||||
SettingsGroup(
|
||||
margin: EdgeInsets.zero,
|
||||
children: [
|
||||
|
||||
@@ -114,7 +114,6 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Package info card
|
||||
if (mergedLicense.allPackageNames.length > 1)
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -134,7 +133,6 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
),
|
||||
if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16),
|
||||
|
||||
// License cards
|
||||
...licenseEntries.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final license = entry.value;
|
||||
|
||||
@@ -43,10 +43,8 @@ class AmbientLightingService {
|
||||
|
||||
appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
|
||||
|
||||
// Set video-aspect-override to fill the entire output area
|
||||
await _player.setProperty('video-aspect-override', outputAspect.toString());
|
||||
|
||||
// Append ambient lighting shader
|
||||
await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]);
|
||||
|
||||
_enabled = true;
|
||||
|
||||
@@ -139,7 +139,6 @@ class DiscordRPCService {
|
||||
_playbackSpeed = 1.0;
|
||||
|
||||
if (_isEnabled && _isConnected) {
|
||||
// Upload thumbnail in background, don't block playback
|
||||
unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client));
|
||||
}
|
||||
}
|
||||
@@ -148,9 +147,7 @@ class DiscordRPCService {
|
||||
void updatePosition(Duration position) {
|
||||
final isSeek = _timeline.updatePosition(position);
|
||||
|
||||
// Update presence if position jumped significantly (seek detected)
|
||||
if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) {
|
||||
// Throttle updates to max once per second
|
||||
final now = DateTime.now();
|
||||
if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) {
|
||||
_lastPresenceUpdate = now;
|
||||
@@ -172,7 +169,6 @@ class DiscordRPCService {
|
||||
Future<void> resumePlayback() async {
|
||||
if (_currentMetadata == null) return;
|
||||
|
||||
// Reset start time for elapsed time display
|
||||
_playbackStartTime = DateTime.now();
|
||||
|
||||
if (_isEnabled && _isConnected) {
|
||||
@@ -182,7 +178,6 @@ class DiscordRPCService {
|
||||
|
||||
/// Pause - clear timestamp but keep showing what's playing
|
||||
Future<void> pausePlayback() async {
|
||||
// Clear start time so Discord stops counting
|
||||
_playbackStartTime = null;
|
||||
|
||||
if (_isEnabled && _isConnected) {
|
||||
@@ -309,12 +304,9 @@ class DiscordRPCService {
|
||||
|
||||
Future<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async {
|
||||
try {
|
||||
// Get the thumbnail path (prefer show poster for episodes)
|
||||
final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath;
|
||||
if (thumbPath == null || thumbPath.isEmpty) return null;
|
||||
|
||||
// Check cache first (with expiry check). Key by backend so the same
|
||||
// path on Plex and Jellyfin doesn't collide.
|
||||
final cacheKey = '${client.backend.id}:$thumbPath';
|
||||
final cached = _posterUrlCache[cacheKey];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
|
||||
@@ -192,25 +192,20 @@ class GamepadService with WindowListener {
|
||||
_tabNavigationHandlers.clear();
|
||||
}
|
||||
|
||||
// Deadzone for analog sticks (0.0 to 1.0)
|
||||
static const double _stickDeadzone = 0.5;
|
||||
|
||||
// Auto-repeat timing for held directional inputs (D-pad / stick)
|
||||
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
|
||||
static const Duration _repeatInterval = Duration(milliseconds: 80);
|
||||
|
||||
key_sim.KeyEventSimulatorController? _keyEventSimulator;
|
||||
|
||||
// Track stick state to detect deadzone crossings
|
||||
bool _leftStickUp = false;
|
||||
bool _leftStickDown = false;
|
||||
bool _leftStickLeft = false;
|
||||
bool _leftStickRight = false;
|
||||
|
||||
// Track button states to prevent repeated events from button holds
|
||||
final Set<GamepadButton> _pressedButtons = {};
|
||||
final Set<GamepadButton> _suppressedButtons = {};
|
||||
// Whether the app window is currently focused — ignore gamepad input when false
|
||||
bool _windowFocused = true;
|
||||
bool _nativeKeyHandlerRegistered = false;
|
||||
bool _nativeTextInputFocused = false;
|
||||
|
||||
@@ -250,16 +250,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
if (!isMetaPressed) modifiersMatch = false;
|
||||
break;
|
||||
case HotKeyModifier.capsLock:
|
||||
// CapsLock is typically not used for shortcuts, ignore for now
|
||||
break;
|
||||
case HotKeyModifier.fn:
|
||||
// Fn key is typically not used for shortcuts, ignore for now
|
||||
break;
|
||||
}
|
||||
if (!modifiersMatch) break;
|
||||
}
|
||||
|
||||
// Check that no extra modifiers are pressed
|
||||
if (modifiersMatch) {
|
||||
final hasShift = requiredModifiers.contains(HotKeyModifier.shift);
|
||||
final hasControl = requiredModifiers.contains(HotKeyModifier.control);
|
||||
@@ -372,7 +369,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge);
|
||||
}
|
||||
|
||||
// Check if a hotkey is already assigned to another action
|
||||
String? getActionForHotkey(HotKey hotkey) {
|
||||
for (final entry in _hotkeys.entries) {
|
||||
final assignedHotkey = entry.value;
|
||||
@@ -383,7 +379,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Helper method to compare two HotKey objects
|
||||
bool _hotkeyEquals(HotKey a, HotKey b) {
|
||||
if (a.key != b.key) return false;
|
||||
|
||||
|
||||
@@ -50,8 +50,6 @@ String formatContentRating(String? contentRating) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Remove common country prefixes like "gb/", "us/", "de/", etc.
|
||||
// The pattern matches: lowercase letters followed by a forward slash
|
||||
final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false);
|
||||
final match = regex.firstMatch(contentRating);
|
||||
|
||||
|
||||
@@ -68,7 +68,6 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final usesBackButton = leading == null && onBack != null;
|
||||
|
||||
// Determine the leading widget based on priority: leading > onBack > icon
|
||||
Widget? resolvedLeading;
|
||||
if (leading != null) {
|
||||
resolvedLeading = leading;
|
||||
|
||||
@@ -92,7 +92,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
@override
|
||||
void didUpdateWidget(DownloadTreeView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// When suppressAutoFocus changes from true to false, focus the first item
|
||||
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _firstItemFocusNode.canRequestFocus) {
|
||||
@@ -121,13 +120,11 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the download tree from flat download list
|
||||
List<DownloadTreeNode> _buildTree() {
|
||||
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
|
||||
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
|
||||
final List<DownloadTreeNode> movies = [];
|
||||
|
||||
// Group downloads
|
||||
for (final entry in widget.downloads.entries) {
|
||||
final globalKey = entry.key;
|
||||
final download = entry.value;
|
||||
@@ -136,17 +133,14 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
if (meta == null) continue;
|
||||
|
||||
if (meta.isEpisode) {
|
||||
// Group episodes by show
|
||||
final showKey = meta.grandparentId ?? 'unknown';
|
||||
showGroups.putIfAbsent(showKey, () => []);
|
||||
showGroups[showKey]!.add(entry);
|
||||
} else if (meta.kind == MediaKind.track) {
|
||||
// Group tracks by album (single level — no per-disc tier)
|
||||
final albumKey = meta.parentId ?? 'unknown';
|
||||
albumGroups.putIfAbsent(albumKey, () => []);
|
||||
albumGroups[albumKey]!.add(entry);
|
||||
} else if (meta.isMovie) {
|
||||
// Movies go at top level
|
||||
movies.add(
|
||||
DownloadTreeNode(
|
||||
key: globalKey,
|
||||
@@ -161,7 +155,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
}
|
||||
}
|
||||
|
||||
// Build show nodes
|
||||
final List<DownloadTreeNode> shows = [];
|
||||
for (final showEntry in showGroups.entries) {
|
||||
final showKey = showEntry.key;
|
||||
@@ -169,11 +162,9 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
if (episodes.isEmpty) continue;
|
||||
|
||||
// Get show metadata from first episode
|
||||
final firstEpisode = widget.metadata[episodes.first.key];
|
||||
final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow;
|
||||
|
||||
// Group episodes by season
|
||||
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
|
||||
for (final episode in episodes) {
|
||||
final meta = widget.metadata[episode.key];
|
||||
@@ -184,7 +175,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
seasonGroups[seasonKey]!.add(episode);
|
||||
}
|
||||
|
||||
// Build season nodes
|
||||
final List<DownloadTreeNode> seasons = [];
|
||||
for (final seasonEntry in seasonGroups.entries) {
|
||||
final seasonKey = seasonEntry.key;
|
||||
@@ -192,7 +182,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
if (seasonEpisodes.isEmpty) continue;
|
||||
|
||||
// Get season metadata from first episode
|
||||
final firstEpisode = widget.metadata[seasonEpisodes.first.key];
|
||||
final seasonNumber = firstEpisode?.parentIndex;
|
||||
final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true
|
||||
@@ -201,7 +190,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
? t.common.seasonNumber(number: seasonNumber)
|
||||
: t.downloads.unknownSeason;
|
||||
|
||||
// Build episode nodes
|
||||
final List<DownloadTreeNode> episodeNodes = [];
|
||||
for (final episodeEntry in seasonEpisodes) {
|
||||
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) {
|
||||
final aIndex = a.metadata?.index ?? 0;
|
||||
final bIndex = b.metadata?.index ?? 0;
|
||||
return aIndex.compareTo(bIndex);
|
||||
});
|
||||
|
||||
// Calculate aggregate season progress
|
||||
final seasonProgress = episodeNodes.isEmpty
|
||||
? 0.0
|
||||
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length;
|
||||
@@ -255,14 +241,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
seasons.removeWhere((s) => s.children.isEmpty);
|
||||
|
||||
// Sort seasons by season number
|
||||
seasons.sort((a, b) {
|
||||
final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0;
|
||||
final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0;
|
||||
return aSeasonNum.compareTo(bSeasonNum);
|
||||
});
|
||||
|
||||
// Calculate aggregate show progress
|
||||
final showProgress = seasons.isEmpty
|
||||
? 0.0
|
||||
: seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length;
|
||||
@@ -280,14 +264,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
// Build album nodes (album -> tracks)
|
||||
final List<DownloadTreeNode> albums = [];
|
||||
for (final albumEntry in albumGroups.entries) {
|
||||
final albumKey = albumEntry.key;
|
||||
final tracks = albumEntry.value;
|
||||
if (tracks.isEmpty) continue;
|
||||
|
||||
// Album/artist names from any track's parent fields
|
||||
final firstTrack = widget.metadata[tracks.first.key];
|
||||
final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum;
|
||||
final artistTitle = firstTrack?.albumArtistTitle;
|
||||
@@ -314,7 +296,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
}
|
||||
if (trackNodes.isEmpty) continue;
|
||||
|
||||
// Sort tracks by disc then track number
|
||||
trackNodes.sort((a, b) {
|
||||
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
|
||||
if (byDisc != 0) return byDisc;
|
||||
@@ -336,17 +317,13 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort shows, albums, and movies by status and title
|
||||
_sortNodesByStatusAndTitle(shows);
|
||||
_sortNodesByStatusAndTitle(albums);
|
||||
_sortNodesByStatusAndTitle(movies);
|
||||
|
||||
// Combine movies, shows, and albums
|
||||
return [...movies, ...shows, ...albums];
|
||||
}
|
||||
|
||||
/// Determine aggregate status from child statuses
|
||||
/// Priority: downloading > queued > paused > completed > failed
|
||||
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
|
||||
if (statuses.isEmpty) return DownloadStatus.queued;
|
||||
|
||||
@@ -365,7 +342,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
return DownloadStatus.completed;
|
||||
}
|
||||
|
||||
/// Compare statuses for sorting (downloading first, then queued, etc.)
|
||||
int _compareByStatus(DownloadStatus a, DownloadStatus b) {
|
||||
const statusOrder = {
|
||||
DownloadStatus.downloading: 0,
|
||||
@@ -378,7 +354,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
|
||||
}
|
||||
|
||||
/// Sort nodes by status (downloading first) then by title
|
||||
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
|
||||
nodes.sort((a, b) {
|
||||
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}) {
|
||||
return _DownloadTreeItem(
|
||||
node: node,
|
||||
@@ -590,9 +564,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
return widget.node.status;
|
||||
}
|
||||
|
||||
// Focus node for row content (only created if not provided externally)
|
||||
FocusNode? _ownedRowFocusNode;
|
||||
// Focus nodes for action buttons (up to 3 buttons max)
|
||||
final List<FocusNode> _buttonFocusNodes = [];
|
||||
|
||||
FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!;
|
||||
@@ -676,10 +648,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Row content
|
||||
Expanded(child: _buildRowContent(theme, canExpand)),
|
||||
|
||||
// Action buttons
|
||||
if (actions.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: .min,
|
||||
@@ -696,7 +666,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
Widget _buildRowContent(ThemeData theme, bool canExpand) {
|
||||
return Row(
|
||||
children: [
|
||||
// Expand/collapse icon
|
||||
if (canExpand)
|
||||
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
|
||||
else
|
||||
@@ -704,12 +673,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Status icon
|
||||
DownloadStatusIcon(status: _effectiveStatus, size: 20),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
|
||||
@@ -275,7 +275,6 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key down and repeat events
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@@ -289,7 +289,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
final ScrollController _dialogScrollController = ScrollController();
|
||||
final ScrollController _sheetScrollController = ScrollController();
|
||||
|
||||
// Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button.
|
||||
@override
|
||||
List<MediaLibrary> get reorderItems => _tempLibraries;
|
||||
|
||||
@@ -468,16 +467,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
final isHidden = hiddenLibraryKeys.contains(library.globalKey);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Determine background color based on state
|
||||
Color? tileColor;
|
||||
if (isMoving) {
|
||||
tileColor = colorScheme.primaryContainer;
|
||||
} else if (isFocused && focusedColumn == 0) {
|
||||
// Only highlight row when row itself is focused (column 0)
|
||||
tileColor = colorScheme.surfaceContainerHighest;
|
||||
}
|
||||
|
||||
// Button focus states
|
||||
final isVisibilityButtonFocused = isFocused && focusedColumn == 1;
|
||||
final isOptionsButtonFocused = isFocused && focusedColumn == 2;
|
||||
|
||||
|
||||
@@ -615,13 +615,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
// Poster with overlay
|
||||
if (posterHeight != null)
|
||||
SizedBox(width: double.infinity, height: posterHeight, child: poster)
|
||||
else
|
||||
Expanded(child: poster),
|
||||
const SizedBox(height: 2),
|
||||
// Title (flattened — no inner Column)
|
||||
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
|
||||
_ClickableText(
|
||||
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),
|
||||
),
|
||||
),
|
||||
// Subtitle
|
||||
if (item is MediaPlaylist)
|
||||
_MediaCardHelpers.buildPlaylistMeta(context, item)
|
||||
else if (item is MediaItem)
|
||||
@@ -1131,7 +1128,6 @@ class _MediaCardHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
// For collections, show item count
|
||||
if (mi.kind == MediaKind.collection) {
|
||||
final count = mi.childCount ?? mi.leafCount;
|
||||
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) {
|
||||
return ExcludeSemantics(
|
||||
child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||
);
|
||||
}
|
||||
|
||||
// For tracks, show "Artist • duration"
|
||||
if (mi.kind == MediaKind.track) {
|
||||
final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)];
|
||||
if (parts.isNotEmpty) {
|
||||
@@ -1163,7 +1157,6 @@ class _MediaCardHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
// For episodes, show "S# · Episode Title" with clickable season link
|
||||
if (mi.isEpisode && mi.parentIndex != null) {
|
||||
if (enableDetailLinks && mi.parentId != null) {
|
||||
return _buildEpisodeSubtitleRow(
|
||||
@@ -1185,7 +1178,6 @@ class _MediaCardHelpers {
|
||||
);
|
||||
}
|
||||
|
||||
// For other media types, show subtitle/parent/year
|
||||
if (mi.displaySubtitle != null) {
|
||||
return ExcludeSemantics(
|
||||
child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||
|
||||
@@ -130,7 +130,7 @@ class MediaContextMenu extends StatefulWidget {
|
||||
final Object item;
|
||||
final void Function(MediaItem source)? onRefresh;
|
||||
final VoidCallback? onRemoveFromContinueWatching;
|
||||
final VoidCallback? onListRefresh; // For refreshing list after deletion
|
||||
final VoidCallback? onListRefresh;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// Plays the item's trailer. When non-null a "Play trailer" item is added to
|
||||
|
||||
@@ -173,7 +173,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
bool get _canControl => _trackControlsState.canControl;
|
||||
bool get _isLive => _trackControlsState.isLive;
|
||||
|
||||
// Focus nodes for playback control buttons
|
||||
late final FocusNode _prevItemFocusNode;
|
||||
late final FocusNode _prevChapterFocusNode;
|
||||
late final FocusNode _skipBackFocusNode;
|
||||
@@ -184,30 +183,23 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
late final FocusNode _goToLiveFocusNode;
|
||||
late final FocusNode _timelineFocusNode;
|
||||
|
||||
// Focus node for volume control
|
||||
late final FocusNode _volumeFocusNode;
|
||||
|
||||
// Focus nodes for track/chapter controls (max 8 buttons possible)
|
||||
late final List<FocusNode> _trackControlFocusNodes;
|
||||
|
||||
// List of button focus nodes for horizontal navigation
|
||||
late final List<FocusNode> _buttonFocusNodes;
|
||||
|
||||
// Progressive seek acceleration state
|
||||
LogicalKeyboardKey? _seekDirection; // Current direction being held
|
||||
int _seekRepeatCount = 0; // Consecutive key repeats for acceleration
|
||||
|
||||
// Preview thumbnail during sustained dpad/keyboard seeking
|
||||
bool _showKeyRepeatThumbnail = false;
|
||||
Timer? _keyRepeatThumbnailTimer;
|
||||
late final DebouncedSeekAccumulator _timelineSeek;
|
||||
static const _keyRepeatThumbnailTimeout = Duration(milliseconds: 400);
|
||||
|
||||
// Content strip state
|
||||
bool _contentStripVisible = false;
|
||||
final GlobalKey<ContentStripState> _contentStripKey = GlobalKey<ContentStripState>();
|
||||
|
||||
// Track which button was last focused (for returning from content strip)
|
||||
FocusNode? _lastFocusedButtonNode;
|
||||
|
||||
/// Whether the content strip has any content to show
|
||||
@@ -714,7 +706,6 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Row 1: Timeline (LiveTimelineBar for time-shifted live, VideoTimelineBar for VOD)
|
||||
if (_isLive && widget.captureBuffer != null) ...[
|
||||
LiveTimelineBar(
|
||||
player: widget.player,
|
||||
@@ -748,14 +739,12 @@ class DesktopVideoControlsState extends State<DesktopVideoControls> {
|
||||
previewPosition: _timelineSeek.pendingPosition,
|
||||
),
|
||||
],
|
||||
// Row 2: Playback controls and options
|
||||
Focus(
|
||||
onFocusChange: _onButtonRowFocusChange,
|
||||
skipTraversal: true,
|
||||
child: Row(
|
||||
children: [
|
||||
if (!_isLive) ...[
|
||||
// Previous item
|
||||
Opacity(
|
||||
opacity: _canControl ? 1.0 : 0.5,
|
||||
child: _buildFocusableButton(
|
||||
|
||||
@@ -354,7 +354,6 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
mainAxisAlignment: .center,
|
||||
children: [
|
||||
if (!widget.isLive) ...[
|
||||
// Previous episode button (greyed out when unavailable)
|
||||
CircularControlButton(
|
||||
semanticLabel: t.videoControls.previousButton,
|
||||
icon: Symbols.skip_previous_rounded,
|
||||
@@ -378,7 +377,6 @@ class _MobileVideoControlsState extends State<MobileVideoControls> with SingleTi
|
||||
),
|
||||
if (!widget.isLive) ...[
|
||||
const SizedBox(width: 24),
|
||||
// Next episode button (greyed out when unavailable)
|
||||
CircularControlButton(
|
||||
semanticLabel: t.videoControls.nextButton,
|
||||
icon: Symbols.skip_next_rounded,
|
||||
|
||||
@@ -24,7 +24,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
final currentTrack = widget.player.state.track.subtitle;
|
||||
// Nothing to hide when no subtitle track is selected.
|
||||
if (currentTrack == null || currentTrack.id == SubtitleTrack.off.id) return;
|
||||
|
||||
_setSubtitleVisibility(false);
|
||||
@@ -51,7 +50,6 @@ extension _PlexVideoControlsTrackMethods on _PlexVideoControlsState {
|
||||
}
|
||||
|
||||
void _onSubtitleTrackChanged(SubtitleTrack track) {
|
||||
// Reset visibility when user explicitly picks a new subtitle track
|
||||
if (track.id != 'no' && !_subtitlesVisible) {
|
||||
_setSubtitleVisibility(true);
|
||||
}
|
||||
|
||||
@@ -770,7 +770,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
},
|
||||
),
|
||||
|
||||
// Audio Sync
|
||||
_SettingsMenuItem(
|
||||
icon: Symbols.sync_rounded,
|
||||
title: t.videoSettings.audioSync,
|
||||
@@ -779,7 +778,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
onTap: () => _navigateTo(_SettingsView.audioSync),
|
||||
),
|
||||
|
||||
// Subtitle Sync
|
||||
_SettingsMenuItem(
|
||||
icon: Symbols.subtitles_rounded,
|
||||
title: t.videoSettings.subtitleSync,
|
||||
@@ -788,7 +786,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
onTap: () => _navigateTo(_SettingsView.subtitleSync),
|
||||
),
|
||||
|
||||
// HDR Toggle
|
||||
if (_supportsHdrControl)
|
||||
_SettingsToggleItem(
|
||||
pref: SettingsService.enableHDR,
|
||||
@@ -808,14 +805,12 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
onTap: () => _navigateTo(_SettingsView.hdrToneMapping),
|
||||
),
|
||||
|
||||
// Auto-Play Next Episode Toggle
|
||||
_SettingsToggleItem(
|
||||
pref: SettingsService.autoPlayNextEpisode,
|
||||
icon: Symbols.skip_next_rounded,
|
||||
title: t.videoControls.autoPlayNext,
|
||||
),
|
||||
|
||||
// Audio Output Device (Desktop only)
|
||||
if (isDesktop)
|
||||
StreamBuilder<AudioDevice>(
|
||||
stream: widget.player.streams.audioDevice,
|
||||
@@ -845,7 +840,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
// "not Dolby" when the system reports notApplicable.
|
||||
if (PlatformDetector.isAppleTV()) _AudioRenderingModeItem(player: widget.player),
|
||||
|
||||
// Audio Normalization
|
||||
_SettingsToggleItem(
|
||||
pref: SettingsService.audioNormalization,
|
||||
icon: Symbols.graphic_eq_rounded,
|
||||
@@ -853,7 +847,6 @@ class _VideoSettingsSheetState extends State<VideoSettingsSheet> {
|
||||
onAfterWrite: widget.player.setAudioNormalization,
|
||||
),
|
||||
|
||||
// Stereo Downmix
|
||||
_SettingsToggleItem(
|
||||
pref: SettingsService.audioDownmix,
|
||||
icon: Symbols.headphones_rounded,
|
||||
|
||||
@@ -62,7 +62,6 @@ class VideoControlButton extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Determine the effective color: explicit color > active amber > default white
|
||||
final effectiveColor = color ?? (isActive ? Colors.amber : Colors.white);
|
||||
|
||||
final button = IconButton(
|
||||
@@ -85,9 +84,9 @@ class VideoControlButton extends StatelessWidget {
|
||||
semanticLabel: effectiveSemanticLabel,
|
||||
semanticValue: semanticValue,
|
||||
checked: checked,
|
||||
borderRadius: 20, // Circular for icon buttons
|
||||
autoScroll: false, // Video controls don't scroll
|
||||
useBackgroundFocus: true, // Use background highlight for video controls
|
||||
borderRadius: 20,
|
||||
autoScroll: false,
|
||||
useBackgroundFocus: true,
|
||||
child: result,
|
||||
);
|
||||
} else if (effectiveSemanticLabel != null) {
|
||||
|
||||
@@ -5,10 +5,8 @@ import '../../../../i18n/strings.g.dart';
|
||||
/// Contains metrics queried from the video player (MPV or ExoPlayer)
|
||||
/// including video/audio codec info, playback performance, and buffer state.
|
||||
class PerformanceStats {
|
||||
// Player info
|
||||
final String playerType; // 'mpv' or 'exoplayer'
|
||||
final String playerType;
|
||||
|
||||
// Video metrics
|
||||
final String? videoCodec;
|
||||
final int? videoWidth;
|
||||
final int? videoHeight;
|
||||
@@ -19,38 +17,32 @@ class PerformanceStats {
|
||||
final int? rotate;
|
||||
final String? videoDecoderName;
|
||||
|
||||
// Color/Format metrics
|
||||
final String? pixelformat;
|
||||
final String? hwPixelformat;
|
||||
final String? colormatrix;
|
||||
final String? primaries;
|
||||
final String? gamma;
|
||||
|
||||
// HDR metadata
|
||||
final double? maxLuma;
|
||||
final double? minLuma;
|
||||
final double? maxCll;
|
||||
final double? maxFall;
|
||||
|
||||
// Audio metrics
|
||||
final String? audioCodec;
|
||||
final int? audioSamplerate;
|
||||
final String? audioChannels;
|
||||
final int? audioBitrate;
|
||||
final String? audioDecoderName;
|
||||
|
||||
// Tunneling
|
||||
final bool tunneledPlayback;
|
||||
final String? tunnelingStatus;
|
||||
|
||||
// Performance metrics
|
||||
final double? actualFps;
|
||||
final double? avsyncChange;
|
||||
final double? displayFps;
|
||||
final int? frameDropCount;
|
||||
final int? decoderFrameDropCount;
|
||||
|
||||
// Buffer metrics
|
||||
final int? cacheUsed;
|
||||
final int? cacheLimit;
|
||||
final double? cacheSpeed;
|
||||
@@ -58,9 +50,8 @@ class PerformanceStats {
|
||||
final int? bufferTargetBytes;
|
||||
final int? bufferMaxMs;
|
||||
|
||||
// DV conversion
|
||||
final bool dvConversionActive;
|
||||
final String dvConversionMode; // "DV81", "HEVC_STRIP", "DISABLED"
|
||||
final String dvConversionMode;
|
||||
final int? dvConvertedRpus;
|
||||
final int? dvRpuConversionFailures;
|
||||
final int? dvRpuOutputTooSmall;
|
||||
@@ -70,7 +61,6 @@ class PerformanceStats {
|
||||
final String? dvPlaybackPath;
|
||||
final String? dvPlaybackReason;
|
||||
|
||||
// App metrics
|
||||
final int? appMemoryBytes;
|
||||
final double? uiFps;
|
||||
|
||||
|
||||
@@ -69,7 +69,6 @@ class TrackChapterControls extends StatelessWidget {
|
||||
|
||||
final key = event.logicalKey;
|
||||
|
||||
// LEFT arrow - move to previous button or exit to volume
|
||||
if (key == LogicalKeyboardKey.arrowLeft) {
|
||||
if (index > 0 && focusNodes != null && focusNodes!.length > index - 1) {
|
||||
focusNodes![index - 1].requestFocus();
|
||||
@@ -81,23 +80,19 @@ class TrackChapterControls extends StatelessWidget {
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// RIGHT arrow - move to next button
|
||||
if (key == LogicalKeyboardKey.arrowRight) {
|
||||
if (index < totalButtons - 1 && focusNodes != null && focusNodes!.length > index + 1) {
|
||||
focusNodes![index + 1].requestFocus();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
// At end, consume to prevent bubbling
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// UP arrow - navigate up (e.g., to timeline)
|
||||
if (key == LogicalKeyboardKey.arrowUp) {
|
||||
onNavigateUp?.call();
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
// DOWN arrow - navigate down (e.g., to content strip)
|
||||
if (key == LogicalKeyboardKey.arrowDown) {
|
||||
onNavigateDown?.call();
|
||||
return KeyEventResult.handled;
|
||||
@@ -146,11 +141,9 @@ class TrackChapterControls extends StatelessWidget {
|
||||
final isMobile = PlatformDetector.isMobile(context);
|
||||
final isDesktop = PlatformDetector.isDesktopOS();
|
||||
|
||||
// Build list of buttons dynamically to track indices
|
||||
final buttons = <Widget>[];
|
||||
int buttonIndex = 0;
|
||||
|
||||
// Settings button (always shown)
|
||||
buttons.add(
|
||||
ListenableBuilder(
|
||||
listenable: SleepTimerService(),
|
||||
@@ -193,7 +186,6 @@ class TrackChapterControls extends StatelessWidget {
|
||||
);
|
||||
buttonIndex++;
|
||||
|
||||
// Combined audio & subtitles button
|
||||
{
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
@@ -232,7 +224,6 @@ class TrackChapterControls extends StatelessWidget {
|
||||
buttonIndex++;
|
||||
}
|
||||
|
||||
// Chapters button (hidden on mobile when content strip is available)
|
||||
if (chapters.isNotEmpty && !hideChaptersAndQueue) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
@@ -264,7 +255,6 @@ class TrackChapterControls extends StatelessWidget {
|
||||
buttonIndex++;
|
||||
}
|
||||
|
||||
// Queue button (hidden on mobile when content strip is available)
|
||||
if (state.showQueueButton && state.onQueueItemSelected != null && !hideChaptersAndQueue) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
@@ -286,7 +276,6 @@ class TrackChapterControls extends StatelessWidget {
|
||||
buttonIndex++;
|
||||
}
|
||||
|
||||
// Picture-in-Picture mode
|
||||
if (state.onTogglePIPMode != null) {
|
||||
final currentIndex = buttonIndex;
|
||||
buttons.add(
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
# Project-level configuration.
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
@@ -6,8 +5,6 @@ project(runner LANGUAGES CXX)
|
||||
# registered by the runner subdirectory.
|
||||
include(CTest)
|
||||
|
||||
# The name of the executable created for the application. Change this to change
|
||||
# the on-disk name of your application.
|
||||
set(BINARY_NAME "plezy")
|
||||
# The unique GTK application identifier for this application. See:
|
||||
# https://wiki.gnome.org/HowDoI/ChooseApplicationID
|
||||
@@ -30,7 +27,6 @@ if(FLUTTER_TARGET_PLATFORM_SYSROOT)
|
||||
set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
|
||||
endif()
|
||||
|
||||
# Define build configuration options.
|
||||
if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES)
|
||||
set(CMAKE_BUILD_TYPE "Debug" CACHE
|
||||
STRING "Flutter build mode" FORCE)
|
||||
@@ -63,7 +59,6 @@ function(APPLY_STANDARD_SETTINGS TARGET)
|
||||
target_compile_definitions(${TARGET} PRIVATE "$<$<NOT:$<CONFIG:Debug>>:NDEBUG>")
|
||||
endfunction()
|
||||
|
||||
# Flutter library and tool build rules.
|
||||
set(FLUTTER_MANAGED_DIR "${CMAKE_CURRENT_SOURCE_DIR}/flutter")
|
||||
add_subdirectory(${FLUTTER_MANAGED_DIR})
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
#!/bin/bash
|
||||
# Ensure binaries are executable
|
||||
chmod +x /usr/bin/plezy
|
||||
chmod +x /opt/plezy/plezy
|
||||
chmod +x /opt/plezy/lib/crashpad_handler
|
||||
|
||||
# Update icon cache
|
||||
if command -v gtk-update-icon-cache &> /dev/null; then
|
||||
gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true
|
||||
fi
|
||||
|
||||
# Update desktop database
|
||||
if command -v update-desktop-database &> /dev/null; then
|
||||
update-desktop-database /usr/share/applications || true
|
||||
fi
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
#!/bin/bash
|
||||
# Update icon cache
|
||||
if command -v gtk-update-icon-cache &> /dev/null; then
|
||||
gtk-update-icon-cache -f -t /usr/share/icons/hicolor || true
|
||||
fi
|
||||
|
||||
# Update desktop database
|
||||
if command -v update-desktop-database &> /dev/null; then
|
||||
update-desktop-database /usr/share/applications || true
|
||||
fi
|
||||
|
||||
@@ -6,14 +6,12 @@ import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Paths
|
||||
SCRIPT_DIR = Path(__file__).parent.resolve()
|
||||
PROJECT_ROOT = SCRIPT_DIR.parent.parent
|
||||
BUILD_DIR = Path(os.environ["BUILD_DIR"]) if "BUILD_DIR" in os.environ else PROJECT_ROOT / "build/linux/x64/release/bundle"
|
||||
OUTPUT_DIR = Path(os.environ.get("OUTPUT_DIR", PROJECT_ROOT))
|
||||
ARCH_SUFFIX = os.environ.get("ARCH_SUFFIX", "x64")
|
||||
|
||||
# Package metadata
|
||||
METADATA = {
|
||||
"name": "plezy",
|
||||
"license": "GPL-3.0",
|
||||
@@ -23,16 +21,13 @@ METADATA = {
|
||||
"description": "A modern Plex and Jellyfin client for desktop and mobile",
|
||||
}
|
||||
|
||||
# Icon sizes to generate
|
||||
ICON_SIZES = [16, 32, 48, 64, 128, 256, 512]
|
||||
|
||||
# Architecture mappings per package format
|
||||
ARCH_MAP = {
|
||||
"x64": {"deb": "amd64", "rpm": "x86_64", "pacman": "x86_64"},
|
||||
"arm64": {"deb": "arm64", "rpm": "aarch64", "pacman": "aarch64"},
|
||||
}
|
||||
|
||||
# Distro-specific configuration
|
||||
DISTROS = {
|
||||
"deb": {
|
||||
"type": "deb",
|
||||
|
||||
@@ -1,11 +1,6 @@
|
||||
cmake_minimum_required(VERSION 3.13)
|
||||
project(runner LANGUAGES CXX)
|
||||
|
||||
# Define the application target. To change its name, change BINARY_NAME in the
|
||||
# top-level CMakeLists.txt, not the value here, or `flutter run` will no longer
|
||||
# work.
|
||||
#
|
||||
# Any new source files that you add to the application should be added here.
|
||||
add_executable(${BINARY_NAME}
|
||||
"main.cc"
|
||||
"my_application.cc"
|
||||
@@ -15,8 +10,6 @@ add_executable(${BINARY_NAME}
|
||||
"${FLUTTER_MANAGED_DIR}/generated_plugin_registrant.cc"
|
||||
)
|
||||
|
||||
# Apply the standard set of build settings. This can be removed for applications
|
||||
# that need different build settings.
|
||||
apply_standard_settings(${BINARY_NAME})
|
||||
|
||||
# apply_standard_settings asks for cxx_std_14, which current compilers satisfy
|
||||
@@ -25,14 +18,8 @@ apply_standard_settings(${BINARY_NAME})
|
||||
# compiler happens to default to.
|
||||
target_compile_features(${BINARY_NAME} PRIVATE cxx_std_17)
|
||||
|
||||
# Add preprocessor definitions for the application ID.
|
||||
add_definitions(-DAPPLICATION_ID="${APPLICATION_ID}")
|
||||
|
||||
# Find mpv library.
|
||||
pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)
|
||||
|
||||
# Find epoxy (OpenGL loader).
|
||||
pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
|
||||
|
||||
# Wayland client + EGL back the native video plane (a wl_subsurface below the
|
||||
# Flutter surface). GTK's Wayland backend already links these, but the runner
|
||||
@@ -40,6 +27,8 @@ pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
|
||||
pkg_check_modules(WAYLAND_CLIENT REQUIRED IMPORTED_TARGET wayland-client)
|
||||
pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
|
||||
pkg_check_modules(EGL REQUIRED IMPORTED_TARGET egl)
|
||||
pkg_check_modules(MPV REQUIRED IMPORTED_TARGET mpv)
|
||||
pkg_check_modules(EPOXY REQUIRED IMPORTED_TARGET epoxy)
|
||||
|
||||
# Vendored wayland-scanner output for color-management-v1 (staging), which backs
|
||||
# HDR on the native video plane. Built separately because it is C (the runner is
|
||||
@@ -65,14 +54,11 @@ target_include_directories(wayland_protocols PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}
|
||||
target_link_libraries(wayland_protocols PUBLIC PkgConfig::WAYLAND_CLIENT)
|
||||
target_compile_options(wayland_protocols PRIVATE -w)
|
||||
|
||||
# Build simdutf as a static library from the single-header amalgamation.
|
||||
add_library(simdutf STATIC "${simdutf_SOURCE_DIR}/simdutf.cpp")
|
||||
target_include_directories(simdutf PUBLIC "${simdutf_SOURCE_DIR}")
|
||||
target_compile_features(simdutf PUBLIC cxx_std_14)
|
||||
# Suppress warnings in third-party code
|
||||
target_compile_options(simdutf PRIVATE -w)
|
||||
|
||||
# Add dependency libraries. Add any application-specific dependencies here.
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE flutter)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::GTK)
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::MPV)
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
// Application-level settings for the Runner target.
|
||||
//
|
||||
// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
|
||||
// future. If not, the values below would default to using the project name when this becomes a
|
||||
// 'flutter create' template.
|
||||
|
||||
// The application's name. By default this is also the title of the Flutter window.
|
||||
PRODUCT_NAME = Plezy
|
||||
|
||||
// The application's bundle identifier
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy
|
||||
|
||||
// The copyright displayed in application information
|
||||
PRODUCT_COPYRIGHT = Copyright © 2025 edde746
|
||||
|
||||
@@ -16,8 +16,7 @@ class MainFlutterWindow: NSWindow {
|
||||
self.contentViewController = flutterViewController
|
||||
self.setFrame(windowFrame, display: true)
|
||||
|
||||
// Apply initial window configuration BEFORE frame restoration
|
||||
// This prevents the window from shrinking on launch
|
||||
// Configure window chrome before restoring the saved frame.
|
||||
self.titlebarAppearsTransparent = true
|
||||
self.titleVisibility = .hidden
|
||||
self.styleMask.insert(.fullSizeContentView)
|
||||
@@ -26,28 +25,23 @@ class MainFlutterWindow: NSWindow {
|
||||
let toolbar = ForwardingToolbar(flutterViewController: flutterViewController)
|
||||
self.toolbar = toolbar
|
||||
|
||||
// Register MPV player plugin for video playback
|
||||
MpvPlayerPlugin.register(
|
||||
with: flutterViewController.registrar(forPlugin: "MpvPlayerPlugin"))
|
||||
|
||||
// Register the audio-only MPV player plugin for music playback
|
||||
MpvAudioPlayerPlugin.register(
|
||||
with: flutterViewController.registrar(forPlugin: "MpvAudioPlayerPlugin"))
|
||||
|
||||
// Register window utils plugin for dynamic titlebar/fullscreen control from Dart
|
||||
WindowUtilsPlugin.register(
|
||||
with: flutterViewController.registrar(forPlugin: "WindowUtilsPlugin"))
|
||||
WindowUtilsPlugin.setWindow(self)
|
||||
WindowUtilsPlugin.installWindowDelegate()
|
||||
WindowUtilsPlugin.syncWindowChrome()
|
||||
|
||||
// Register Launch Services lookups used to detect installed external players
|
||||
AppLookupPlugin.register(
|
||||
with: flutterViewController.registrar(forPlugin: "AppLookupPlugin"))
|
||||
|
||||
RegisterGeneratedPlugins(registry: flutterViewController)
|
||||
|
||||
// Enable window position/size persistence
|
||||
self.setFrameAutosaveName("com.edde746.plezy.MainWindow")
|
||||
WindowUtilsPlugin.syncWindowChrome()
|
||||
|
||||
|
||||
@@ -47,8 +47,6 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
|
||||
|
||||
sourceWindow = window
|
||||
|
||||
// Create a layer-hosting wrapper view for the Metal layer.
|
||||
// PIPViewController resizes the view (and its root layer) as the PiP window resizes.
|
||||
let videoView = NSView(frame: NSRect(origin: .zero, size: aspectRatio))
|
||||
videoView.wantsLayer = true
|
||||
videoView.layer = metalLayer
|
||||
@@ -57,14 +55,12 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
|
||||
// Without this, the explicit main-window drawableSize persists in PiP.
|
||||
metalLayer.drawableSize = .zero
|
||||
|
||||
// Create a view controller for PIPViewController
|
||||
let vc = NSViewController()
|
||||
vc.view = videoView
|
||||
|
||||
pipVideoVC = vc
|
||||
pipVideoView = videoView
|
||||
|
||||
// Configure PiP
|
||||
pip.playing = delegate?.isPipPlaying ?? false
|
||||
pip.aspectRatio = aspectRatio
|
||||
pip.replacementWindow = window
|
||||
@@ -72,7 +68,6 @@ class MpvPipController: NSObject, PIPViewControllerDelegate {
|
||||
|
||||
delegate?.pipWillStart()
|
||||
|
||||
// Present PiP
|
||||
pip.presentAsPicture(inPicture: vc)
|
||||
isActive = true
|
||||
delegate?.pipDidStart()
|
||||
|
||||
@@ -11,14 +11,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible: Bool) {
|
||||
playerCore?.setVisible(visible, restoreOnWindowVisible: restoreOnWindowVisible)
|
||||
}
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
@@ -27,13 +25,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
// MARK: - FlutterPlugin Registration
|
||||
|
||||
static func register(with registrar: FlutterPluginRegistrar) {
|
||||
// Method channel for commands
|
||||
let methodChannel = FlutterMethodChannel(
|
||||
name: "com.plezy/mpv_player",
|
||||
binaryMessenger: registrar.messenger
|
||||
)
|
||||
|
||||
// Event channel for state updates
|
||||
let eventChannel = FlutterEventChannel(
|
||||
name: "com.plezy/mpv_player/events",
|
||||
binaryMessenger: registrar.messenger
|
||||
@@ -110,7 +106,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - PiP
|
||||
|
||||
private func ensurePipController() -> MpvPipController {
|
||||
if let existing = pipController { return existing }
|
||||
@@ -135,13 +130,11 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
let pip = ensurePipController()
|
||||
pip.setAutoStart(ready)
|
||||
if ready {
|
||||
// Observe app resigning active to auto-enter PiP
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didResignActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
self, selector: #selector(appDidResignActive),
|
||||
name: NSApplication.didResignActiveNotification, object: nil)
|
||||
// Observe app becoming active to auto-exit PiP
|
||||
NotificationCenter.default.removeObserver(
|
||||
self, name: NSApplication.didBecomeActiveNotification, object: nil)
|
||||
NotificationCenter.default.addObserver(
|
||||
@@ -184,8 +177,7 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
return
|
||||
}
|
||||
|
||||
// Get video dimensions for aspect ratio
|
||||
var aspectRatio = NSSize(width: 16, height: 9) // default
|
||||
var aspectRatio = NSSize(width: 16, height: 9)
|
||||
if let videoSize = playerCore.videoSize {
|
||||
aspectRatio = NSSize(width: videoSize.width, height: videoSize.height)
|
||||
}
|
||||
@@ -226,14 +218,12 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
return
|
||||
}
|
||||
|
||||
// Check if already initialized
|
||||
if self.playerCore?.isInitialized == true {
|
||||
print("[MpvPlayerPlugin] Already initialized")
|
||||
result(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Find the Flutter window
|
||||
guard let (window, _, _) = self.findFlutterWindow() else {
|
||||
print("[MpvPlayerPlugin] Failed to find Flutter window")
|
||||
result(
|
||||
@@ -242,7 +232,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
return
|
||||
}
|
||||
|
||||
// Create and initialize player core
|
||||
let core = MpvPlayerCore()
|
||||
core.delegate = self
|
||||
|
||||
@@ -256,7 +245,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
|
||||
self.playerCore = core
|
||||
|
||||
// Start hidden
|
||||
core.setVisible(false)
|
||||
|
||||
print("[MpvPlayerPlugin] Initialized successfully")
|
||||
@@ -303,7 +291,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
for window in NSApplication.shared.windows {
|
||||
if let contentView = window.contentView,
|
||||
let contentVC = window.contentViewController
|
||||
@@ -334,13 +321,10 @@ extension MpvPlayerPlugin: MpvPipDelegate {
|
||||
playerCore?.isPipActive = false
|
||||
enteredPipViaAuto = false
|
||||
|
||||
// Detach the Metal layer from the PiP wrapper view
|
||||
pipController?.detachLayer()
|
||||
|
||||
// Re-attach the Metal layer to the main window
|
||||
playerCore?.reattachMetalLayer()
|
||||
|
||||
// Force a redraw if paused (prevents black frame after PiP exit)
|
||||
if playerCore?.isPaused == true {
|
||||
playerCore?.forceDraw()
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ class WindowDelegate: NSObject, NSWindowDelegate {
|
||||
weak var channel: FlutterMethodChannel?
|
||||
weak var window: NSWindow?
|
||||
|
||||
// Hardcoded presentation options for fullscreen mode
|
||||
// Auto-hide toolbar, menu bar, and dock when in fullscreen
|
||||
private let fullScreenPresentationOptions: NSApplication.PresentationOptions = [
|
||||
.fullScreen,
|
||||
.autoHideToolbar,
|
||||
@@ -62,7 +60,6 @@ class WindowDelegate: NSObject, NSWindowDelegate {
|
||||
func windowWillEnterFullScreen(_ notification: Notification) {
|
||||
guard let window = window else { return }
|
||||
applyFullScreenChrome(to: window)
|
||||
// Notify Dart for state management only
|
||||
emit("windowWillEnterFullScreen")
|
||||
}
|
||||
|
||||
@@ -72,7 +69,6 @@ class WindowDelegate: NSObject, NSWindowDelegate {
|
||||
|
||||
func windowWillExitFullScreen(_ notification: Notification) {
|
||||
guard let window = window else { return }
|
||||
// Hide title and make titlebar transparent BEFORE exiting
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
emit("windowWillExitFullScreen")
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import Cocoa
|
||||
import FlutterMacOS
|
||||
|
||||
// MARK: - ForwardingView
|
||||
// A view that forwards mouse events to the Flutter view controller
|
||||
class ForwardingView: NSView {
|
||||
weak var flutterViewController: NSViewController?
|
||||
|
||||
@@ -15,8 +13,6 @@ class ForwardingView: NSView {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - ForwardingToolbar
|
||||
// A custom toolbar that forwards mouse events from the toolbar area to Flutter
|
||||
class ForwardingToolbar: NSToolbar, NSToolbarDelegate {
|
||||
let flutterViewController: NSViewController
|
||||
|
||||
@@ -26,7 +22,6 @@ class ForwardingToolbar: NSToolbar, NSToolbarDelegate {
|
||||
self.delegate = self
|
||||
self.showsBaselineSeparator = false
|
||||
|
||||
// Prevent toolbar customization UI (the "rounded box")
|
||||
self.allowsUserCustomization = false
|
||||
self.allowsExtensionItems = false
|
||||
if #available(macOS 15.0, *) {
|
||||
@@ -48,11 +43,11 @@ class ForwardingToolbar: NSToolbar, NSToolbarDelegate {
|
||||
) -> NSToolbarItem? {
|
||||
if itemIdentifier == NSToolbarItem.Identifier("ForwardingItem") {
|
||||
let item = NSToolbarItem(itemIdentifier: itemIdentifier)
|
||||
item.isBordered = false // Remove the rounded box appearance
|
||||
let view = ForwardingView()
|
||||
view.flutterViewController = flutterViewController
|
||||
view.widthAnchor.constraint(lessThanOrEqualToConstant: 100000).isActive = true
|
||||
view.widthAnchor.constraint(greaterThanOrEqualToConstant: 1).isActive = true
|
||||
item.isBordered = false
|
||||
item.view = view
|
||||
return item
|
||||
}
|
||||
@@ -68,7 +63,6 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin {
|
||||
private var windowDelegate: WindowDelegate?
|
||||
private var originalButtonConstraints: [NSWindow.ButtonType: [NSLayoutConstraint]] = [:]
|
||||
|
||||
// Centralized traffic light positions - the single source of truth
|
||||
private static let customButtonPositions: [(NSWindow.ButtonType, CGPoint)] = [
|
||||
(.closeButton, CGPoint(x: 20, y: 21)),
|
||||
(.miniaturizeButton, CGPoint(x: 40, y: 21)),
|
||||
@@ -224,7 +218,6 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin {
|
||||
window: NSWindow, buttonType: NSWindow.ButtonType, offset: CGPoint
|
||||
) {
|
||||
withButton(buttonType, in: window) { button, superview in
|
||||
// Store original constraints if not already stored
|
||||
if originalButtonConstraints[buttonType] == nil {
|
||||
let constraints = superview.constraints.filter { constraint in
|
||||
(constraint.firstItem as? NSButton) == button
|
||||
@@ -233,12 +226,10 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin {
|
||||
originalButtonConstraints[buttonType] = constraints
|
||||
}
|
||||
|
||||
// Remove existing position constraints
|
||||
superview.removeConstraints(positionConstraints(for: button, in: superview))
|
||||
|
||||
button.translatesAutoresizingMaskIntoConstraints = false
|
||||
|
||||
// Add new positioning constraints
|
||||
superview.addConstraints([
|
||||
button.leftAnchor.constraint(equalTo: superview.leftAnchor, constant: offset.x),
|
||||
button.topAnchor.constraint(equalTo: superview.topAnchor, constant: offset.y),
|
||||
@@ -249,10 +240,8 @@ class WindowUtilsPlugin: NSObject, FlutterPlugin {
|
||||
|
||||
private func resetButtonPosition(window: NSWindow, buttonType: NSWindow.ButtonType) {
|
||||
withButton(buttonType, in: window) { button, superview in
|
||||
// Remove custom constraints
|
||||
superview.removeConstraints(positionConstraints(for: button, in: superview))
|
||||
|
||||
// Restore original constraints if we have them
|
||||
if let originalConstraints = originalButtonConstraints[buttonType] {
|
||||
superview.addConstraints(originalConstraints)
|
||||
originalButtonConstraints.removeValue(forKey: buttonType)
|
||||
|
||||
+1
-12
@@ -25,15 +25,10 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** SafUtilPlugin */
|
||||
class SafUtilPlugin :
|
||||
FlutterPlugin,
|
||||
MethodCallHandler,
|
||||
ActivityAware {
|
||||
// / The MethodChannel that will the communication between Flutter and native Android
|
||||
// /
|
||||
// / This local reference serves to register the plugin with the Flutter Engine and unregister it
|
||||
// / when the Flutter Engine is detached from the Activity
|
||||
private lateinit var channel: MethodChannel
|
||||
|
||||
private lateinit var context: Context
|
||||
@@ -138,10 +133,8 @@ class SafUtilPlugin :
|
||||
val lastModified = cursor.getLong(4)
|
||||
val documentUri = DocumentsContract.buildDocumentUriUsingTree(mUri, documentId)
|
||||
|
||||
// Determine if the file is a directory based on the MIME type
|
||||
val isDirectory = DocumentsContract.Document.MIME_TYPE_DIR == mimeType
|
||||
|
||||
// Create a dictionary (map) for each file with its details
|
||||
val fileInfo =
|
||||
fileObjMap(
|
||||
documentUri,
|
||||
@@ -325,8 +318,7 @@ class SafUtilPlugin :
|
||||
if (findRes == null) {
|
||||
val createRes = curDocument.createDirectory(curName)
|
||||
if (createRes != null && createRes.name != curName) {
|
||||
// There are cases where the created directory has a different name due to concurrent operations.
|
||||
// In this case, we need to find the directory with the correct name again.
|
||||
// SAF may rename a concurrently created directory; resolve the requested name.
|
||||
val findRes2 = findDirectChild(curDocument.uri, curName)
|
||||
nextDocument =
|
||||
if (findRes2 != null) {
|
||||
@@ -504,7 +496,6 @@ class SafUtilPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
// Store the result to return the URI later
|
||||
pendingResult = result
|
||||
pendingArguments = PendingDirArguments(writePermission, persistablePermission)
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT_TREE)
|
||||
@@ -548,7 +539,6 @@ class SafUtilPlugin :
|
||||
return
|
||||
}
|
||||
|
||||
// Store the result to return the URI later
|
||||
pendingResult = result
|
||||
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT)
|
||||
intent.addCategory(Intent.CATEGORY_OPENABLE)
|
||||
@@ -772,7 +762,6 @@ class SafUtilPlugin :
|
||||
}
|
||||
}
|
||||
|
||||
// Handle folder/file/media picker results; unrelated request codes are not ours.
|
||||
private fun onActivityResult(
|
||||
requestCode: Int,
|
||||
resultCode: Int,
|
||||
|
||||
+8
-19
@@ -48,22 +48,15 @@ dependencies:
|
||||
cryptography: ^2.9.0
|
||||
file_picker: ^11.0.2
|
||||
saf_util:
|
||||
# Vendored upstream 3.1.0 with a pending Result lifecycle fix (CHANGELOG) —
|
||||
# upstream still double-replies MethodChannel results and crashes the Activity.
|
||||
# Vendored 3.1.0: fixes upstream's duplicate MethodChannel result.
|
||||
path: packages/saf_util
|
||||
saf_stream: ^3.1.0
|
||||
material_symbols_icons: ^4.2951.0
|
||||
web_socket_channel: ^3.0.1
|
||||
dart_discord_presence: ^1.2.0
|
||||
flutter_svg: ^2.2.3
|
||||
# Fork note: 41c16be creates the desktop temp parent before opening the
|
||||
# partial file, independently fixing upstream #649's missing-directory case.
|
||||
# Upstream's later target-directory fallback is unnecessary while this fork
|
||||
# retains its same-filesystem move and resume-data cleanup implementation.
|
||||
# No per-host trust bypass is carried: Plezy has no app-wide certificate
|
||||
# exception policy, and weakening only background downloads would make API
|
||||
# and playback behavior inconsistent. Revisit only with an explicit,
|
||||
# host-scoped trust setting shared by every media-server HTTP client.
|
||||
# Fork 41c16be fixes upstream #649's missing desktop temp parent.
|
||||
# Keep the same-filesystem move/resume cleanup; no per-host TLS bypass.
|
||||
background_downloader:
|
||||
git:
|
||||
url: https://github.com/edde746/background_downloader
|
||||
@@ -87,7 +80,7 @@ dev_dependencies:
|
||||
flutter_test:
|
||||
sdk: flutter
|
||||
flutter_lints: ^6.0.0
|
||||
# Root analysis traverses packages/saf_util and consumes its lint profile.
|
||||
# Root analysis includes packages/saf_util.
|
||||
mgenware_dart_lints: ^8.0.0
|
||||
fake_async: ^1.3.3
|
||||
stream_channel: ^2.1.4
|
||||
@@ -95,15 +88,13 @@ dev_dependencies:
|
||||
json_serializable: ^6.7.1
|
||||
slang_build_runner: ^4.14.0
|
||||
sentry_dart_plugin: ^3.3.0
|
||||
# Used by the explicit check-unused-* CI steps; its analyzer plugin is disabled.
|
||||
# Used by explicit check-unused CI steps; analyzer plugin disabled.
|
||||
dart_code_linter: ^4.1.8
|
||||
drift_dev: ^2.28.3
|
||||
shared_preferences_platform_interface: ^2.4.0
|
||||
path_provider_platform_interface: ^2.1.0
|
||||
# Drive the real desktop preference backends against a temp file in tests, so
|
||||
# the #1732 corrupt-store repair loop and the vendored atomic write are both
|
||||
# covered against genuine file I/O. All four expose `fs`/`pathProvider`.
|
||||
# The two shared_preferences packages resolve to the vendored copies below.
|
||||
# Exercise vendored desktop preference stores against real file I/O,
|
||||
# including #1732 repair and atomic writes.
|
||||
shared_preferences_linux: ^2.4.1
|
||||
shared_preferences_windows: ^2.4.1
|
||||
path_provider_linux: ^2.2.1
|
||||
@@ -128,9 +119,7 @@ dependency_overrides:
|
||||
url: https://github.com/edde746/auto_updater
|
||||
ref: 9e150f7
|
||||
path: packages/auto_updater_windows
|
||||
# Vendored 2.4.1 with an atomic store replacement (CHANGELOG, provenance.json)
|
||||
# — upstream truncates the live preference document on every write, which is
|
||||
# the corruption behind #1732.
|
||||
# Vendored 2.4.1: atomic replacement prevents upstream #1732 corruption.
|
||||
shared_preferences_windows:
|
||||
path: packages/shared_preferences_windows
|
||||
shared_preferences_linux:
|
||||
|
||||
@@ -15,9 +15,8 @@ FLUTTER_COMMIT = "559ffa3f75e7402d65a8def9c28389a9b2e6fe42"
|
||||
if len(sys.argv) > 2:
|
||||
raise SystemExit(f"Usage: {Path(sys.argv[0]).name} [workflow-path]")
|
||||
WORKFLOW = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else DEFAULT_WORKFLOW
|
||||
# The shared bootstrap both windows-arm jobs call, and the pins it must keep.
|
||||
# Resolved beside the workflow rather than from ROOT so that checking a fixture
|
||||
# tree exercises this rule instead of silently re-reading the real action.
|
||||
# Resolve the shared bootstrap beside the workflow so fixture checks use their
|
||||
# local action rather than the checkout's real action.
|
||||
SETUP_FLUTTER_GIT = WORKFLOW.parents[1] / "actions/setup-flutter-git/action.yml"
|
||||
text = WORKFLOW.read_text(encoding="utf-8")
|
||||
errors: list[str] = []
|
||||
@@ -168,9 +167,7 @@ require(bool(setup_flutter_git), "missing .github/actions/setup-flutter-git/acti
|
||||
for expected in (
|
||||
f'$version = "{FLUTTER_VERSION}"',
|
||||
f'$expectedCommit = "{FLUTTER_COMMIT}"',
|
||||
# Fetch the release tag rather than the bare commit: the commit is only
|
||||
# reachable through the tag, and the tag is what makes the SDK report its
|
||||
# own version. Both halves are then verified, so a moved tag fails the job.
|
||||
# Fetch and verify the release tag so moved tags cannot change the SDK.
|
||||
'git -C $root fetch --depth 1 origin "refs/tags/${version}:refs/tags/${version}"',
|
||||
'git -C $root checkout --detach "refs/tags/$version"',
|
||||
"$actualCommit = git -C $root rev-parse HEAD",
|
||||
@@ -336,10 +333,8 @@ require(
|
||||
"every Flutter SDK cache must define its trusted cache key",
|
||||
)
|
||||
|
||||
# check_workflow_action_pins.py owns the SHA-pin rule for every workflow, this
|
||||
# one included; build.yml only adds the credential invariant on top, because it
|
||||
# is workflow_dispatch-only and so escapes the pull-request rule in
|
||||
# check_workflow_security.py.
|
||||
# Action-pin checks run elsewhere; this guard adds the checkout credential
|
||||
# invariant for the workflow-dispatch-only build.
|
||||
remote_actions = [
|
||||
reference.rpartition("@")[0]
|
||||
for _, reference in iter_uses_references(text)
|
||||
|
||||
@@ -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*$")
|
||||
_EXCLUDED_ARGUMENT_RE = re.compile(r"(?:debugLabel|fontFamily)\s*:\s*$")
|
||||
_KEY_RE = re.compile(r"(?:\bKey|\bValueKey)\s*\(\s*$")
|
||||
# `createdLog:`/`log:` name a diagnostic sink, never rendered copy. Matched
|
||||
# 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.
|
||||
# `log:`/`createdLog:` are diagnostic sinks, including inside lambdas.
|
||||
_DIAGNOSTIC_RE = re.compile(
|
||||
r"(?:\bappLogger\.|\bSentry\.|\bassert\s*\(|\bthrow\b|(?:\blog|[a-z]Log)\s*:)"
|
||||
)
|
||||
@@ -52,17 +48,9 @@ _TRANSLATION_INTERPOLATION_RE = re.compile(
|
||||
r"\$\{\s*(?:t\.|context\.t\b|Translations\.of\s*\()"
|
||||
)
|
||||
_T_PARAMETER_RE = re.compile(r"(?:\(\s*t\s*\)|\bt)\s*=>[^;]*$")
|
||||
# Rule 4 support: a literal bound to a name or returned, rather than handed
|
||||
# straight to a widget. This is the shape issue #1856 actually had --
|
||||
# `baseButtonText = 'Skip Intro';` a few lines above the `Text(buttonText)`
|
||||
# that renders it -- which rules 1-3 structurally cannot see.
|
||||
#
|
||||
# Restricted to phrase-shaped literals: at least two letter-words separated by
|
||||
# whitespace. Without that restriction the rule cannot tell display copy from
|
||||
# the identifier strings this codebase binds constantly ('cast_row', 'auto',
|
||||
# 'liveTv', 'HDR_UNSUPPORTED'), and it drowns in false positives. The cost is
|
||||
# that a bound single-word label ('Software', 'Stereo') slips through; those
|
||||
# are indistinguishable from an identifier without dataflow analysis.
|
||||
# Rule 4 catches phrases assigned or returned before a widget renders them.
|
||||
# Restrict it to multi-word phrases to avoid confusing identifiers with UI copy;
|
||||
# single-word labels remain indistinguishable without dataflow analysis.
|
||||
_PHRASE_RE = re.compile(r"[A-Za-z]\s+[A-Za-z]")
|
||||
_BOUND_LITERAL_RE = re.compile(r"(?:\breturn|=>|(?<![=!<>+\-*/%&|^~])=)\s*$")
|
||||
_RENDERS_UI_RE = re.compile(
|
||||
@@ -326,7 +314,7 @@ def scan(
|
||||
seen_literals: dict[str, set[str]] = {}
|
||||
for path in sorted(lib_dir.rglob("*.dart")):
|
||||
relative = path.relative_to(root).as_posix()
|
||||
# lib/dev is a separate measurement entrypoint, explicitly "NOT part of the app".
|
||||
# lib/dev is a separate, non-app measurement entrypoint.
|
||||
if (
|
||||
relative.startswith(("lib/i18n/", "lib/dev/"))
|
||||
or path.name.endswith((".g.dart", ".freezed.dart"))
|
||||
|
||||
@@ -1,32 +1,9 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Guard the Linux package dependency lists against what the runner links.
|
||||
"""Guard distro dependencies for host libraries linked by the Linux runner.
|
||||
|
||||
linux/packaging/bundle-libs.sh deliberately refuses to bundle the display- and
|
||||
driver-coupled libraries (libEGL, libwayland-*, libGL, libdrm ...): they must
|
||||
come from the host or the app will not talk to the compositor it is running
|
||||
under. That makes them the package manager's problem, and the depends lists in
|
||||
linux/packaging/build-packages.py are maintained by hand.
|
||||
|
||||
Nothing connected the two. Adding a pkg-config link to the runner produced a
|
||||
binary with an undeclared shared-library dependency, and the failure surfaces
|
||||
only on a user's machine at exec time - a class of bug no compile or unit test
|
||||
can reach. This walks the runner's own link line instead:
|
||||
|
||||
target_link_libraries(${BINARY_NAME} PRIVATE PkgConfig::WAYLAND_EGL)
|
||||
-> pkg_check_modules(WAYLAND_EGL REQUIRED IMPORTED_TARGET wayland-egl)
|
||||
-> RUNTIME_PACKAGES["wayland-egl"] -> libwayland-egl1 / libwayland-egl / wayland
|
||||
|
||||
and requires every distro to declare it. A new pkg-config module fails here
|
||||
until its runtime package names are named for all three.
|
||||
|
||||
What it walks is exactly CMAKE_FILES, the three CMakeLists.txt this checkout
|
||||
owns - and nothing else. The Flutter plugins link into the same binary from
|
||||
linux/flutter/generated_plugins.cmake, whose add_subdirectory() targets live
|
||||
under flutter/ephemeral/.plugin_symlinks/, a directory that only exists after
|
||||
`flutter pub get`. A plugin's own pkg_check_modules is therefore unreadable at
|
||||
pull-request time, and a plugin that starts linking a new host library passes
|
||||
here. linux/packaging/check-bundle-host-deps.py is what covers that: it runs
|
||||
ldd over the built bundle, where every link edge is finally real.
|
||||
The runner's CMake link graph maps through pkg-config modules to the hand-maintained
|
||||
package lists. This catches undeclared runtime libraries before they fail on users'
|
||||
machines; the built-bundle check covers plugin links unavailable before `pub get`.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
@@ -43,31 +20,24 @@ LINUX = Path(sys.argv[1]).resolve() if len(sys.argv) == 2 else ROOT / "linux"
|
||||
RUNNER_CMAKE = LINUX / "runner/CMakeLists.txt"
|
||||
PACKAGES_PY = LINUX / "packaging/build-packages.py"
|
||||
BUNDLE_SH = LINUX / "packaging/bundle-libs.sh"
|
||||
# pkg_check_modules for targets the runner links may live in any of these.
|
||||
# Runner-linked pkg_check_modules may be declared in any of these files.
|
||||
CMAKE_FILES = (RUNNER_CMAKE, LINUX / "CMakeLists.txt", LINUX / "flutter/CMakeLists.txt")
|
||||
|
||||
# pkg-config modules whose library ships *inside* the package instead of being
|
||||
# depended on. libmpv is pinned and Wayland-enabled because the video plane needs
|
||||
# it to be; a distro libmpv silently drops hwdec to vaapi-copy. Bundling it means
|
||||
# there is deliberately no runtime dependency to find, so the walk must not
|
||||
# demand one - but the libraries it links that bundle-libs.sh excludes still have
|
||||
# to be declared, which the packaging job re-derives from the built bundle.
|
||||
# Bundled modules have no host dependency; their excluded runtime links are
|
||||
# checked separately against the built bundle.
|
||||
BUNDLED_MODULES = {"mpv"}
|
||||
|
||||
# pkg-config module -> the package that ships its runtime library, per distro.
|
||||
# Only modules the runner actually links are consulted, so an unused entry here
|
||||
# is harmless; a missing one is an error.
|
||||
# pkg-config module -> runtime package name for each distro.
|
||||
RUNTIME_PACKAGES = {
|
||||
"gtk+-3.0": {"deb": "libgtk-3-0", "rpm": "gtk3", "pacman": "gtk3"},
|
||||
"epoxy": {"deb": "libepoxy0", "rpm": "libepoxy", "pacman": "libepoxy"},
|
||||
# Reached through the `flutter` INTERFACE target rather than named by the
|
||||
# runner, which is why the graph has to cross file boundaries to see them.
|
||||
# Reached through the `flutter` INTERFACE target.
|
||||
"glib-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
|
||||
"gio-2.0": {"deb": "libglib2.0-0", "rpm": "glib2", "pacman": "glib2"},
|
||||
"wayland-client": {
|
||||
"deb": "libwayland-client0",
|
||||
"rpm": "libwayland-client",
|
||||
# Arch ships every libwayland-* in the one `wayland` package.
|
||||
# Arch ships all libwayland-* libraries in `wayland`.
|
||||
"pacman": "wayland",
|
||||
},
|
||||
"wayland-egl": {
|
||||
@@ -75,7 +45,7 @@ RUNTIME_PACKAGES = {
|
||||
"rpm": "libwayland-egl",
|
||||
"pacman": "wayland",
|
||||
},
|
||||
# libglvnd is the vendor-neutral dispatch that provides libEGL.so.1.
|
||||
# libglvnd provides libEGL.so.1.
|
||||
"egl": {"deb": "libegl1", "rpm": "libglvnd-egl", "pacman": "libglvnd"},
|
||||
}
|
||||
|
||||
@@ -95,9 +65,8 @@ def read(path: Path) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
# Keywords that carry no target name.
|
||||
LINK_KEYWORDS = {"PRIVATE", "PUBLIC", "INTERFACE", "optimized", "debug", "general"}
|
||||
# Options CMake accepts between IMPORTED_TARGET and the module names, in any order.
|
||||
# Link keywords and pkg_check_modules options are not module names.
|
||||
PKG_OPTIONS = ("REQUIRED", "QUIET", "GLOBAL", "NO_CMAKE_PATH", "NO_CMAKE_ENVIRONMENT_PATH")
|
||||
|
||||
|
||||
@@ -167,19 +136,11 @@ def pkgconfig_modules() -> dict[str, list[str]]:
|
||||
options = "|".join(PKG_OPTIONS)
|
||||
for path in CMAKE_FILES:
|
||||
for match in re.finditer(
|
||||
# The options may precede the module names, so skip any run of them
|
||||
# rather than taking the first token and reporting `REQUIRED` as a
|
||||
# package nobody ships. The tail is then every remaining token up to
|
||||
# the closing paren.
|
||||
# Skip options before module names.
|
||||
r"pkg_check_modules\(\s*(\w+)\b[^)]*?IMPORTED_TARGET\s+((?:(?:" + options + r")\s+)*[^)]*)\)",
|
||||
strip_comments(read(path)),
|
||||
):
|
||||
# A moduleSpec is `<name>` or `<name><op><version>`, so the version
|
||||
# constraint has to come off before the name is looked up - otherwise
|
||||
# a perfectly legal `mpv>=0.40` is reported as a package nobody
|
||||
# ships, and the message sends whoever hits it off to invent a
|
||||
# RUNTIME_PACKAGES entry for it. Pinning that minimum is a plausible
|
||||
# next edit here: target-colorspace-hint=auto needs mpv 0.40.
|
||||
# Strip version constraints before package lookup.
|
||||
names = [
|
||||
re.split(r"[<>=!]", t, maxsplit=1)[0] for t in match.group(2).split() if t not in PKG_OPTIONS
|
||||
]
|
||||
@@ -203,12 +164,8 @@ def declared_depends() -> dict[str, list[str]]:
|
||||
return {}
|
||||
|
||||
|
||||
# Every file, not just the runner's: `flutter` is defined in flutter/CMakeLists.txt
|
||||
# and propagates GTK, GLIB and GIO to whatever links it, so a graph built from one
|
||||
# file treats it as a leaf and never sees them. A member that moved or was renamed
|
||||
# is fatal rather than a smaller walk: its modules drop out of the graph, a
|
||||
# declaration deleted alongside it goes unreported, and every check below then
|
||||
# passes over a tree nobody actually looked at.
|
||||
# Include every owned CMake input; missing files fail closed instead of shrinking
|
||||
# the graph and silently passing.
|
||||
absent = [path for path in CMAKE_FILES if not path.is_file()]
|
||||
if absent:
|
||||
for path in absent:
|
||||
@@ -225,8 +182,7 @@ depends = declared_depends()
|
||||
|
||||
require(bool(depends), "no distro depends lists were found, so nothing was checked")
|
||||
|
||||
# The exclusion list is what makes declaring these mandatory rather than
|
||||
# optional. If bundling ever starts covering them, this guard is the wrong shape.
|
||||
# These libraries must remain host-provided; bundling them would invalidate this guard.
|
||||
bundle = read(BUNDLE_SH)
|
||||
for pattern in (r"libEGL\.so", r"libwayland.*\.so"):
|
||||
require(
|
||||
@@ -253,9 +209,7 @@ for target in sorted(linked):
|
||||
for module in target_modules:
|
||||
checked_modules += 1
|
||||
if module in BUNDLED_MODULES:
|
||||
# Shipped inside the package, so there is no dependency to find. Still
|
||||
# counted, so the summary keeps naming everything the walk reached and
|
||||
# a module going missing is a drop rather than a silent skip.
|
||||
# Bundled modules have no host dependency but remain part of the summary.
|
||||
continue
|
||||
packages = RUNTIME_PACKAGES.get(module)
|
||||
if packages is None:
|
||||
|
||||
@@ -24,11 +24,9 @@ PROGUARD_RULES = Path("android/app/proguard-rules.pro")
|
||||
APP_JAVA_ROOT = Path("android/app/src/main/java")
|
||||
CPP_ROOT = Path("android/app/src/main/cpp")
|
||||
NATIVE_SUFFIXES = {".c", ".cc", ".cpp", ".h", ".hpp"}
|
||||
# Namespaces the app borrows from a dependency purely so that dependency can reflect on
|
||||
# them. A class under one of these has no direct caller by construction.
|
||||
# Dependency namespaces reached only through reflection have no direct callers.
|
||||
REFLECTED_NAMESPACES = ("androidx/media3/",)
|
||||
# Framework types live on the bootclasspath, never in the app's dex, so R8 cannot rename
|
||||
# them and they need no keep.
|
||||
# Bootclasspath framework types are not in the app dex and need no keep rule.
|
||||
PLATFORM_PREFIXES = ("java.", "javax.", "android.")
|
||||
|
||||
_STRING_LITERAL = re.compile(r'"((?:[^"\\]|\\.)*)"')
|
||||
@@ -42,9 +40,7 @@ _MEMBER_LOOKUP = re.compile(
|
||||
+ r"\s*\)"
|
||||
)
|
||||
_DESCRIPTOR_CLASS = re.compile(r"L([\w/$]+);")
|
||||
# Only -keep and -keepclasseswithmembers protect a class from both shrinking and
|
||||
# renaming. -keepclassmembers/-keepclassmembernames cover members alone, and the
|
||||
# -keepnames family allows shrinking, so none of them save a class nothing references.
|
||||
# Only -keep variants without allowshrinking/allowobfuscation keep classes and names.
|
||||
_KEEP = re.compile(
|
||||
r"^-(?:keep|keepclasseswithmembers)((?:\s*,\s*\w+)*)\s+(?:class|interface|enum)\s+(\S+)"
|
||||
r"(?:\s*\{(.*?)\})?",
|
||||
@@ -62,7 +58,7 @@ class Keep:
|
||||
self.members = members
|
||||
self._regex = re.compile(
|
||||
"".join(
|
||||
# ** spans package separators, * does not, ? is a single character.
|
||||
# ** crosses package separators; * and ? match within a segment.
|
||||
{"**": r".*", "*": r"[^.]*", "?": r"."}.get(token, re.escape(token))
|
||||
for token in re.findall(r"\*\*|[*?]|[^*?]+", pattern)
|
||||
)
|
||||
|
||||
@@ -40,8 +40,7 @@ require(
|
||||
)
|
||||
iss = template()
|
||||
|
||||
# The script used to carry two near-identical copies of the whole .iss, one per
|
||||
# architecture shape. Anything that appears twice again has drifted apart.
|
||||
# A single template prevents architecture copies from drifting.
|
||||
for once in (
|
||||
r"^\[Setup\]$",
|
||||
r"^\[Code\]$",
|
||||
@@ -76,9 +75,7 @@ require(
|
||||
"the dual-architecture [Files] entries must keep their architecture checks",
|
||||
)
|
||||
|
||||
# A fresh install stays per-user and prompts for nothing; only an existing
|
||||
# machine-wide copy pulls in elevation, and only via /ALLUSERS, which Inno
|
||||
# ignores unless the commandline override is allowed.
|
||||
# Fresh installs stay per-user; only /ALLUSERS may trigger elevation.
|
||||
require(
|
||||
re.search(r"(?m)^PrivilegesRequired=lowest\s*$", iss) is not None,
|
||||
"a fresh install must stay per-user; PrivilegesRequired=lowest",
|
||||
@@ -93,7 +90,7 @@ require(
|
||||
"allowing dialog makes a silent install with no previous copy prompt; winget installs that way",
|
||||
)
|
||||
|
||||
# The elevation path itself.
|
||||
# Verify the elevation path.
|
||||
require(
|
||||
"IsAdminInstallMode" in iss,
|
||||
"the elevation path must be skipped once Setup already runs in administrative install mode",
|
||||
@@ -125,7 +122,7 @@ require(
|
||||
"a refused elevation must explain itself instead of failing silently",
|
||||
)
|
||||
|
||||
# Behavior other tooling already depends on.
|
||||
# Preserve behavior required by release tooling.
|
||||
require(
|
||||
"{param:WINGET|0}" in iss and "{app}\\.winget" in iss,
|
||||
"the winget marker file gates UpdateService.useNativeUpdater",
|
||||
|
||||
@@ -36,18 +36,14 @@ PUBSPEC = ROOT / "pubspec.yaml"
|
||||
WORKFLOW = ROOT / ".github/workflows/build.yml"
|
||||
MSIX_STEP = "Build Store package (MSIX)"
|
||||
BUNDLE = "plezy-windows.msixbundle"
|
||||
# The identity reserved in Partner Center. All three are pinned here because a
|
||||
# character of drift in any of them fails Store validation, and the first two
|
||||
# derive the package family name edde746.Plezy_13q3sv6jzathm that installed
|
||||
# copies are keyed by.
|
||||
# Partner Center reserves these identity values and derives the package family name.
|
||||
IDENTITY_NAME = "edde746.Plezy"
|
||||
PUBLISHER = "CN=AA9C53CB-AD3C-48DA-B3E3-D1E8986D4E25"
|
||||
PUBLISHER_DISPLAY_NAME = "edde746"
|
||||
PACKAGE_FAMILY_SUFFIX = "13q3sv6jzathm"
|
||||
FOUNDATION = "http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
ASSET_REFERENCE = re.compile(r"assets\\([A-Za-z0-9._-]+\.png)")
|
||||
# Package's child order is fixed by the foundation schema. A manifest may use a
|
||||
# subset of these, but never a different order.
|
||||
# Package child order is fixed by the foundation schema.
|
||||
SCHEMA_ORDER = (
|
||||
"Identity",
|
||||
"PhoneIdentity",
|
||||
@@ -68,12 +64,10 @@ REQUIRED_ELEMENTS = (
|
||||
"Applications",
|
||||
)
|
||||
REQUIRED_CAPABILITIES = ("runFullTrust", "internetClient", "privateNetworkClientServer")
|
||||
# Certification requires these three. The optional tile and splash assets are
|
||||
# only checked once referenced, since dropping them is a legitimate choice.
|
||||
# Certification requires these capabilities; optional assets are checked only when referenced.
|
||||
REQUIRED_ASSETS = ("StoreLogo.png", "Square150x150Logo.png", "Square44x44Logo.png")
|
||||
|
||||
# Normalized so that every pattern below can anchor on \n and $ regardless of
|
||||
# whether this checkout stores the PowerShell scripts with CRLF endings.
|
||||
# Normalize line endings so subsequent patterns are portable.
|
||||
text = SCRIPT.read_text(encoding="utf-8").replace("\r\n", "\n")
|
||||
errors: list[str] = []
|
||||
|
||||
@@ -144,8 +138,7 @@ require(
|
||||
prelude = declarations()
|
||||
manifest_template = template()
|
||||
|
||||
# build-installer.ps1 once carried one whole .iss per architecture shape and the
|
||||
# copies drifted apart. Anything that appears twice here has drifted too.
|
||||
# A single manifest template prevents architecture copies from drifting.
|
||||
for once in (
|
||||
r'^ return @"$',
|
||||
r"^<Package ",
|
||||
@@ -158,9 +151,7 @@ for once in (
|
||||
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
|
||||
# declares. Together they must cover the whole template, so an interpolation
|
||||
# added there has to be declared here before it can pass.
|
||||
# Caller parameters and declared identity values must cover every interpolation.
|
||||
for parameter in ("MsixVersion", "Architecture"):
|
||||
require(
|
||||
f"[Parameter(Mandatory)][string]${parameter}" in prelude,
|
||||
@@ -198,8 +189,7 @@ if package is not None:
|
||||
if identity is None:
|
||||
require(False, "the manifest must declare an Identity element")
|
||||
else:
|
||||
# Both patterns are schema constraints: makeappx refuses the package
|
||||
# before reading a single payload file when either is violated.
|
||||
# makeappx rejects either schema violation before reading payloads.
|
||||
require(
|
||||
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 "
|
||||
@@ -263,8 +253,7 @@ if package is not None:
|
||||
"a packaged Win32 app must enter through Windows.FullTrustApplication",
|
||||
)
|
||||
|
||||
# Assets are named in attributes (the tile logos) and in element text (the
|
||||
# Properties/Logo), so both are scanned.
|
||||
# Asset paths appear in attributes and element text.
|
||||
referenced = {
|
||||
match.group(1)
|
||||
for element in package.iter()
|
||||
@@ -280,9 +269,7 @@ if package is not None:
|
||||
"tree; packaging fails on a missing asset",
|
||||
)
|
||||
|
||||
# Independent of the manifest: Partner Center reports the package family name,
|
||||
# so recomputing it from the pinned publisher proves that string is byte-exact.
|
||||
# A mistyped publisher otherwise only surfaces as a rejected upload.
|
||||
# Recompute the Partner Center package-family suffix to catch publisher drift.
|
||||
require(
|
||||
package_family_suffix(PUBLISHER) == PACKAGE_FAMILY_SUFFIX,
|
||||
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",
|
||||
)
|
||||
|
||||
# Native exit codes are invisible to $ErrorActionPreference, so the one
|
||||
# invocation that checks $LASTEXITCODE has to be the only one.
|
||||
# PowerShell does not surface native exit codes; centralize the $LASTEXITCODE check.
|
||||
require(
|
||||
len(re.findall(r"(?m)^ & \$Tool @Arguments$", text)) == 1
|
||||
and "& $MakeAppx" not in text
|
||||
@@ -324,11 +310,7 @@ require(
|
||||
"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
|
||||
# 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.
|
||||
# Unplated variants prevent the shell's accent-colored plate; resources.pri resolves them.
|
||||
for form in ("altform-unplated", "altform-lightunplated"):
|
||||
require(
|
||||
any(ASSETS.glob(f"Square44x44Logo.targetsize-*_{form}.png")),
|
||||
@@ -351,8 +333,7 @@ require(
|
||||
"fails publisher-identity validation",
|
||||
)
|
||||
|
||||
# The version the manifest carries is passed in by the workflow, so the link
|
||||
# back to pubspec.yaml lives there rather than in the script.
|
||||
# The workflow supplies the manifest version from pubspec.yaml.
|
||||
workflow = WORKFLOW.read_text(encoding="utf-8")
|
||||
package_windows = job_block(workflow, "package-windows")
|
||||
require(bool(package_windows), "missing package-windows job")
|
||||
|
||||
+2
-16
@@ -1,12 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
set -uo pipefail
|
||||
|
||||
# Git sets GIT_DIR (and friends) for hook invocations. Inside `flutter pub
|
||||
# run`, that leaks into Flutter's own SDK-version probe (`git describe` from
|
||||
# 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.
|
||||
# Hook-invoked Flutter commands inherit GIT_* variables and can misreport the SDK
|
||||
# version; clear them so hooks and direct invocations behave identically.
|
||||
unset GIT_DIR GIT_INDEX_FILE GIT_WORK_TREE GIT_PREFIX
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
@@ -37,7 +33,6 @@ have_dart_code_linter() {
|
||||
|
||||
FAILED=0
|
||||
|
||||
# 1. dart format (mirrors ci.yml "Verify formatting")
|
||||
section "dart format"
|
||||
files=()
|
||||
while IFS= read -r -d '' f; do files+=("$f"); done < <(
|
||||
@@ -59,7 +54,6 @@ else
|
||||
rm -f "$out"
|
||||
fi
|
||||
|
||||
# 2. Codegen freshness
|
||||
section "codegen freshness"
|
||||
out="$(mktemp)"
|
||||
if scripts/codegen.sh --check >"$out" 2>&1; then
|
||||
@@ -71,7 +65,6 @@ else
|
||||
fi
|
||||
rm -f "$out"
|
||||
|
||||
# 3. Translation hygiene
|
||||
section "translation hygiene"
|
||||
if python3 scripts/clean_translations.py --check --strict; then
|
||||
ok "locale files normalized and no unused keys found"
|
||||
@@ -80,7 +73,6 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 4. Hardcoded user-facing strings
|
||||
section "hardcoded UI strings"
|
||||
if python3 scripts/check_hardcoded_strings.py; then
|
||||
ok "user-facing strings use the translation layer"
|
||||
@@ -89,7 +81,6 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 5. Workflow and script regression guards
|
||||
section "workflow and script guards"
|
||||
if bash scripts/ci_guard_checks.sh; then
|
||||
ok "workflow and script guards passed"
|
||||
@@ -98,7 +89,6 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 6. Icon consistency
|
||||
section "icon consistency"
|
||||
if dart run scripts/check_icon_consistency.dart; then
|
||||
ok "production icons use AppIcon and rounded Symbols"
|
||||
@@ -107,7 +97,6 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 7. Native formatting
|
||||
section "native format"
|
||||
out="$(mktemp)"
|
||||
if scripts/format_native.sh --check >"$out" 2>&1; then
|
||||
@@ -119,7 +108,6 @@ else
|
||||
fi
|
||||
rm -f "$out"
|
||||
|
||||
# 8. Dart analyzer (mirrors ci.yml "Analyze code")
|
||||
section "Dart analyzer"
|
||||
if dart run scripts/check_analyzer.dart; then
|
||||
ok "no unapproved diagnostics"
|
||||
@@ -128,7 +116,6 @@ else
|
||||
FAILED=1
|
||||
fi
|
||||
|
||||
# 9. Unused code (mirrors ci.yml "Check for unused code")
|
||||
section "dart_code_linter: unused code"
|
||||
if ! have_dart_code_linter; then
|
||||
skip "dart_code_linter unresolved — run 'flutter pub get'"
|
||||
@@ -145,7 +132,6 @@ else
|
||||
rm -f "$out"
|
||||
fi
|
||||
|
||||
# 10. Unused files (mirrors ci.yml "Check for unused files")
|
||||
section "dart_code_linter: unused files"
|
||||
if ! have_dart_code_linter; then
|
||||
skip "dart_code_linter unresolved — run 'flutter pub get'"
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
#!/usr/bin/env bash
|
||||
# Workflow and script regression guards.
|
||||
#
|
||||
# Single source of truth for the guard roster, shared by the "Verify workflow
|
||||
# and script guards" step in .github/workflows/ci.yml and section 4 of
|
||||
# scripts/ci_checks.sh. The checkers are named explicitly because a few of them
|
||||
# belong to other jobs (check_bun_audit.py needs Bun, check_codegen.py runs via
|
||||
# codegen.sh, and linux/packaging/check-bundle-host-deps.py and
|
||||
# check-package-deps.py need a built bundle and built packages, so both run in
|
||||
# the linux-packages job and again in the release build), but their regression
|
||||
# tests are discovered by glob so a newly added scripts/test_*.py is picked up
|
||||
# automatically instead of having to be remembered in two places.
|
||||
# Single source of truth for workflow/script guards, shared by CI and
|
||||
# scripts/ci_checks.sh. Regression tests use a glob so new test_checkers are
|
||||
# picked up automatically; checkers requiring Bun or built packages run in
|
||||
# their owning jobs as well.
|
||||
set -euo pipefail
|
||||
shopt -s nullglob
|
||||
|
||||
|
||||
@@ -1,23 +1,18 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
# Android Icon Generator Script
|
||||
# Generates notification icons and monochrome launcher icons from SVG source
|
||||
# Usage: ./generate_android_icons.sh
|
||||
# Generate Android notification and monochrome icons from assets/plezy.svg.
|
||||
|
||||
set -e
|
||||
|
||||
# Configuration
|
||||
SVG_SOURCE="assets/plezy.svg"
|
||||
ANDROID_RES="android/app/src/main/res"
|
||||
TEMP_DIR="/tmp/android_icons_$$"
|
||||
|
||||
# Check if source SVG exists
|
||||
if [ ! -f "$SVG_SOURCE" ]; then
|
||||
echo "Error: $SVG_SOURCE not found"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Check for required tools
|
||||
if ! command -v rsvg-convert &> /dev/null; then
|
||||
echo "Error: rsvg-convert not found. Install with: brew install librsvg"
|
||||
exit 1
|
||||
@@ -37,27 +32,24 @@ if ! command -v bc &> /dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Create temp directory
|
||||
mkdir -p "$TEMP_DIR"
|
||||
|
||||
echo "🎨 Generating Android icons from $SVG_SOURCE..."
|
||||
|
||||
# Function to generate white silhouette icon
|
||||
generate_white_icon() {
|
||||
local size=$1
|
||||
local output=$2
|
||||
|
||||
# Get SVG dimensions to detect if it's non-square
|
||||
# Read dimensions so non-square artwork can be padded.
|
||||
local svg_viewbox=$(grep -o 'viewBox="[^"]*"' "$SVG_SOURCE" | sed 's/viewBox="//;s/"//')
|
||||
local svg_width=$(echo "$svg_viewbox" | awk '{print $3}')
|
||||
local svg_height=$(echo "$svg_viewbox" | awk '{print $4}')
|
||||
|
||||
# Convert SVG to PNG, preserving aspect ratio
|
||||
# Preserve the source aspect ratio.
|
||||
if (( $(echo "$svg_width != $svg_height" | bc -l) )); then
|
||||
# Non-square SVG: render at size and add padding to center it
|
||||
# Center non-square artwork on a transparent square canvas.
|
||||
rsvg-convert --keep-aspect-ratio --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp_unpadded.png"
|
||||
|
||||
# Center the image in a square canvas with transparent padding
|
||||
"$IMAGEMAGICK" "$TEMP_DIR/temp_unpadded.png" \
|
||||
-resize "${size}x${size}" \
|
||||
-gravity center \
|
||||
@@ -65,11 +57,10 @@ generate_white_icon() {
|
||||
-extent "${size}x${size}" \
|
||||
"$TEMP_DIR/temp.png"
|
||||
else
|
||||
# Square SVG: convert directly
|
||||
rsvg-convert -w "$size" -h "$size" --background-color=transparent "$SVG_SOURCE" -o "$TEMP_DIR/temp.png"
|
||||
fi
|
||||
|
||||
# Convert to white silhouette: extract alpha, fill with white, apply alpha
|
||||
# Convert the source alpha channel to a white silhouette.
|
||||
"$IMAGEMAGICK" "$TEMP_DIR/temp.png" \
|
||||
-alpha extract \
|
||||
"$TEMP_DIR/alpha_mask.png"
|
||||
@@ -85,12 +76,9 @@ generate_white_icon() {
|
||||
echo " ✓ Generated $(basename $output) (${size}x${size}px)"
|
||||
}
|
||||
|
||||
# Generate Notification Icons (24dp base)
|
||||
echo ""
|
||||
echo "📱 Generating notification icons (ic_stat_notification.png)..."
|
||||
|
||||
# Notification icon densities: 24dp base
|
||||
# mdpi=1x, hdpi=1.5x, xhdpi=2x, xxhdpi=3x, xxxhdpi=4x
|
||||
declare -A NOTIF_SIZES=(
|
||||
["mdpi"]=24
|
||||
["hdpi"]=36
|
||||
@@ -106,12 +94,9 @@ for density in "${!NOTIF_SIZES[@]}"; do
|
||||
generate_white_icon "$size" "$output_dir/ic_stat_notification.png"
|
||||
done
|
||||
|
||||
# Generate Monochrome Launcher Icons (108dp base)
|
||||
echo ""
|
||||
echo "🚀 Generating monochrome launcher icons (ic_launcher_monochrome.png)..."
|
||||
|
||||
# Monochrome launcher icon densities: 108dp base
|
||||
# mdpi=1x, hdpi=1.5x, xhdpi=2x, xxhdpi=3x, xxxhdpi=4x
|
||||
declare -A MONO_SIZES=(
|
||||
["mdpi"]=108
|
||||
["hdpi"]=162
|
||||
@@ -127,7 +112,6 @@ for density in "${!MONO_SIZES[@]}"; do
|
||||
generate_white_icon "$size" "$output_dir/ic_launcher_monochrome.png"
|
||||
done
|
||||
|
||||
# Clean up
|
||||
rm -rf "$TEMP_DIR"
|
||||
|
||||
echo ""
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# Windows MSIX Asset Generator
|
||||
# Renders the tile, store and splash PNGs the Store manifest references from
|
||||
# assets/plezy.png, for windows/msix/assets. The results are committed and
|
||||
# windows/build-msix.ps1 only copies them, so packaging stays runnable without
|
||||
# image tooling; regenerating is only needed when the app icon changes.
|
||||
# Generate committed MSIX assets from assets/plezy.png.
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
@@ -16,9 +12,7 @@ Add-Type -AssemblyName System.Drawing
|
||||
$Source = Resolve-Path "assets\plezy.png"
|
||||
$OutputDir = Join-Path $ProjectRoot "windows\msix\assets"
|
||||
|
||||
# Sizes and names come from the uap:VisualElements attributes in the manifest
|
||||
# template; a rename here has to be made there too. The two non-square targets
|
||||
# letterbox the square icon on transparent padding rather than stretching it.
|
||||
# Names and dimensions mirror the manifest's VisualElements.
|
||||
$Targets = @(
|
||||
@{ Name = "Square44x44Logo.png"; Width = 44; Height = 44 }
|
||||
@{ Name = "Square150x150Logo.png"; Width = 150; Height = 150 }
|
||||
@@ -26,25 +20,19 @@ $Targets = @(
|
||||
@{ Name = "Wide310x150Logo.png"; Width = 310; Height = 150 }
|
||||
@{ Name = "Square310x310Logo.png"; Width = 310; Height = 310 }
|
||||
@{ Name = "SplashScreen.png"; Width = 620; Height = 300 }
|
||||
# The base logos are scale-100. Without a 200 the shell upscales them on the
|
||||
# high-DPI displays most laptops ship with.
|
||||
# Scale-200 variants avoid shell upscaling on high-DPI displays.
|
||||
@{ Name = "Square44x44Logo.scale-200.png"; Width = 88; Height = 88 }
|
||||
@{ Name = "Square150x150Logo.scale-200.png"; Width = 300; Height = 300 }
|
||||
)
|
||||
|
||||
# The taskbar, task view and Alt-Tab draw the small logo on a plate filled with
|
||||
# BackgroundColor, and a transparent background leaves the shell painting the
|
||||
# user's accent colour behind the icon. An altform-unplated variant is the only
|
||||
# way to suppress that plate; lightunplated is its light-theme counterpart.
|
||||
# These resolve through resources.pri, which windows/build-msix.ps1 builds - as
|
||||
# plain payload files they are inert.
|
||||
# Resources.pri resolves these unplated variants; plain payload files are inert.
|
||||
foreach ($Size in 16, 24, 32, 48, 256) {
|
||||
foreach ($Form in "", "_altform-unplated", "_altform-lightunplated") {
|
||||
$Targets += @{ Name = "Square44x44Logo.targetsize-${Size}${Form}.png"; Width = $Size; Height = $Size }
|
||||
}
|
||||
}
|
||||
|
||||
# Regenerate from scratch so a renamed target cannot leave an orphan behind.
|
||||
# Remove stale outputs when targets are renamed.
|
||||
if (Test-Path $OutputDir) { Remove-Item (Join-Path $OutputDir "*.png") -Force }
|
||||
New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null
|
||||
|
||||
@@ -58,9 +46,7 @@ try {
|
||||
try {
|
||||
$Graphics = [System.Drawing.Graphics]::FromImage($Canvas)
|
||||
try {
|
||||
# SourceCopy keeps the icon's own alpha instead of blending it
|
||||
# into the canvas; the manifest declares a transparent tile
|
||||
# background and expects the padding to stay transparent.
|
||||
# Preserve alpha for the manifest's transparent tile background.
|
||||
$Graphics.CompositingMode = [System.Drawing.Drawing2D.CompositingMode]::SourceCopy
|
||||
$Graphics.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic
|
||||
$Graphics.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality
|
||||
|
||||
@@ -578,12 +578,8 @@ class MaestroRunner:
|
||||
check=False,
|
||||
quiet=True,
|
||||
)
|
||||
# Maestro waits for the view hierarchy to settle after every tap, input,
|
||||
# and key press. With animations at their default 1.0 scale each of
|
||||
# those waits pays for a real transition, which dominates a flow: taps
|
||||
# measured 3-5s apiece on a physical Pixel 7. CI's emulator gets this
|
||||
# from the runner's disable-animations flag; nothing was setting it for
|
||||
# a real device. cleanup() restores the captured values.
|
||||
# Disable animations to avoid Maestro's per-action transition waits;
|
||||
# cleanup() restores the captured settings.
|
||||
for key in ANIMATION_SCALES:
|
||||
self._adb_run("shell", "settings", "put", "global", key, "0", check=False, quiet=True)
|
||||
self._adb_run("shell", "input", "keyevent", "KEYCODE_BACK", check=False, quiet=True)
|
||||
@@ -621,7 +617,7 @@ class MaestroRunner:
|
||||
) as response:
|
||||
output.write(response.read().decode(errors="replace"))
|
||||
output.write("\n")
|
||||
except Exception as error: # Diagnostics must not hide the original failure.
|
||||
except Exception as error: # Keep diagnostics from masking the failure.
|
||||
output.write(f"health_error={error}\n")
|
||||
|
||||
if self.container_name:
|
||||
|
||||
@@ -15,9 +15,8 @@ ANDROID_15_INSTRUMENTATION_CLASSES = (
|
||||
"com.edde746.plezy.exoplayer.PlezyAudioModePlaybackTest"
|
||||
)
|
||||
ANDROID_15_INSTRUMENTATION_TARGET = "android-15-instrumentation"
|
||||
# Kept separate from the suites above: only one build type can host androidTest, and
|
||||
# those suites drive media3 builder APIs the app itself never calls, which R8 shrinks
|
||||
# legitimately. This class asserts only name-based reachability (#1703).
|
||||
# Separate R8 reachability from instrumentation: these suites exercise APIs the app
|
||||
# does not call directly, so shrinking them is expected. Covers name-based reachability (#1703).
|
||||
ANDROID_R8_REACHABILITY_CLASSES = "androidx.media3.decoder.ffmpeg.FfmpegDecoderReachabilityTest"
|
||||
ANDROID_R8_REACHABILITY_TARGET = "android-r8-reachability"
|
||||
|
||||
@@ -126,9 +125,7 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
|
||||
"android-9": (
|
||||
(
|
||||
"basic",
|
||||
# API 28's emulator routing to the 10.0.2.2 host alias is unreliable
|
||||
# on this image, so reach Jellyfin over an adb reverse mapping the
|
||||
# way the media suite already does.
|
||||
# API 28 uses adb reverse because 10.0.2.2 routing is unreliable here.
|
||||
"--adb-reverse",
|
||||
"--flow",
|
||||
".maestro/flows/05_playback.yaml",
|
||||
@@ -138,13 +135,7 @@ GROUPS: dict[str, tuple[tuple[str, ...], ...]] = {
|
||||
"build/maestro-legacy/diagnostics",
|
||||
),
|
||||
),
|
||||
# Real Android TV hardware only, so no workflow dispatches it. The three
|
||||
# `tv` regressions above run on a phone emulator that `onboard_jellyfin_tv`
|
||||
# forces into TV mode; this drives the rail layout a device reports on its
|
||||
# own. The D-pad-only path is also the only way to reach the TV number
|
||||
# spinner, which InputModeTracker hides as soon as a tap arrives. Run as
|
||||
# `python3 scripts/run_maestro_ci.py android-tv-device` with
|
||||
# MAESTRO_DEVICE_ID set to the box.
|
||||
# Manual Android TV hardware target; workflow runs phone-emulator TV regressions.
|
||||
"android-tv-device": (
|
||||
(
|
||||
"basic",
|
||||
@@ -201,12 +192,8 @@ def run_android_15_instrumentation() -> None:
|
||||
|
||||
def run_android_r8_reachability() -> None:
|
||||
print("==> Android R8 reachability", flush=True)
|
||||
# The `minified` build type runs R8 over the app under test, so a keep rule that stops
|
||||
# covering a reflective lookup, a JNI callback or a native library load fails here
|
||||
# instead of shipping. No other gate in this repository runs R8 at all.
|
||||
#
|
||||
# compileFlutterBuildMinified is deliberately not excluded: CI only prebuilds the
|
||||
# debug APK, so this variant has no Flutter outputs to reuse.
|
||||
# Run minified instrumentation to catch reflective, JNI, and native-load keep
|
||||
# regressions. Do not exclude the Flutter build; CI only prebuilds debug.
|
||||
run_maestro._run_checked(
|
||||
(
|
||||
"android/gradlew",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user