chore: clean up code comments

This commit is contained in:
edde746
2026-08-10 20:28:41 +02:00
parent 5611c6785a
commit 69fadc220d
170 changed files with 324 additions and 1765 deletions
-2
View File
@@ -271,13 +271,11 @@ jobs:
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
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
View File
@@ -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:
+4 -14
View File
@@ -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
+2 -4
View File
@@ -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
+2 -4
View File
@@ -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
+2 -6
View File
@@ -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
+4 -5
View File
@@ -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
-2
View File
@@ -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
-13
View File
@@ -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() {
-3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -5
View File
@@ -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
-3
View File
@@ -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,
-1
View File
@@ -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(
-2
View File
@@ -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) {
-3
View File
@@ -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;
}
-1
View File
@@ -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(
+3 -22
View File
@@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox {
/// A wrapper widget that makes its child focusable with D-pad navigation support.
///
/// 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;
}
-2
View File
@@ -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);
}
-13
View File
@@ -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;
-3
View File
@@ -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;
-1
View File
@@ -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;
-11
View File
@@ -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,
-6
View File
@@ -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;
+3 -14
View File
@@ -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(
-2
View File
@@ -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;
-8
View File
@@ -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) {
-5
View File
@@ -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;
-2
View File
@@ -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);
-1
View File
@@ -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;
-33
View File
@@ -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,
-1
View File
@@ -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;
-8
View File
@@ -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),
+1 -1
View File
@@ -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(
-5
View File
@@ -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})
-3
View File
@@ -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
-2
View File
@@ -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
-5
View File
@@ -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",
+2 -16
View File
@@ -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)
-8
View File
@@ -1,14 +1,6 @@
// Application-level settings for the Runner target.
//
// This may be replaced with something auto-generated from metadata (e.g., pubspec.yaml) in the
// future. If not, the values below would default to using the project name when this becomes a
// 'flutter create' template.
// The application's name. By default this is also the title of the Flutter window.
PRODUCT_NAME = Plezy
// The application's bundle identifier
PRODUCT_BUNDLE_IDENTIFIER = com.edde746.plezy
// The copyright displayed in application information
PRODUCT_COPYRIGHT = Copyright © 2025 edde746
+1 -7
View File
@@ -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()
+1 -17
View File
@@ -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()
}
-4
View File
@@ -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 -12
View File
@@ -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)
@@ -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
View File
@@ -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:
+5 -10
View File
@@ -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)
+5 -17
View File
@@ -40,11 +40,7 @@ _UI_ARGUMENT_RE = re.compile(r"(?<![A-Za-z0-9_])(?:" + "|".join(_UI_ARGUMENTS) +
_TEXT_RE = re.compile(r"(?:\bText|\bSelectableText)\s*\(\s*$")
_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"))
+18 -64
View File
@@ -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:
+4 -8
View File
@@ -24,11 +24,9 @@ PROGUARD_RULES = Path("android/app/proguard-rules.pro")
APP_JAVA_ROOT = Path("android/app/src/main/java")
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)
)
+4 -7
View File
@@ -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",
+12 -31
View File
@@ -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
View File
@@ -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'"
+4 -11
View File
@@ -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
+5 -21
View File
@@ -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 ""
+6 -20
View File
@@ -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
+3 -7
View File
@@ -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:
+6 -19
View File
@@ -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