Compare commits
10
Commits
01279e5fbb
...
26e98e898d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
26e98e898d | ||
|
|
6663353895 | ||
|
|
83c50d93a2 | ||
|
|
3a704a2b9b | ||
|
|
aff6b6576f | ||
|
|
d19ec625cd | ||
|
|
c2bd1d28fd | ||
|
|
ea356a6112 | ||
|
|
69fadc220d | ||
|
|
5611c6785a |
@@ -271,13 +271,11 @@ jobs:
|
||||
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||
run: |
|
||||
# Create temporary keychain
|
||||
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
|
||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||
|
||||
# Import certificate to keychain
|
||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||
|
||||
+1
-3
@@ -18,9 +18,7 @@ migrate_working_dir/
|
||||
*.iws
|
||||
.idea/
|
||||
|
||||
# The .vscode folder contains launch configuration and tasks you configure in
|
||||
# VS Code which you may wish to be included in version control, so this line
|
||||
# is commented out by default.
|
||||
# Keep .vscode/ available for contributors' local configuration.
|
||||
#.vscode/
|
||||
|
||||
# Flutter/Dart/Pub related
|
||||
|
||||
@@ -22,8 +22,8 @@ tags:
|
||||
- pressKey: "Remote Dpad Up"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- pressKey: "Remote Dpad Right"
|
||||
# The grid row must be matchable before the sort sheet opens, otherwise the
|
||||
# occlusion assertion below would pass for the wrong reason.
|
||||
# Wait for the row before opening the sheet so occlusion is tested against the
|
||||
# grid, not an absent or stale match.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*Alpha Archive.*"
|
||||
timeout: 15000
|
||||
@@ -31,9 +31,8 @@ tags:
|
||||
- extendedWaitUntil:
|
||||
visible: "Title"
|
||||
timeout: 10000
|
||||
# The host renders the grid, the barrier, and the sheet in one Stack and never
|
||||
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
|
||||
# an occluded row still read as visible to accessibility and to Maestro.
|
||||
# The grid remains mounted under the Stack; BlockSemantics is required for
|
||||
# occluded rows to disappear from Maestro's accessibility tree.
|
||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- pressKey: "Remote Dpad Left"
|
||||
|
||||
@@ -16,13 +16,9 @@ tags:
|
||||
visible: "(?s)^(Play|Resume).*$"
|
||||
commands:
|
||||
- pressKey: "Remote Dpad Center"
|
||||
# The TV player opens with its chrome down so the OSD and timebar never sit
|
||||
# over the opening seconds of the picture (#1765), so the Pause button can no
|
||||
# longer stand in for "the player has finished loading". Wait for the detail
|
||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
||||
# player route owns the frame, and it only clears once playback reports its
|
||||
# first frame or gives up, so this can never advance before the media opens.
|
||||
# A give-up lands on the transport assertions below, which is where it belongs.
|
||||
# TV playback starts with chrome hidden (#1765), so wait for its loading label
|
||||
# rather than treating the Pause button as readiness. A failed open reaches the
|
||||
# transport assertions below.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "Overview"
|
||||
timeout: 30000
|
||||
@@ -30,18 +26,14 @@ tags:
|
||||
notVisible: "Loading video"
|
||||
timeout: 30000
|
||||
- assertNotVisible: "(?s)^(Play|Pause)$"
|
||||
# Hardware transport keys drive playback without raising the chrome (#1676):
|
||||
# the player answers with a centred disc (pause) or a side readout (seek), never the
|
||||
# full chrome, so subtitles stay readable.
|
||||
# Pause first — it also freezes the clip, so the seek assertions below cannot
|
||||
# race the end of a short fixture item.
|
||||
# Transport keys keep chrome hidden (#1676); pause first so seeks cannot race
|
||||
# the end of this short fixture.
|
||||
- pressKey: "Remote Media Play Pause"
|
||||
- extendedWaitUntil:
|
||||
visible: "Paused"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Play$"
|
||||
# D-pad seeking reports through the skip badge, never the scrub bar, and
|
||||
# consecutive presses in one direction stack into a running total.
|
||||
# D-pad seeks use the cumulative skip badge, not the scrub bar.
|
||||
- pressKey: "Remote Dpad Right"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Seek forward 10 seconds$"
|
||||
@@ -56,7 +48,7 @@ tags:
|
||||
visible: "Playing"
|
||||
timeout: 10000
|
||||
- assertNotVisible: "(?s)^Pause$"
|
||||
# Select stays the deliberate way to bring the chrome back.
|
||||
# Select is the explicit way to restore the chrome.
|
||||
- pressKey: "Remote Dpad Center"
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(Play|Pause)$"
|
||||
|
||||
@@ -7,10 +7,8 @@ tags:
|
||||
- playback
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||
# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A
|
||||
# tap here flips InputModeTracker into pointer mode, which collapses the rail
|
||||
# to icons and leaves no "Search" label to hit — and a percentage coordinate
|
||||
# does not survive the jump from a phone in forced TV mode to a 4K television.
|
||||
# Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label,
|
||||
# while percentage coordinates are not portable across TV sizes.
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
visible: "Search"
|
||||
@@ -38,11 +36,8 @@ tags:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 5000
|
||||
- tapOn: "(?s)^Play S1E1$"
|
||||
# The TV player opens with its chrome down (#1765), so the Pause button can no
|
||||
# longer stand in for "the player has finished loading". Wait for the detail
|
||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
||||
# player route owns the frame, and it only clears once playback reports its
|
||||
# first frame or gives up, so this can never advance before the media opens.
|
||||
# The player starts with chrome hidden (#1765); wait for its loading label
|
||||
# rather than using Pause as readiness.
|
||||
- extendedWaitUntil:
|
||||
notVisible: "(?s)^Play S1E1$"
|
||||
timeout: 30000
|
||||
@@ -51,9 +46,7 @@ tags:
|
||||
timeout: 30000
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
- pressKey: "Remote Media Fast Forward"
|
||||
# Gate on the prompt's own Cancel action, the label the iOS branch below taps.
|
||||
# "Next Episode" is the credits skip button at this point in the episode, so
|
||||
# waiting on it would let Back fire before the prompt ever opened.
|
||||
# Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Cancel$"
|
||||
timeout: 20000
|
||||
|
||||
@@ -8,10 +8,8 @@ tags:
|
||||
---
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- tapOn: "(?s)^Libraries.*"
|
||||
# Every seeded alphabet title carries the same dateadded, so which of them the
|
||||
# "Recently Added" rail returns is not stable across scans. This flow only
|
||||
# needs some playable movie, so accept any of them rather than pinning one and
|
||||
# failing whenever the tie-break lands elsewhere.
|
||||
# All seeded titles share dateadded, so accept any playable Recently Added movie
|
||||
# instead of relying on an unstable tie-break.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||
timeout: 15000
|
||||
|
||||
@@ -7,20 +7,9 @@ tags:
|
||||
- settings
|
||||
- sheets
|
||||
---
|
||||
# Guards 8e1904dd and c48cbf70 together.
|
||||
#
|
||||
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
|
||||
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
|
||||
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
|
||||
# answered the platform pop with a TV-only dedup marker that could already be
|
||||
# spent, stranding the sheet open with no way out. Either defect fails the
|
||||
# "Manage Libraries is still there after one Back" assertion below.
|
||||
#
|
||||
# The same commit blocks semantics behind the barrier, so the occlusion
|
||||
# assertions pin that an open sheet actually hides the rows underneath.
|
||||
# Pristine state on purpose. The TV regressions in the same CI group leave
|
||||
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
|
||||
# so it must not inherit whichever layout the previous flow left behind.
|
||||
# Guards 8e1904dd and c48cbf70: phone Settings must host the sheet so one Back
|
||||
# dismisses only the sheet, while BlockSemantics hides rows behind the barrier.
|
||||
# Start clean because preceding TV flows may leave Force TV mode enabled.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
@@ -29,10 +18,8 @@ tags:
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
timeout: 15000
|
||||
# The barrier swallows every pointer event, so the rows behind it must leave
|
||||
# the semantics tree too. Without BlockSemantics an occluded row still reads as
|
||||
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
|
||||
# excluded on purpose: it is also the sheet's own title.
|
||||
# The barrier removes covered rows from semantics; the sheet title remains
|
||||
# visible because it belongs to the sheet.
|
||||
- assertNotVisible: "(?s)^Services.*"
|
||||
- assertNotVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "(?s)^Appearance.*"
|
||||
@@ -41,15 +28,13 @@ tags:
|
||||
- waitForAnimationToEnd:
|
||||
timeout: 3000
|
||||
- assertNotVisible: "Maestro Movies"
|
||||
# One Back closed only the sheet: Settings is still the current route.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^Manage Libraries.*"
|
||||
timeout: 10000
|
||||
- assertVisible: "(?s)^Services.*"
|
||||
- assertVisible: "(?s)^Video Playback.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Reopening proves the host did not strand the one-shot dedup marker: a second
|
||||
# dismissal has to work exactly like the first.
|
||||
# Reopening verifies the one-shot pop deduplication marker was not stranded.
|
||||
- tapOn: "(?s)^Manage Libraries.*"
|
||||
- extendedWaitUntil:
|
||||
visible: "Maestro Movies"
|
||||
|
||||
@@ -7,20 +7,10 @@ tags:
|
||||
- playback
|
||||
- subtitles
|
||||
---
|
||||
# Guards 468d6804. When a source advertises tracks the native list has not
|
||||
# produced yet, TrackManager arms an automatic selection pass with a 5s first
|
||||
# attempt and a 25s deadline. A user pick made inside that window used to only
|
||||
# persist the preference, leaving the pass armed, so the choice snapped back to
|
||||
# the server/profile preferred track up to ~30s later. The pick now retires the
|
||||
# pending pass, and the wait below is long enough to outlive both timers.
|
||||
#
|
||||
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
|
||||
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
|
||||
# streams, so both picks below are genuine changes away from the automatic one.
|
||||
# Pristine state on purpose, for two reasons: the TV regressions in the same
|
||||
# CI group leave "Force TV mode" enabled, and a remembered track selection
|
||||
# from an earlier run would pre-select the very rows this flow picks, making
|
||||
# every assertion below vacuous.
|
||||
# Guards 468d6804: a user pick must retire TrackManager's pending automatic
|
||||
# selection pass, or the server preference can overwrite it up to 30s later.
|
||||
# The codec fixture supplies non-default audio and subtitle choices, and clean
|
||||
# state prevents remembered selections from making the assertions vacuous.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow:
|
||||
file: ../subflows/open_codec_sample.yaml
|
||||
|
||||
@@ -7,16 +7,9 @@ tags:
|
||||
- settings
|
||||
- i18n
|
||||
---
|
||||
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
|
||||
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
|
||||
# locale whose JSON never reached the generator ships a picker entry that falls
|
||||
# back to English. Selecting Turkish and reading root-level navigation labels
|
||||
# proves the generated locale is wired end to end.
|
||||
#
|
||||
# This flow mutates a persisted pref, and the app language decides every
|
||||
# selector after the tap, so it starts from a cleared install rather than
|
||||
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
|
||||
# hunting for English strings in a Turkish tree.
|
||||
# Guards 7677d159 and 100d7729: selecting Turkish and reading root navigation
|
||||
# labels proves the generated locale is wired end to end. Start clean because
|
||||
# the language preference persists and affects every later selector.
|
||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||
- runFlow: ../subflows/open_settings.yaml
|
||||
- tapOn: "(?s)^Appearance.*"
|
||||
@@ -54,9 +47,7 @@ tags:
|
||||
- assertVisible: "Keşfet"
|
||||
- assertNotVisible: "(?s)^Libraries.*"
|
||||
- assertNotVisible: "Discover"
|
||||
# Walk back through the Turkish tree and restore English. The pref outlives a
|
||||
# plain launchApp, so leaving the device in Turkish would break every later
|
||||
# flow in the suite.
|
||||
# Restore English before leaving the device; the locale preference outlives launchApp.
|
||||
- repeat:
|
||||
times: 3
|
||||
while:
|
||||
|
||||
@@ -7,21 +7,11 @@ tags:
|
||||
- tv
|
||||
- settings
|
||||
---
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
|
||||
# settings navigation generally.
|
||||
#
|
||||
# It deliberately does NOT guard the 15b54e2e density change. That commit took
|
||||
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
|
||||
# the semantics tree either way, so this flow passes identically on both sides
|
||||
# of it — measured, not assumed. A real guard would need a pixel `height`
|
||||
# assertion, and Maestro has no DPR-relative form, so pinning one here would
|
||||
# only hold for the box it was written on. Row geometry belongs to
|
||||
# test/widgets/setting_tile_test.dart.
|
||||
#
|
||||
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
|
||||
# mode on the first touch event and the TV number spinner only builds in
|
||||
# keyboard mode, so a single tapOn anywhere above would silently swap the
|
||||
# spinner for a plain text field.
|
||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e.
|
||||
# This flow intentionally does not pin 15b54e2e's row density: semantics stay
|
||||
# stable across that change, and Maestro has no DPR-relative height assertion.
|
||||
# Use D-pad only; a tap switches InputModeTracker to pointer mode and removes
|
||||
# the TV number spinner.
|
||||
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
||||
- pressKey: "Remote Dpad Left"
|
||||
- extendedWaitUntil:
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach a signed-in Home without paying for onboarding that already happened.
|
||||
#
|
||||
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
|
||||
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
|
||||
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
|
||||
# data and cold-starts straight back to Home, which measures ~16s.
|
||||
#
|
||||
# The guard keeps each flow runnable on its own: when no session is stored --
|
||||
# a fresh install, or a run after the logout flow -- "Discover" is absent and
|
||||
# this falls through to the real onboarding. Flows that must prove onboarding
|
||||
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
|
||||
# Reach a signed-in Home without repeating onboarding. A plain launch preserves
|
||||
# stored state (~16s versus ~59s for retyping credentials); fall back to full
|
||||
# onboarding when no session exists so each flow remains runnable alone.
|
||||
- launchApp
|
||||
# Settle on a known screen first. Branching straight off `launchApp` races the
|
||||
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
|
||||
# every flow pays for a full re-onboarding it did not need.
|
||||
# Wait for the splash to resolve before deciding whether onboarding is needed.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
|
||||
@@ -10,10 +10,8 @@ appId: com.edde746.plezy
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||
timeout: 30000
|
||||
# A miss on `tapOn ... optional: true` still runs Maestro's full element
|
||||
# search before giving up, which measured 3s here and 7.8s for "Sign in"
|
||||
# on every onboarding. These taps normally find nothing, so gate them on
|
||||
# a single visibility check instead.
|
||||
# Optional taps still pay Maestro's full search cost when they miss; guard them
|
||||
# with one visibility check instead.
|
||||
- runFlow:
|
||||
when:
|
||||
visible: "Wait"
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Onboarding for a device that already reports itself as a TV.
|
||||
#
|
||||
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
|
||||
# onboards through the handheld layout and then walks Settings to flip
|
||||
# "Force TV mode". A real Android TV box takes the TV layout from the first
|
||||
# frame, so that walk has no handheld chrome to tap and the toggle would only
|
||||
# re-assert what PlatformDetector already reports.
|
||||
# Onboard a device that reports itself as a TV; unlike the phone flow, it starts
|
||||
# in TV layout and needs no Force TV mode walk.
|
||||
- retry:
|
||||
maxRetries: 1
|
||||
commands:
|
||||
@@ -32,9 +27,8 @@ appId: com.edde746.plezy
|
||||
- tapOn: "(?s)Server URLs.*"
|
||||
- inputText: ${JELLYFIN_URL}
|
||||
- tapOn: "Find server"
|
||||
# A TV-sized layout offers Quick Connect first because typing on a remote is
|
||||
# slow. The code path needs a second device to approve, so fall back to the
|
||||
# username form the phone flow already uses.
|
||||
# Quick Connect is awkward on a remote and needs another device, so use the
|
||||
# username form as fallback.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^(?:Username|Cancel)$"
|
||||
timeout: 30000
|
||||
|
||||
@@ -17,10 +17,8 @@ appId: com.edde746.plezy
|
||||
platform: Android
|
||||
commands:
|
||||
- hideKeyboard
|
||||
# A card announces its watch state three ways, and the codec samples keep a
|
||||
# resume position on the server once any earlier flow has played them, so the
|
||||
# partial form has to be accepted for this subflow to be re-runnable against a
|
||||
# fixture container that is not thrown away between suites.
|
||||
# Codec cards retain watch state and resume position between flows, so accept
|
||||
# any valid watch-state label to keep this subflow rerunnable.
|
||||
- extendedWaitUntil:
|
||||
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||
timeout: 15000
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
appId: com.edde746.plezy
|
||||
---
|
||||
# Reach Settings from a signed-in Home on a phone layout.
|
||||
#
|
||||
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
|
||||
# issued while the previous route is still settling opens nothing. The guarded
|
||||
# repeat re-opens the menu instead of failing the flow, matching the pattern
|
||||
# already used by flows/07_profiles_settings.yaml.
|
||||
# Reach phone Settings from signed-in Home. The profile menu is an overlay
|
||||
# sheet; retry while the previous route settles.
|
||||
- extendedWaitUntil:
|
||||
visible: "Discover"
|
||||
timeout: 30000
|
||||
|
||||
@@ -15,8 +15,7 @@ linter:
|
||||
prefer_final_in_for_each: true
|
||||
avoid_print: true
|
||||
|
||||
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
|
||||
# on Linux. CI invokes the supported check-unused commands explicitly.
|
||||
# DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly.
|
||||
dart_code_linter:
|
||||
rules-exclude:
|
||||
- "test/**"
|
||||
@@ -27,7 +26,7 @@ dart_code_linter:
|
||||
- package:dart_code_linter/presets/recommended.yaml
|
||||
|
||||
rules:
|
||||
# --- Flutter rules (on top of recommended) ---
|
||||
# Flutter-specific rules.
|
||||
- avoid-border-all
|
||||
- avoid-shrink-wrap-in-lists
|
||||
- avoid-expanded-as-spacer
|
||||
@@ -37,7 +36,7 @@ dart_code_linter:
|
||||
- prefer-define-hero-tag
|
||||
- use-setstate-synchronously
|
||||
|
||||
# --- Additional useful Dart rules ---
|
||||
# Additional Dart rules.
|
||||
- avoid-cascade-after-if-null
|
||||
- avoid-collection-methods-with-unrelated-types
|
||||
- avoid-unnecessary-type-assertions
|
||||
@@ -48,7 +47,7 @@ dart_code_linter:
|
||||
- prefer-enums-by-name
|
||||
- prefer-commenting-analyzer-ignores
|
||||
|
||||
# --- Disable noisy rules from recommended preset ---
|
||||
# Disabled noisy rules.
|
||||
- no-magic-number: false
|
||||
- avoid-dynamic: false
|
||||
- format-comment: false
|
||||
|
||||
@@ -336,8 +336,6 @@ android {
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.edde746.plezy"
|
||||
// You can update the following values to match your application needs.
|
||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||
targetSdk = flutter.targetSdkVersion
|
||||
versionCode = flutter.versionCode
|
||||
@@ -518,8 +516,10 @@ dependencies {
|
||||
// Android TV Watch Next integration
|
||||
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
||||
|
||||
// Periodic Watch Next background refresh (ShelfRefreshWorker). Same version
|
||||
// background_downloader pins, so the merged classpath stays coherent.
|
||||
// Only used to cancel the legacy periodic shelf refresh job (2.13.0's
|
||||
// removed ShelfRefreshWorker) that WorkManager persisted on updated
|
||||
// devices. Same version background_downloader pins, so the merged
|
||||
// classpath stays coherent.
|
||||
implementation("androidx.work:work-runtime-ktx:2.11.0")
|
||||
|
||||
// Media3 ExoPlayer for Android
|
||||
|
||||
+1
-1
@@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest {
|
||||
@Test
|
||||
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
|
||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||
if (!supportsTrueHdMatCarrier(context)) {
|
||||
if (!supportsTrueHdMatCarrier()) {
|
||||
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,6 +7,7 @@ import android.media.AudioTrack
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.annotation.OptIn
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.media3.common.AudioAttributes
|
||||
import androidx.media3.common.C
|
||||
import androidx.media3.common.MimeTypes
|
||||
@@ -99,41 +100,88 @@ internal fun supportedMpvSpdifCodecs(context: Context): String {
|
||||
* layer about the encoding, which on the boxes measured for this issue answers "TrueHD is
|
||||
* offload-capable" and says nothing about whether a raw TrueHD track will ever drain.
|
||||
*
|
||||
* Both are consulted: `getMinBufferSize` proves a track can be built, and, where the API exists,
|
||||
* `getDirectPlaybackSupport` proves the route will actually bitstream it rather than silently
|
||||
* decode or wedge.
|
||||
* Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback oracle
|
||||
* proves the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
|
||||
* not sufficient — on a Shield it answers yes for this tuple and the AudioTrack then fails to
|
||||
* initialise.
|
||||
*
|
||||
* The oracle is tiered by what the platform offers:
|
||||
* - API 33+: `getDirectPlaybackSupport`, whose bitstream flag also rules out offload-only answers.
|
||||
* - API 29–32: `AudioTrack.isDirectPlaybackSupported` for the same tuple. Coarser — it cannot tell
|
||||
* bitstream from offload — but an IEC 61937 track is PCM-shaped by definition, so direct support
|
||||
* for it means the route carries the frames. Fire OS 8 (API 30) devices bitstream TrueHD this way
|
||||
* and lost passthrough entirely under an API 33 gate (#1863). A route that still lies here fails
|
||||
* AudioTrack initialisation, which the audio recovery path answers by force-decoding.
|
||||
* - Below API 29 there is no oracle at all, so the carrier is not offered and TrueHD decodes as
|
||||
* before.
|
||||
*/
|
||||
internal fun supportsTrueHdMatCarrier(context: Context): Boolean {
|
||||
// getMinBufferSize alone is not sufficient. On a Shield it answers yes for the 192kHz/7.1 IEC
|
||||
// tuple and the AudioTrack then fails to initialise; it reports that a buffer can be sized, not
|
||||
// that the route will carry the format. Without getDirectPlaybackSupport there is no way to tell
|
||||
// the two apart, so below API 33 the carrier is not offered and TrueHD decodes as before.
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
|
||||
|
||||
val rate = TrueHdMatPacker.CARRIER_SAMPLE_RATE
|
||||
val mask = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
|
||||
val sizedOk = try {
|
||||
AudioTrack.getMinBufferSize(rate, mask, AudioFormat.ENCODING_IEC61937) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
|
||||
sdkInt = Build.VERSION.SDK_INT,
|
||||
canSizeCarrierBuffer = {
|
||||
try {
|
||||
AudioTrack.getMinBufferSize(
|
||||
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
|
||||
AudioFormat.ENCODING_IEC61937
|
||||
) > 0
|
||||
} catch (error: Exception) {
|
||||
false
|
||||
}
|
||||
},
|
||||
// The SDK_INT guards repeat trueHdMatCarrierSupported's tiering only because lint's NewApi
|
||||
// check cannot see through the injected lambdas.
|
||||
bitstreamSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU && iecCarrierBitstreamSupported()
|
||||
},
|
||||
directPlaybackSupported = {
|
||||
Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && iecCarrierDirectPlaybackSupported()
|
||||
}
|
||||
if (!sizedOk) return false
|
||||
)
|
||||
|
||||
return try {
|
||||
val audioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
.getPlatformAudioAttributes()
|
||||
val probe = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
||||
.setChannelMask(mask)
|
||||
.setSampleRate(rate)
|
||||
.build()
|
||||
val support = AudioManager.getDirectPlaybackSupport(probe, audioAttributes)
|
||||
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
|
||||
false
|
||||
}
|
||||
/**
|
||||
* [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
|
||||
* API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and
|
||||
* [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32.
|
||||
*/
|
||||
internal fun trueHdMatCarrierSupported(
|
||||
sdkInt: Int,
|
||||
canSizeCarrierBuffer: () -> Boolean,
|
||||
bitstreamSupported: () -> Boolean,
|
||||
directPlaybackSupported: () -> Boolean
|
||||
): Boolean = when {
|
||||
sdkInt < Build.VERSION_CODES.Q -> false
|
||||
!canSizeCarrierBuffer() -> false
|
||||
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
|
||||
else -> directPlaybackSupported()
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.TIRAMISU)
|
||||
private fun iecCarrierBitstreamSupported(): Boolean = try {
|
||||
val support = AudioManager.getDirectPlaybackSupport(iecCarrierProbeFormat(), movieAudioAttributes())
|
||||
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
|
||||
false
|
||||
}
|
||||
|
||||
@RequiresApi(Build.VERSION_CODES.Q)
|
||||
@Suppress("DEPRECATION") // Deprecated in favour of the API 33 probe the tier above uses.
|
||||
private fun iecCarrierDirectPlaybackSupported(): Boolean = try {
|
||||
AudioTrack.isDirectPlaybackSupported(iecCarrierProbeFormat(), movieAudioAttributes())
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
|
||||
false
|
||||
}
|
||||
|
||||
/** The exact tuple the carrier's `AudioTrack` is built with; see [PlezyRenderersFactory]. */
|
||||
private fun iecCarrierProbeFormat(): AudioFormat = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
||||
.setChannelMask(AudioFormat.CHANNEL_OUT_7POINT1_SURROUND)
|
||||
.setSampleRate(TrueHdMatPacker.CARRIER_SAMPLE_RATE)
|
||||
.build()
|
||||
|
||||
private fun movieAudioAttributes(): android.media.AudioAttributes = AudioAttributes.Builder()
|
||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
||||
.setUsage(C.USAGE_MEDIA)
|
||||
.build()
|
||||
.getPlatformAudioAttributes()
|
||||
|
||||
@@ -208,7 +208,7 @@ class PlezyRenderersFactory(context: Context) : DefaultRenderersFactory(context)
|
||||
return TrueHdCarrierSink(
|
||||
defaultSink = processedSink,
|
||||
carrierSink = buildCarrierSink(context, bufferSizeProvider),
|
||||
carrierRouteAvailable = { supportsTrueHdMatCarrier(context) },
|
||||
carrierRouteAvailable = { supportsTrueHdMatCarrier() },
|
||||
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
|
||||
log = audioDiagnosticsLogger
|
||||
).also { trueHdCarrierSink = it }
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.util.Log
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import androidx.work.Constraints
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.WorkerParameters
|
||||
import io.flutter.FlutterInjector
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.embedding.engine.dart.DartExecutor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeoutOrNull
|
||||
|
||||
/**
|
||||
* Owns the WorkManager registration for the periodic launcher-shelf refresh.
|
||||
*
|
||||
* Registration is best-effort by design: it runs inside the plugin's sync/clear
|
||||
* result path, and a WorkManager failure must not turn a committed shelf write
|
||||
* into a channel error.
|
||||
*/
|
||||
internal object ShelfRefreshScheduler {
|
||||
private const val TAG = "ShelfRefreshScheduler"
|
||||
internal const val WORK_NAME = "plezy_shelf_refresh"
|
||||
private const val REFRESH_INTERVAL_HOURS = 6L
|
||||
|
||||
fun schedule(context: Context) {
|
||||
try {
|
||||
val request = PeriodicWorkRequestBuilder<ShelfRefreshWorker>(REFRESH_INTERVAL_HOURS, TimeUnit.HOURS)
|
||||
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
|
||||
.build()
|
||||
WorkManager.getInstance(context.applicationContext)
|
||||
.enqueueUniquePeriodicWork(WORK_NAME, ExistingPeriodicWorkPolicy.KEEP, request)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to schedule shelf refresh work", e)
|
||||
}
|
||||
}
|
||||
|
||||
fun cancel(context: Context) {
|
||||
try {
|
||||
WorkManager.getInstance(context.applicationContext).cancelUniqueWork(WORK_NAME)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cancel shelf refresh work", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refreshes the Android TV Watch Next row while the app is not running.
|
||||
*
|
||||
* Boots a headless [FlutterEngine], runs the Dart `systemShelfBackgroundMain`
|
||||
* entrypoint (`lib/services/system_shelf_background.dart`), and resolves with
|
||||
* the bool that isolate reports through the `backgroundSyncComplete` method on
|
||||
* `com.plezy/watch_next`. The run is hard-capped at [RUN_TIMEOUT_MS]; the
|
||||
* engine is always destroyed on the main thread, timeout included.
|
||||
*/
|
||||
class ShelfRefreshWorker(
|
||||
appContext: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(appContext, params) {
|
||||
companion object {
|
||||
private const val TAG = "ShelfRefreshWorker"
|
||||
internal const val RUN_TIMEOUT_MS = 90_000L
|
||||
|
||||
/** Test seam: replaces the headless Flutter launch so tests never boot an engine. */
|
||||
@Volatile
|
||||
@VisibleForTesting
|
||||
internal var engineLauncherOverride: (suspend (Context) -> Boolean)? = null
|
||||
}
|
||||
|
||||
override suspend fun doWork(): Result {
|
||||
val context = applicationContext
|
||||
// Same support gate as WatchNextPlugin.handleIsSupported: no leanback
|
||||
// launcher, no Watch Next row worth refreshing.
|
||||
if (!context.packageManager.hasSystemFeature(PackageManager.FEATURE_LEANBACK)) return Result.success()
|
||||
// A live engine (the foreground app) owns the shelf and keeps it fresh
|
||||
// itself; a concurrent headless engine would only fight it for ownership.
|
||||
if (SystemShelfLifecycle.hasLiveLease()) return Result.success()
|
||||
|
||||
val launcher = engineLauncherOverride ?: ::runHeadlessShelfSync
|
||||
val success = try {
|
||||
withTimeoutOrNull(RUN_TIMEOUT_MS) { launcher(context) } ?: false
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Background shelf refresh failed", e)
|
||||
false
|
||||
}
|
||||
return if (success) Result.success() else Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun runHeadlessShelfSync(context: Context): Boolean {
|
||||
val appContext = context.applicationContext
|
||||
val completion = CompletableDeferred<Boolean>()
|
||||
val engine = withContext(Dispatchers.Main) {
|
||||
val loader = FlutterInjector.instance().flutterLoader()
|
||||
if (!loader.initialized()) {
|
||||
loader.startInitialization(appContext)
|
||||
}
|
||||
loader.ensureInitializationComplete(appContext, null)
|
||||
// The engine constructor auto-registers GeneratedPluginRegistrant plugins
|
||||
// (shared_preferences, path_provider, connectivity, sqlite, ...);
|
||||
// WatchNextPlugin is app-local and must be added explicitly.
|
||||
val engine = FlutterEngine(appContext)
|
||||
try {
|
||||
WatchNextPlugin.backgroundSyncCompletionListener = { success ->
|
||||
completion.complete(success)
|
||||
}
|
||||
engine.plugins.add(WatchNextPlugin())
|
||||
engine.dartExecutor.executeDartEntrypoint(
|
||||
DartExecutor.DartEntrypoint(
|
||||
loader.findAppBundlePath(),
|
||||
"package:plezy/services/system_shelf_background.dart",
|
||||
"systemShelfBackgroundMain"
|
||||
)
|
||||
)
|
||||
} catch (e: Throwable) {
|
||||
WatchNextPlugin.backgroundSyncCompletionListener = null
|
||||
engine.destroy()
|
||||
throw e
|
||||
}
|
||||
engine
|
||||
}
|
||||
return try {
|
||||
completion.await()
|
||||
} finally {
|
||||
withContext(NonCancellable + Dispatchers.Main) {
|
||||
WatchNextPlugin.backgroundSyncCompletionListener = null
|
||||
engine.destroy()
|
||||
}
|
||||
}
|
||||
}
|
||||
+25
-7
@@ -3,6 +3,8 @@ package com.edde746.plezy.watchnext
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import androidx.work.WorkManager
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.ExecutorService
|
||||
import java.util.concurrent.Executors
|
||||
@@ -15,6 +17,19 @@ class SystemShelfUpdateReceiver private constructor(
|
||||
constructor() : this(Executors.newSingleThreadExecutor(), true)
|
||||
internal constructor(executor: Executor) : this(executor, false)
|
||||
|
||||
companion object {
|
||||
private const val TAG = "SystemShelfUpdateReceiver"
|
||||
|
||||
/**
|
||||
* Unique name of the periodic ShelfRefreshWorker job 2.13.0 shipped and
|
||||
* enqueued (KEEP, persisted by WorkManager). The worker is gone, so on an
|
||||
* updated device the persisted job would wake the process once more, fail
|
||||
* to instantiate the deleted class, and linger as a permanently failed
|
||||
* record; cancel it on the first update instead.
|
||||
*/
|
||||
internal const val LEGACY_SHELF_REFRESH_WORK = "plezy_shelf_refresh"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val action = intent.action
|
||||
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
|
||||
@@ -22,22 +37,25 @@ class SystemShelfUpdateReceiver private constructor(
|
||||
executor.execute {
|
||||
try {
|
||||
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
|
||||
// Snapshot before maintenance: restoreReadGrants() re-commits the
|
||||
// granted-URI key even when no sync ever wrote it.
|
||||
val hadPriorSync = provider.hasPersistedShelfState()
|
||||
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||
cancelLegacyShelfRefreshWork(context.applicationContext)
|
||||
provider.migrateShelfSchema()
|
||||
} else {
|
||||
provider.restoreReadGrants()
|
||||
}
|
||||
// WorkManager normally survives reboots on its own; re-arming here
|
||||
// covers force-stop and update edge cases, and only for devices whose
|
||||
// persisted state says a shelf was actually synced before.
|
||||
if (hadPriorSync) ShelfRefreshScheduler.schedule(context.applicationContext)
|
||||
} finally {
|
||||
pending?.finish()
|
||||
if (ownsExecutor) (executor as ExecutorService).shutdown()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Best-effort by design: shelf maintenance must not die on a WorkManager failure. */
|
||||
private fun cancelLegacyShelfRefreshWork(context: Context) {
|
||||
try {
|
||||
WorkManager.getInstance(context).cancelUniqueWork(LEGACY_SHELF_REFRESH_WORK)
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to cancel legacy shelf refresh work", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,13 +27,6 @@ class WatchNextPlugin() :
|
||||
internal const val SCHEMA_VERSION = 3
|
||||
private var pendingDeepLink: String? = null
|
||||
|
||||
// Resolves ShelfRefreshWorker's headless run. The worker installs it
|
||||
// before launching the background engine and clears it on engine destroy;
|
||||
// a foreground engine never sets it, so its own channel calls are unaffected.
|
||||
@Volatile
|
||||
@JvmStatic
|
||||
var backgroundSyncCompletionListener: ((Boolean) -> Unit)? = null
|
||||
|
||||
fun handleIntent(intent: Intent?): String? {
|
||||
val data = intent?.data ?: return null
|
||||
return if (data.scheme == "plezy" && data.authority == "play") {
|
||||
@@ -111,7 +104,6 @@ class WatchNextPlugin() :
|
||||
"clear" -> handleClear(call, result)
|
||||
"remove" -> handleRemove(call, result)
|
||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||
"backgroundSyncComplete" -> handleBackgroundSyncComplete(call, result)
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
@@ -144,12 +136,7 @@ class WatchNextPlugin() :
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
val synced = provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
||||
// Only a committed shelf warrants the periodic background refresh; KEEP
|
||||
// makes re-arming from every foreground sync (and the headless worker's
|
||||
// own sync) idempotent.
|
||||
if (synced) ShelfRefreshScheduler.schedule(session.context)
|
||||
synced
|
||||
provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,10 +150,7 @@ class WatchNextPlugin() :
|
||||
val provider = session.provider ?: return@executeOnIo false
|
||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||
if (!session.isOpen()) return@executeOnIo false
|
||||
val cleared = provider.clearAll(owner, generation, ownership, session::isOpen)
|
||||
// A cleared shelf has nothing to refresh; the next successful sync re-arms.
|
||||
if (cleared) ShelfRefreshScheduler.cancel(session.context)
|
||||
cleared
|
||||
provider.clearAll(owner, generation, ownership, session::isOpen)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -208,12 +192,6 @@ class WatchNextPlugin() :
|
||||
result.success(contentId)
|
||||
}
|
||||
|
||||
private fun handleBackgroundSyncComplete(call: MethodCall, result: MethodChannel.Result) {
|
||||
val success = call.arguments as? Boolean ?: false
|
||||
backgroundSyncCompletionListener?.invoke(success)
|
||||
result.success(true)
|
||||
}
|
||||
|
||||
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
|
||||
val title = data["title"] as? String ?: return null
|
||||
|
||||
@@ -25,7 +25,6 @@ internal object SystemShelfLifecycle {
|
||||
private var claimToken = 0L
|
||||
private var currentOwner = ""
|
||||
private var currentGeneration = 0L
|
||||
private var leaseHeld = false
|
||||
|
||||
fun acquire(): Lease = acquireIf { true }!!
|
||||
|
||||
@@ -36,7 +35,6 @@ internal object SystemShelfLifecycle {
|
||||
claimToken += 1
|
||||
currentOwner = ""
|
||||
currentGeneration = 0
|
||||
leaseHeld = true
|
||||
Lease(token)
|
||||
}
|
||||
}
|
||||
@@ -47,19 +45,11 @@ internal object SystemShelfLifecycle {
|
||||
if (token == lease.token) {
|
||||
token += 1
|
||||
claimToken += 1
|
||||
leaseHeld = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True while the most recently acquired engine lease has not been
|
||||
* invalidated — i.e. a live engine (normally the UI) currently owns the
|
||||
* shelf. ShelfRefreshWorker checks this before booting a headless engine.
|
||||
*/
|
||||
fun hasLiveLease(): Boolean = synchronized(lock) { leaseHeld }
|
||||
|
||||
fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
|
||||
synchronized(lock) {
|
||||
if (
|
||||
@@ -140,13 +130,6 @@ class WatchNextProvider internal constructor(
|
||||
|
||||
internal fun claimOwnership(ownerId: String, generation: Long): SystemShelfLifecycle.Ownership? = lifecycleLease?.let { SystemShelfLifecycle.claim(it, ownerId, generation) }
|
||||
|
||||
/**
|
||||
* Whether a prior sync's committed state is still on disk. Cleared rows
|
||||
* ([clearAll]) and schema-migration wipes remove the granted-URI key, so
|
||||
* this distinguishes "user had a shelf" from "never synced / cleared".
|
||||
*/
|
||||
internal fun hasPersistedShelfState(): Boolean = prefs.contains(GRANTED_URIS)
|
||||
|
||||
internal fun syncWatchNextPrograms(
|
||||
ownerId: String,
|
||||
generation: Long,
|
||||
|
||||
@@ -82,4 +82,70 @@ class AudioOutputPolicyTest {
|
||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||
assertEquals("", mpvSpdifCodecs { false })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierIsNeverOfferedBelowApi29() {
|
||||
// No direct-playback oracle exists there, and getMinBufferSize alone is known to lie
|
||||
// (a Shield sizes the tuple, then the AudioTrack fails to initialise).
|
||||
assertFalse(
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = 28,
|
||||
canSizeCarrierBuffer = { true },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true }
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierRequiresASizableBufferOnEveryTier() {
|
||||
for (sdkInt in intArrayOf(29, 30, 32, 33, 34)) {
|
||||
assertFalse(
|
||||
"api $sdkInt",
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeCarrierBuffer = { false },
|
||||
bitstreamSupported = { true },
|
||||
directPlaybackSupported = { true }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierOnApi29To32FollowsTheDirectPlaybackProbe() {
|
||||
// Fire OS 8 (API 30) bitstreams TrueHD over this route; an API 33 gate force-decoded it (#1863).
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
for (sdkInt in intArrayOf(29, 30, 32)) {
|
||||
assertEquals(
|
||||
"api $sdkInt supported=$supported",
|
||||
supported,
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = sdkInt,
|
||||
canSizeCarrierBuffer = { true },
|
||||
bitstreamSupported = { throw AssertionError("getDirectPlaybackSupport does not exist below API 33") },
|
||||
directPlaybackSupported = { supported }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun carrierOnApi33UsesTheBitstreamProbe() {
|
||||
// getDirectPlaybackSupport distinguishes bitstream from offload-only; the coarser API 29
|
||||
// probe must not shadow it where the platform can answer precisely.
|
||||
for (supported in booleanArrayOf(true, false)) {
|
||||
assertEquals(
|
||||
"supported=$supported",
|
||||
supported,
|
||||
trueHdMatCarrierSupported(
|
||||
sdkInt = 33,
|
||||
canSizeCarrierBuffer = { true },
|
||||
bitstreamSupported = { supported },
|
||||
directPlaybackSupported = { throw AssertionError("API 29 probe must not be consulted on API 33+") }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -1,286 +0,0 @@
|
||||
package com.edde746.plezy.watchnext
|
||||
|
||||
import android.content.ContentProvider
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.database.Cursor
|
||||
import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.work.NetworkType
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.testing.TestListenableWorkerBuilder
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.lang.reflect.Proxy
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executor
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.RuntimeEnvironment
|
||||
import org.robolectric.Shadows.shadowOf
|
||||
import org.robolectric.shadows.ShadowContentResolver
|
||||
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
class ShelfRefreshWorkerTest {
|
||||
private val context: Context get() = RuntimeEnvironment.getApplication()
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
context.getSharedPreferences("system_shelf_state", 0).edit().clear().commit()
|
||||
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
|
||||
ShadowContentResolver.registerProviderInternal(TvContractCompat.AUTHORITY, StubTvProvider())
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context)
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
ShelfRefreshWorker.engineLauncherOverride = null
|
||||
// Leave no live lease behind for the next test in this sandbox.
|
||||
SystemShelfLifecycle.invalidate(SystemShelfLifecycle.acquire())
|
||||
context.cacheDir.resolve("system_shelf_artwork").deleteRecursively()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun successfulSyncSchedulesUniquePeriodicRefreshWithKeepAndNetworkConstraint() {
|
||||
val plugin = WatchNextPlugin()
|
||||
val binding = pluginBinding()
|
||||
plugin.onAttachedToEngine(binding)
|
||||
try {
|
||||
val first = ShelfRecordingResult()
|
||||
plugin.onMethodCall(syncCall(generation = 1), first)
|
||||
awaitResult(first)
|
||||
assertEquals(true, first.successValue)
|
||||
|
||||
val infos = uniqueWorkInfos()
|
||||
assertEquals(1, infos.size)
|
||||
val info = infos.single()
|
||||
assertEquals(WorkInfo.State.ENQUEUED, info.state)
|
||||
assertEquals(NetworkType.CONNECTED, info.constraints.requiredNetworkType)
|
||||
assertEquals(TimeUnit.HOURS.toMillis(6), info.periodicityInfo?.repeatIntervalMillis)
|
||||
|
||||
// KEEP: a second successful sync must not replace the pending request.
|
||||
val second = ShelfRecordingResult()
|
||||
plugin.onMethodCall(syncCall(generation = 2), second)
|
||||
awaitResult(second)
|
||||
assertEquals(true, second.successValue)
|
||||
assertEquals(info.id, uniqueWorkInfos().single().id)
|
||||
} finally {
|
||||
plugin.onDetachedFromEngine(binding)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearCancelsScheduledRefresh() {
|
||||
val plugin = WatchNextPlugin()
|
||||
val binding = pluginBinding()
|
||||
plugin.onAttachedToEngine(binding)
|
||||
try {
|
||||
val sync = ShelfRecordingResult()
|
||||
plugin.onMethodCall(syncCall(generation = 1), sync)
|
||||
awaitResult(sync)
|
||||
assertEquals(true, sync.successValue)
|
||||
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
|
||||
|
||||
val clear = ShelfRecordingResult()
|
||||
plugin.onMethodCall(
|
||||
MethodCall("clear", mapOf("schemaVersion" to 3, "ownerId" to "owner-a", "generation" to 2L)),
|
||||
clear
|
||||
)
|
||||
awaitResult(clear)
|
||||
assertEquals(true, clear.successValue)
|
||||
assertEquals(WorkInfo.State.CANCELLED, uniqueWorkInfos().single().state)
|
||||
} finally {
|
||||
plugin.onDetachedFromEngine(binding)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiverDoesNotArmRefreshWithoutPersistedShelfState() {
|
||||
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
|
||||
assertTrue(uniqueWorkInfos().isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiverRearmsRefreshOnBootWhenShelfStateIsPersisted() {
|
||||
// granted_uris is only written by a committed sync (clear removes it), so
|
||||
// its presence — even as an empty set — marks a prior sync.
|
||||
context.getSharedPreferences("system_shelf_state", 0).edit()
|
||||
.putStringSet("granted_uris", emptySet())
|
||||
.putInt("shelf_schema_version", 1)
|
||||
.commit()
|
||||
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_BOOT_COMPLETED))
|
||||
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun receiverRearmsRefreshOnPackageReplacedWhenShelfStateIsPersisted() {
|
||||
context.getSharedPreferences("system_shelf_state", 0).edit()
|
||||
.putStringSet("granted_uris", emptySet())
|
||||
.putInt("shelf_schema_version", 1)
|
||||
.commit()
|
||||
SystemShelfUpdateReceiver(directExecutor).onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
|
||||
assertEquals(WorkInfo.State.ENQUEUED, uniqueWorkInfos().single().state)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workerSkipsWithoutLaunchingEngineWhenLiveEngineHoldsLease() {
|
||||
shadowOf(context.packageManager).setSystemFeature(PackageManager.FEATURE_LEANBACK, true)
|
||||
val launched = AtomicBoolean(false)
|
||||
ShelfRefreshWorker.engineLauncherOverride = {
|
||||
launched.set(true)
|
||||
true
|
||||
}
|
||||
val lease = SystemShelfLifecycle.acquire()
|
||||
try {
|
||||
val result = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.success(), result)
|
||||
assertFalse(launched.get())
|
||||
} finally {
|
||||
SystemShelfLifecycle.invalidate(lease)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun workerSkipsWithoutLaunchingEngineOnNonLeanbackDevices() {
|
||||
val launched = AtomicBoolean(false)
|
||||
ShelfRefreshWorker.engineLauncherOverride = {
|
||||
launched.set(true)
|
||||
true
|
||||
}
|
||||
val result = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.success(), result)
|
||||
assertFalse(launched.get())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unattendedWorkerRunsInjectedLauncherAndMapsCompletionToResult() {
|
||||
shadowOf(context.packageManager).setSystemFeature(PackageManager.FEATURE_LEANBACK, true)
|
||||
val launched = AtomicBoolean(false)
|
||||
ShelfRefreshWorker.engineLauncherOverride = {
|
||||
launched.set(true)
|
||||
true
|
||||
}
|
||||
val success = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.success(), success)
|
||||
assertTrue(launched.get())
|
||||
|
||||
ShelfRefreshWorker.engineLauncherOverride = { false }
|
||||
val failure = TestListenableWorkerBuilder<ShelfRefreshWorker>(context).build().startWork().get()
|
||||
assertEquals(androidx.work.ListenableWorker.Result.retry(), failure)
|
||||
}
|
||||
|
||||
private val directExecutor = Executor { it.run() }
|
||||
|
||||
private fun uniqueWorkInfos(): List<WorkInfo> = WorkManager.getInstance(context).getWorkInfosForUniqueWork(ShelfRefreshScheduler.WORK_NAME).get()
|
||||
|
||||
private fun syncCall(generation: Long) = MethodCall(
|
||||
"sync",
|
||||
mapOf(
|
||||
"schemaVersion" to 3,
|
||||
"ownerId" to "owner-a",
|
||||
"generation" to generation,
|
||||
"items" to emptyList<Map<String, Any?>>()
|
||||
)
|
||||
)
|
||||
|
||||
private fun awaitResult(result: ShelfRecordingResult) {
|
||||
repeat(100) {
|
||||
shadowOf(android.os.Looper.getMainLooper()).idle()
|
||||
if (result.completed.await(10, TimeUnit.MILLISECONDS)) return
|
||||
}
|
||||
assertTrue("Watch Next result never completed", false)
|
||||
}
|
||||
|
||||
private fun pluginBinding(): FlutterPlugin.FlutterPluginBinding {
|
||||
val messenger = Proxy.newProxyInstance(
|
||||
BinaryMessenger::class.java.classLoader,
|
||||
arrayOf(BinaryMessenger::class.java)
|
||||
) { _, _, _ -> null } as BinaryMessenger
|
||||
val constructor = FlutterPlugin.FlutterPluginBinding::class.java.constructors.single()
|
||||
val arguments = constructor.parameterTypes.map { type ->
|
||||
when {
|
||||
Context::class.java.isAssignableFrom(type) -> context
|
||||
BinaryMessenger::class.java.isAssignableFrom(type) -> messenger
|
||||
else -> null
|
||||
}
|
||||
}.toTypedArray()
|
||||
return constructor.newInstance(*arguments) as FlutterPlugin.FlutterPluginBinding
|
||||
}
|
||||
}
|
||||
|
||||
/** Just enough TV provider for an empty-items sync/clear to commit. */
|
||||
private class StubTvProvider : ContentProvider() {
|
||||
private val inserted = mutableListOf<ContentValues>()
|
||||
private var nextRowId = 1L
|
||||
|
||||
override fun onCreate(): Boolean = true
|
||||
|
||||
override fun insert(uri: Uri, values: ContentValues?): Uri {
|
||||
inserted += ContentValues(values)
|
||||
return uri.buildUpon().appendPath((nextRowId++).toString()).build()
|
||||
}
|
||||
|
||||
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
|
||||
val deleted = inserted.size
|
||||
inserted.clear()
|
||||
return deleted
|
||||
}
|
||||
|
||||
override fun query(
|
||||
uri: Uri,
|
||||
projection: Array<out String>?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?,
|
||||
sortOrder: String?
|
||||
): Cursor = MatrixCursor(
|
||||
projection ?: arrayOf(
|
||||
TvContractCompat.WatchNextPrograms._ID,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_ID,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTERNAL_PROVIDER_DATA,
|
||||
TvContractCompat.WatchNextPrograms.COLUMN_INTENT_URI,
|
||||
TvContractCompat.PreviewPrograms.COLUMN_POSTER_ART_URI
|
||||
)
|
||||
)
|
||||
|
||||
override fun getType(uri: Uri): String? = null
|
||||
|
||||
override fun update(
|
||||
uri: Uri,
|
||||
values: ContentValues?,
|
||||
selection: String?,
|
||||
selectionArgs: Array<out String>?
|
||||
): Int = 0
|
||||
}
|
||||
|
||||
private class ShelfRecordingResult : MethodChannel.Result {
|
||||
val completed = CountDownLatch(1)
|
||||
var successValue: Any? = null
|
||||
|
||||
override fun success(result: Any?) {
|
||||
successValue = result
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun error(errorCode: String, errorMessage: String?, errorDetails: Any?) {
|
||||
completed.countDown()
|
||||
}
|
||||
|
||||
override fun notImplemented() {
|
||||
completed.countDown()
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,13 @@ import android.database.MatrixCursor
|
||||
import android.net.Uri
|
||||
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
||||
import androidx.tvprovider.media.tv.TvContractCompat
|
||||
import androidx.work.ExistingPeriodicWorkPolicy
|
||||
import androidx.work.PeriodicWorkRequestBuilder
|
||||
import androidx.work.WorkInfo
|
||||
import androidx.work.WorkManager
|
||||
import androidx.work.Worker
|
||||
import androidx.work.WorkerParameters
|
||||
import androidx.work.testing.WorkManagerTestInitHelper
|
||||
import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
@@ -720,6 +727,26 @@ class WatchNextProviderTest {
|
||||
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun packageUpdateCancelsLegacyShelfRefreshWork() {
|
||||
WorkManagerTestInitHelper.initializeTestWorkManager(context)
|
||||
val workManager = WorkManager.getInstance(context)
|
||||
workManager.enqueueUniquePeriodicWork(
|
||||
SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
PeriodicWorkRequestBuilder<LegacyShelfRefreshStandIn>(6, TimeUnit.HOURS).build()
|
||||
).result.get()
|
||||
|
||||
SystemShelfUpdateReceiver(Executor { command -> command.run() })
|
||||
.onReceive(context, Intent(Intent.ACTION_MY_PACKAGE_REPLACED))
|
||||
|
||||
val states = workManager
|
||||
.getWorkInfosForUniqueWork(SystemShelfUpdateReceiver.LEGACY_SHELF_REFRESH_WORK)
|
||||
.get()
|
||||
.map { it.state }
|
||||
assertEquals(listOf(WorkInfo.State.CANCELLED), states)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
|
||||
ScriptedHttpServer(
|
||||
@@ -1605,3 +1632,15 @@ private class ManualExecutorService : AbstractExecutorService() {
|
||||
tasks.removeFirst().run()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Stands in for the removed ShelfRefreshWorker so the legacy periodic job can
|
||||
* be enqueued. Public because WorkManager's default factory instantiates
|
||||
* workers reflectively and cannot access a package-private class.
|
||||
*/
|
||||
class LegacyShelfRefreshStandIn(
|
||||
context: Context,
|
||||
params: WorkerParameters
|
||||
) : Worker(context, params) {
|
||||
override fun doWork(): Result = Result.success()
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ static inline long long nowMs(void) {
|
||||
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||
}
|
||||
|
||||
#include "AssPack.h"
|
||||
#include "ass/ass.h"
|
||||
|
||||
#define LOG_TAG "SubtitleRenderer"
|
||||
@@ -243,35 +244,8 @@ Java_com_edde746_plezy_libass_AssRender_nativeAssRenderDeinit(JNIEnv* env, jclas
|
||||
}
|
||||
}
|
||||
|
||||
// Hard cap on atlas pages (see the packing comment below). 4 pages of a GL-max
|
||||
// texture is far above the worst real frame measured (a 4K-rendered full-screen
|
||||
// typeset letter needs 3); beyond it tiles are dropped and counted in truncated.
|
||||
#define MAX_ATLAS_PAGES 4
|
||||
|
||||
// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image
|
||||
// can exceed one atlas page only when the render frame is larger than a page
|
||||
// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split
|
||||
// one image across pages (a quad samples one texture), so tiling does, keeping
|
||||
// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the
|
||||
// multi-page pack below, not oversized single images.) Tiles are built in list
|
||||
// order (= libass blend/painter order, preserved for emission); the single-page
|
||||
// pack runs height-sorted via a separate key array so emission order is untouched.
|
||||
typedef struct {
|
||||
ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y)
|
||||
int ox, oy; // tile offset within the source bitmap
|
||||
int tw, th; // tile size (<= atlasMaxW x atlasMaxH)
|
||||
int page; // atlas page the tile is packed into; -1 if dropped for capacity
|
||||
int sx, sy; // packed slot within the page; valid when page >= 0
|
||||
} PackTile;
|
||||
|
||||
typedef struct {
|
||||
int th; // tile height (the sort key)
|
||||
int idx; // index into the build-order tiles[] array
|
||||
} TileSortKey;
|
||||
|
||||
static int compareTileKeysByHeightDesc(const void* a, const void* b) {
|
||||
return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th;
|
||||
}
|
||||
// Packing/composite policy lives in AssPack.c (pure C, desktop-testable); this
|
||||
// file owns the JNI boundary, buffer plumbing and logging.
|
||||
|
||||
static int imageListHasOutput(ASS_Image* image) {
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
@@ -293,11 +267,12 @@ static int truncationLogCounter = 0;
|
||||
// [5]=hasOutput [6]=pageCount
|
||||
// [7 .. 7+MAX-1] = pageHeights[pageCount]
|
||||
// [7+MAX .. 7+2*MAX-1] = pageQuadCounts[pageCount]
|
||||
#define ASS_HEADER_INTS (7 + 2 * MAX_ATLAS_PAGES)
|
||||
// [7+2*MAX] = mode (ASS_PACK_MODE_ATLAS | ASS_PACK_MODE_COMPOSITE)
|
||||
#define ASS_HEADER_INTS (7 + 2 * ASS_PACK_MAX_PAGES + 1)
|
||||
|
||||
static jint writeAtlasHeader(
|
||||
JNIEnv* env, jintArray headerBuf, int atlasWidth, int quadCount, int changed, int truncated, int requiredPages,
|
||||
int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads) {
|
||||
int hasOutput, int pageCount, const int* pageHeights, const int* pageQuads, int mode) {
|
||||
int hdr[ASS_HEADER_INTS];
|
||||
memset(hdr, 0, sizeof(hdr));
|
||||
hdr[0] = atlasWidth;
|
||||
@@ -307,36 +282,32 @@ static jint writeAtlasHeader(
|
||||
hdr[4] = requiredPages;
|
||||
hdr[5] = hasOutput;
|
||||
hdr[6] = pageCount;
|
||||
for (int i = 0; i < pageCount && i < MAX_ATLAS_PAGES; i++) {
|
||||
for (int i = 0; i < pageCount && i < ASS_PACK_MAX_PAGES; i++) {
|
||||
hdr[7 + i] = pageHeights ? pageHeights[i] : 0;
|
||||
hdr[7 + MAX_ATLAS_PAGES + i] = pageQuads ? pageQuads[i] : 0;
|
||||
hdr[7 + ASS_PACK_MAX_PAGES + i] = pageQuads ? pageQuads[i] : 0;
|
||||
}
|
||||
hdr[7 + 2 * ASS_PACK_MAX_PAGES] = mode;
|
||||
(*env)->SetIntArrayRegion(env, headerBuf, 0, ASS_HEADER_INTS, hdr);
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Renders a frame into the provided atlas + vertex direct ByteBuffers.
|
||||
//
|
||||
// - atlasBuf holds one or more vertically-stacked ALPHA_8 *pages*, each atlasMaxW ×
|
||||
// atlasMaxH (row stride atlasMaxW); page p starts at byte offset p*atlasMaxW*atlasMaxH.
|
||||
// The buffer's capacity bounds how many pages this render may fill; AssAtlasFrame
|
||||
// reports pageHeights (rows worth uploading per page) and requiredPages.
|
||||
// - In the common ATLAS mode, atlasBuf holds one or more vertically-stacked ALPHA_8
|
||||
// *pages*, each atlasMaxW × atlasMaxH (row stride atlasMaxW); page p starts at byte
|
||||
// offset p*atlasMaxW*atlasMaxH. The buffer's capacity bounds how many pages this
|
||||
// render may fill; AssAtlasFrame reports pageHeights (rows worth uploading per page)
|
||||
// and requiredPages. UVs are page-local, normalized against atlasMaxW × atlasMaxH.
|
||||
// - In COMPOSITE mode (frames whose tiles can never fit ASS_PACK_MAX_PAGES pages or
|
||||
// the vertex budget) atlasBuf instead starts with one premultiplied RGBA rect of
|
||||
// atlasWidth × pageHeights[0] pixels, drawn as the single emitted quad (UVs 0..1).
|
||||
// - vertexBuf holds a per-quad vertex stream (6 vertices × (2 pos + 2 uv + 4 color)
|
||||
// floats = 48 floats = 192 bytes per quad). Must match BYTES_PER_QUAD/VERTEX in
|
||||
// AssSubtitleAtlasPipeline.kt. UVs are page-local, normalized against atlasMaxW ×
|
||||
// atlasMaxH (the per-page texture dims).
|
||||
// - The common case packs everything into a single height-sorted page (minimizes
|
||||
// packed height, byte-identical to the prior single-page packer). When that
|
||||
// overflows (a 4K full-screen sign can exceed one GL-max texture), the packer
|
||||
// spills into additional pages in list order: page assignment is monotonic in
|
||||
// libass's painter order, so each page's quads are one contiguous run in the
|
||||
// vertex stream and drawing the pages in turn reproduces the blend order.
|
||||
// - Vertices are always emitted in original list order (= libass's painter order).
|
||||
// AssSubtitleAtlasPipeline.kt. Vertices are emitted in libass's painter order.
|
||||
//
|
||||
// Never fails on content size: when the frame needs more pages than the buffer holds
|
||||
// (requiredPages > pageHeights.size) the caller grows the buffer and re-renders; any
|
||||
// genuinely undrawable tiles (past MAX_ATLAS_PAGES / the vertex budget) are dropped
|
||||
// and counted in truncated.
|
||||
// Never drops content for size: when the frame needs more capacity than the buffer
|
||||
// holds (requiredPages > pageHeights.size) the caller grows the buffer and re-renders;
|
||||
// frames too dense for the paged atlas flatten into the RGBA composite (see AssPack.c).
|
||||
//
|
||||
// Returns 0 for missing buffers/handles (the caller maps that to a null frame); 1 when
|
||||
// the header was written. On changed == 0 the header carries (atlasWidth=0, quadCount=0,
|
||||
@@ -359,7 +330,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
|
||||
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", (long long)time,
|
||||
tAss - t0, changed, hasOutput);
|
||||
}
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL);
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, hasOutput, 1, NULL, NULL, ASS_PACK_MODE_ATLAS);
|
||||
}
|
||||
|
||||
if (image == NULL) {
|
||||
@@ -368,7 +339,7 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
|
||||
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, no output)", (long long)time,
|
||||
tAss - t0, changed);
|
||||
}
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL);
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL, ASS_PACK_MODE_ATLAS);
|
||||
}
|
||||
|
||||
uint8_t* atlasPixels = (uint8_t*)(*env)->GetDirectBufferAddress(env, atlasBuf);
|
||||
@@ -382,253 +353,45 @@ JNIEXPORT jint JNICALL Java_com_edde746_plezy_libass_AssRender_nativeAssRenderFr
|
||||
(long long)atlasCap);
|
||||
return 0;
|
||||
}
|
||||
// 48 floats per quad × 4 bytes = 192 bytes/quad
|
||||
const int maxQuads = (int)(vertexCap / 192);
|
||||
const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH;
|
||||
int providedPages = (int)(atlasCap / (jlong)pageBytes);
|
||||
if (providedPages < 1) return 0; // one page is guaranteed above; keep the page math safe
|
||||
if (providedPages > MAX_ATLAS_PAGES) providedPages = MAX_ATLAS_PAGES;
|
||||
|
||||
// Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles.
|
||||
// tiles[] stays in list order (= blend/painter order for emission); keys[] is
|
||||
// sorted by height for the single-page pack so it produces tight rows.
|
||||
int total = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w > 0 && img->h > 0) {
|
||||
int cols = (img->w + atlasMaxW - 1) / atlasMaxW;
|
||||
int rows = (img->h + atlasMaxH - 1) / atlasMaxH;
|
||||
total += cols * rows;
|
||||
}
|
||||
}
|
||||
if (total == 0) {
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, 0, 1, 0, 1, NULL, NULL);
|
||||
}
|
||||
|
||||
PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total);
|
||||
TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total);
|
||||
if (!tiles || !keys) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
AssPackResult pack;
|
||||
if (!ass_pack_frame(image, atlasPixels, (size_t)atlasCap, atlasMaxW, atlasMaxH, vertices, (size_t)vertexCap, &pack)) {
|
||||
return 0;
|
||||
}
|
||||
int n = 0;
|
||||
long long srcPixels = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
srcPixels += (long long)img->w * img->h;
|
||||
for (int oy = 0; oy < img->h; oy += atlasMaxH) {
|
||||
int th = img->h - oy;
|
||||
if (th > atlasMaxH) th = atlasMaxH;
|
||||
for (int ox = 0; ox < img->w; ox += atlasMaxW) {
|
||||
int tw = img->w - ox;
|
||||
if (tw > atlasMaxW) tw = atlasMaxW;
|
||||
tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1};
|
||||
keys[n] = (TileSortKey){.th = th, .idx = n};
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
int pageHeights[MAX_ATLAS_PAGES] = {0};
|
||||
int pageQuads[MAX_ATLAS_PAGES] = {0};
|
||||
int pageCount = 1;
|
||||
int requiredPages = 1;
|
||||
int truncated = 0;
|
||||
|
||||
// Pass 1a: height-sorted single page — the common case, minimal packed height
|
||||
// (byte-identical to the prior single-page packer when the frame fits one page).
|
||||
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
|
||||
int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[keys[i].idx];
|
||||
if (accepted >= maxQuads) break;
|
||||
int cx = cursorX, cy = cursorY, rh = rowH;
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page
|
||||
t->page = 0;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
cursorX = cx + t->tw;
|
||||
cursorY = cy;
|
||||
rowH = (t->th > rh) ? t->th : rh;
|
||||
if (cy + t->th > packedH) packedH = cy + t->th;
|
||||
accepted++;
|
||||
}
|
||||
|
||||
if (accepted == n) {
|
||||
pageHeights[0] = packedH;
|
||||
pageQuads[0] = accepted;
|
||||
} else {
|
||||
// Pass 1b: the frame overflows one page. Re-pack in list order, starting a new
|
||||
// page whenever a tile won't fit the current one. List order keeps the page
|
||||
// index monotonic in painter order, so each page's quads stay one contiguous run.
|
||||
for (int i = 0; i < n; i++) {
|
||||
tiles[i].page = -1;
|
||||
tiles[i].sx = -1;
|
||||
tiles[i].sy = -1;
|
||||
}
|
||||
int page = 0, cx = 0, cy = 0, rh = 0, placed = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) {
|
||||
page++;
|
||||
cx = 0;
|
||||
cy = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (page + 1 > requiredPages) requiredPages = page + 1;
|
||||
if (page < providedPages && placed < maxQuads) {
|
||||
t->page = page;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
if (cy + t->th > pageHeights[page]) pageHeights[page] = cy + t->th;
|
||||
pageQuads[page]++;
|
||||
placed++;
|
||||
}
|
||||
cx += t->tw;
|
||||
rh = (t->th > rh) ? t->th : rh;
|
||||
}
|
||||
pageCount = (requiredPages < providedPages) ? requiredPages : providedPages;
|
||||
accepted = placed;
|
||||
truncated = n - placed;
|
||||
}
|
||||
|
||||
// Warn only for genuinely-unrecoverable loss. A frame that needs more pages than
|
||||
// the buffer currently holds, yet fits within MAX_ATLAS_PAGES and the vertex
|
||||
// budget, is recoverable: the caller grows the buffer and re-renders, so the
|
||||
// first (discarded) render's truncated > 0 is a false alarm, not data loss. Tiles
|
||||
// are only truly lost past the page cap or the vertex budget.
|
||||
const int recoverableGrow = requiredPages <= MAX_ATLAS_PAGES && n <= maxQuads;
|
||||
if (truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) {
|
||||
// Warn only for genuinely-unrecoverable loss. A frame that needs more capacity than
|
||||
// the buffer currently holds is recoverable: the caller grows the buffer and
|
||||
// re-renders, so the first (discarded) render's truncated > 0 is a false alarm, not
|
||||
// data loss. With the composite fallback, unrecoverable truncation should be
|
||||
// unreachable for real content.
|
||||
const int maxQuads = (int)(vertexCap / 192);
|
||||
const int recoverableGrow =
|
||||
pack.requiredPages <= ASS_PACK_MAX_PAGES && (pack.mode == ASS_PACK_MODE_COMPOSITE || pack.totalTiles <= maxQuads);
|
||||
if (pack.truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) {
|
||||
__android_log_print(
|
||||
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %d)",
|
||||
truncated, n, atlasMaxW, atlasMaxH, requiredPages, providedPages);
|
||||
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %lld)",
|
||||
pack.truncated, pack.totalTiles, atlasMaxW, atlasMaxH, pack.requiredPages,
|
||||
(long long)(atlasCap / ((jlong)atlasMaxW * atlasMaxH)));
|
||||
}
|
||||
if (accepted == 0) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
return writeAtlasHeader(env, headerBuf, 0, 0, changed, truncated, requiredPages, 1, 1, NULL, NULL);
|
||||
}
|
||||
|
||||
// Clear only the packed rows of each written page.
|
||||
for (int p = 0; p < pageCount; p++) {
|
||||
memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * pageHeights[p]);
|
||||
}
|
||||
|
||||
// Emit tiles in list order (= libass's painter/blend order), copying each placed
|
||||
// tile into its page slot and emitting its quad. Monotonic page assignment makes
|
||||
// each page's quads a contiguous run, matching pageQuads[] for the per-page draw.
|
||||
int qi = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (t->page < 0) continue;
|
||||
ASS_Image* img = t->img;
|
||||
const int px = t->sx;
|
||||
const int py = t->sy;
|
||||
uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes;
|
||||
|
||||
for (int y = 0; y < t->th; y++) {
|
||||
uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px;
|
||||
const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox;
|
||||
memcpy(dst, src, (size_t)t->tw);
|
||||
}
|
||||
|
||||
const float x0 = (float)(img->dst_x + t->ox);
|
||||
const float y0 = (float)(img->dst_y + t->oy);
|
||||
const float x1 = x0 + (float)t->tw;
|
||||
const float y1 = y0 + (float)t->th;
|
||||
const float u0 = (float)px / (float)atlasMaxW;
|
||||
const float v0 = (float)py / (float)atlasMaxH;
|
||||
const float u1 = (float)(px + t->tw) / (float)atlasMaxW;
|
||||
const float v1 = (float)(py + t->th) / (float)atlasMaxH;
|
||||
const unsigned int c = img->color;
|
||||
const float r = (float)((c >> 24) & 0xFFu) / 255.0f;
|
||||
const float g = (float)((c >> 16) & 0xFFu) / 255.0f;
|
||||
const float b = (float)((c >> 8) & 0xFFu) / 255.0f;
|
||||
const float a = (float)(0xFFu - (c & 0xFFu)) / 255.0f;
|
||||
|
||||
float* vx = vertices + (size_t)qi * 48;
|
||||
// 8 floats per vertex: x, y, u, v, r, g, b, a.
|
||||
// Triangle 1: (x0,y0) (x1,y0) (x0,y1)
|
||||
vx[0] = x0;
|
||||
vx[1] = y0;
|
||||
vx[2] = u0;
|
||||
vx[3] = v0;
|
||||
vx[4] = r;
|
||||
vx[5] = g;
|
||||
vx[6] = b;
|
||||
vx[7] = a;
|
||||
vx[8] = x1;
|
||||
vx[9] = y0;
|
||||
vx[10] = u1;
|
||||
vx[11] = v0;
|
||||
vx[12] = r;
|
||||
vx[13] = g;
|
||||
vx[14] = b;
|
||||
vx[15] = a;
|
||||
vx[16] = x0;
|
||||
vx[17] = y1;
|
||||
vx[18] = u0;
|
||||
vx[19] = v1;
|
||||
vx[20] = r;
|
||||
vx[21] = g;
|
||||
vx[22] = b;
|
||||
vx[23] = a;
|
||||
// Triangle 2: (x1,y0) (x1,y1) (x0,y1)
|
||||
vx[24] = x1;
|
||||
vx[25] = y0;
|
||||
vx[26] = u1;
|
||||
vx[27] = v0;
|
||||
vx[28] = r;
|
||||
vx[29] = g;
|
||||
vx[30] = b;
|
||||
vx[31] = a;
|
||||
vx[32] = x1;
|
||||
vx[33] = y1;
|
||||
vx[34] = u1;
|
||||
vx[35] = v1;
|
||||
vx[36] = r;
|
||||
vx[37] = g;
|
||||
vx[38] = b;
|
||||
vx[39] = a;
|
||||
vx[40] = x0;
|
||||
vx[41] = y1;
|
||||
vx[42] = u0;
|
||||
vx[43] = v1;
|
||||
vx[44] = r;
|
||||
vx[45] = g;
|
||||
vx[46] = b;
|
||||
vx[47] = a;
|
||||
|
||||
qi++;
|
||||
}
|
||||
|
||||
free(tiles);
|
||||
free(keys);
|
||||
|
||||
// Slow-render breakdown: separates libass's own cost (rasterize/blur/shape)
|
||||
// from this function's packing + memcpy, so device logs attribute the time.
|
||||
// from this function's packing/compositing, so device logs attribute the time.
|
||||
const long long tEnd = nowMs();
|
||||
if (tEnd - t0 > 40) {
|
||||
__android_log_print(
|
||||
ANDROID_LOG_WARN, LOG_TAG,
|
||||
"slow render t=%lldms: total=%lldms ass=%lldms pack+copy=%lldms images=%d srcPx=%lldk "
|
||||
"atlas=%dx%d pages=%d quads=%d",
|
||||
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, n, srcPixels / 1000, atlasMaxW, atlasMaxH, pageCount, qi);
|
||||
"atlas=%dx%d pages=%d quads=%d mode=%d",
|
||||
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, pack.totalTiles, pack.srcPixels / 1000, atlasMaxW,
|
||||
atlasMaxH, pack.pageCount, pack.quadCount, pack.mode);
|
||||
}
|
||||
|
||||
// atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width);
|
||||
// pageHeights/pageQuadCounts describe the per-page upload + draw ranges.
|
||||
// ATLAS: atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width)
|
||||
// and pageHeights/pageQuadCounts describe the per-page upload + draw ranges.
|
||||
// COMPOSITE: atlasWidth × pageHeights[0] are the RGBA rect dims for the one quad.
|
||||
return writeAtlasHeader(
|
||||
env, headerBuf, atlasMaxW, qi, changed, truncated, requiredPages, 1, pageCount, pageHeights, pageQuads);
|
||||
env, headerBuf, pack.atlasWidth, pack.quadCount, changed, pack.truncated, pack.requiredPages,
|
||||
pack.totalTiles > 0 ? 1 : 0, pack.pageCount, pack.pageHeights, pack.pageQuads, pack.mode);
|
||||
}
|
||||
|
||||
// --- AssFrameTimestamps (EGL_ANDROID_get_frame_timestamps) ---
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#include "AssPack.h"
|
||||
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
// A tile is a <= atlasMaxW x atlasMaxH sub-rect of an ASS_Image. A single image
|
||||
// can exceed one atlas page only when the render frame is larger than a page
|
||||
// (>4K, or a GPU whose max texture is below the frame) — multi-page can't split
|
||||
// one image across pages (a quad samples one texture), so tiling does, keeping
|
||||
// the never-drop guarantee. (#1436 itself was atlas-AREA overflow, fixed by the
|
||||
// multi-page pack below, not oversized single images.) Tiles are built in list
|
||||
// order (= libass blend/painter order, preserved for emission); the single-page
|
||||
// pack runs height-sorted via a separate key array so emission order is untouched.
|
||||
typedef struct {
|
||||
ASS_Image* img; // source image (for bitmap/stride/color/dst_x/dst_y)
|
||||
int ox, oy; // tile offset within the source bitmap
|
||||
int tw, th; // tile size (<= atlasMaxW x atlasMaxH)
|
||||
int page; // atlas page the tile is packed into; -1 if dropped for capacity
|
||||
int sx, sy; // packed slot within the page; valid when page >= 0
|
||||
} PackTile;
|
||||
|
||||
typedef struct {
|
||||
int th; // tile height (the sort key)
|
||||
int idx; // index into the build-order tiles[] array
|
||||
} TileSortKey;
|
||||
|
||||
static int compareTileKeysByHeightDesc(const void* a, const void* b) {
|
||||
return ((const TileSortKey*)b)->th - ((const TileSortKey*)a)->th;
|
||||
}
|
||||
|
||||
// 8 floats per vertex (x, y, u, v, r, g, b, a) x 6 vertices; layout must match
|
||||
// BYTES_PER_QUAD/VERTEX in AssSubtitleAtlasPipeline.kt.
|
||||
static void emitQuad(
|
||||
float* vx, float x0, float y0, float x1, float y1, float u0, float v0, float u1, float v1, float r, float g,
|
||||
float b, float a) {
|
||||
const float pos[6][2] = {{x0, y0}, {x1, y0}, {x0, y1}, {x1, y0}, {x1, y1}, {x0, y1}};
|
||||
const float uv[6][2] = {{u0, v0}, {u1, v0}, {u0, v1}, {u1, v0}, {u1, v1}, {u0, v1}};
|
||||
for (int i = 0; i < 6; i++) {
|
||||
*vx++ = pos[i][0];
|
||||
*vx++ = pos[i][1];
|
||||
*vx++ = uv[i][0];
|
||||
*vx++ = uv[i][1];
|
||||
*vx++ = r;
|
||||
*vx++ = g;
|
||||
*vx++ = b;
|
||||
*vx++ = a;
|
||||
}
|
||||
}
|
||||
|
||||
// Flattens the whole image list into one premultiplied RGBA rect (the union
|
||||
// bounding box) at the start of `atlasPixels`, emitted as a single quad. The
|
||||
// blend is libass painter-order src-over, the same math the GL path applies to
|
||||
// alpha-atlas quads, so output is visually identical — memory just becomes
|
||||
// O(union area <= frame area) instead of O(sum of image areas). Used for
|
||||
// frames whose summed image area cannot fit ASS_PACK_MAX_PAGES alpha pages
|
||||
// (#1868: ~150 overlapping paint-strokes put ~5 pages of tiles at 1080p and
|
||||
// ~19 at 4K behind a 4-page cap, silently dropping the painter-order tail —
|
||||
// the sign's text).
|
||||
static void compositeFrame(
|
||||
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, size_t pageBytes, float* vertices, size_t vertexCap,
|
||||
AssPackResult* out) {
|
||||
int ux0 = INT_MAX, uy0 = INT_MAX, ux1 = INT_MIN, uy1 = INT_MIN;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
if (img->dst_x < ux0) ux0 = img->dst_x;
|
||||
if (img->dst_y < uy0) uy0 = img->dst_y;
|
||||
if (img->dst_x + img->w > ux1) ux1 = img->dst_x + img->w;
|
||||
if (img->dst_y + img->h > uy1) uy1 = img->dst_y + img->h;
|
||||
}
|
||||
const int uw = ux1 - ux0;
|
||||
const int uh = uy1 - uy0;
|
||||
const size_t rgbaBytes = (size_t)uw * uh * 4;
|
||||
|
||||
out->mode = ASS_PACK_MODE_COMPOSITE;
|
||||
out->requiredPages = (int)((rgbaBytes + pageBytes - 1) / pageBytes);
|
||||
out->pageCount = 1;
|
||||
if (rgbaBytes > atlasCap || vertexCap < 192) {
|
||||
// Buffers too small for the flattened rect: report the needed capacity and
|
||||
// write nothing — the caller grows and re-renders (same contract as the
|
||||
// multi-page atlas grow). truncated flags the frame as not presentable.
|
||||
out->truncated = out->totalTiles;
|
||||
return;
|
||||
}
|
||||
|
||||
memset(atlasPixels, 0, rgbaBytes);
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
const unsigned int c = img->color;
|
||||
const unsigned cr = (c >> 24) & 0xFFu;
|
||||
const unsigned cg = (c >> 16) & 0xFFu;
|
||||
const unsigned cb = (c >> 8) & 0xFFu;
|
||||
const unsigned ca = 0xFFu - (c & 0xFFu);
|
||||
if (ca == 0) continue;
|
||||
for (int y = 0; y < img->h; y++) {
|
||||
const uint8_t* src = img->bitmap + (size_t)y * img->stride;
|
||||
uint8_t* dst = atlasPixels + (((size_t)(img->dst_y - uy0 + y) * uw) + (size_t)(img->dst_x - ux0)) * 4;
|
||||
for (int x = 0; x < img->w; x++, dst += 4) {
|
||||
const unsigned a = (src[x] * ca + 127u) / 255u;
|
||||
if (a == 0) continue;
|
||||
const unsigned inv = 255u - a;
|
||||
dst[0] = (uint8_t)((cr * a + dst[0] * inv + 127u) / 255u);
|
||||
dst[1] = (uint8_t)((cg * a + dst[1] * inv + 127u) / 255u);
|
||||
dst[2] = (uint8_t)((cb * a + dst[2] * inv + 127u) / 255u);
|
||||
dst[3] = (uint8_t)((255u * a + dst[3] * inv + 127u) / 255u);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
out->atlasWidth = uw;
|
||||
out->pageHeights[0] = uh;
|
||||
out->pageQuads[0] = 1;
|
||||
out->quadCount = 1;
|
||||
out->truncated = 0;
|
||||
emitQuad(
|
||||
vertices, (float)ux0, (float)uy0, (float)(ux0 + uw), (float)(uy0 + uh), 0.0f, 0.0f, 1.0f, 1.0f, 1.0f, 1.0f, 1.0f,
|
||||
1.0f);
|
||||
}
|
||||
|
||||
int ass_pack_frame(
|
||||
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices,
|
||||
size_t vertexCap, AssPackResult* out) {
|
||||
memset(out, 0, sizeof(*out));
|
||||
out->mode = ASS_PACK_MODE_ATLAS;
|
||||
out->requiredPages = 1;
|
||||
out->pageCount = 1;
|
||||
|
||||
// 48 floats per quad x 4 bytes = 192 bytes/quad
|
||||
const int maxQuads = (int)(vertexCap / 192);
|
||||
const size_t pageBytes = (size_t)atlasMaxW * atlasMaxH;
|
||||
int providedPages = (int)(atlasCap / pageBytes);
|
||||
if (providedPages > ASS_PACK_MAX_PAGES) providedPages = ASS_PACK_MAX_PAGES;
|
||||
|
||||
// Split every image into <= atlasMaxW x atlasMaxH tiles, then pack the tiles.
|
||||
// tiles[] stays in list order (= blend/painter order for emission); keys[] is
|
||||
// sorted by height for the single-page pack so it produces tight rows.
|
||||
int total = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w > 0 && img->h > 0) {
|
||||
int cols = (img->w + atlasMaxW - 1) / atlasMaxW;
|
||||
int rows = (img->h + atlasMaxH - 1) / atlasMaxH;
|
||||
total += cols * rows;
|
||||
}
|
||||
}
|
||||
out->totalTiles = total;
|
||||
if (total == 0) return 1;
|
||||
|
||||
PackTile* tiles = (PackTile*)malloc(sizeof(PackTile) * (size_t)total);
|
||||
TileSortKey* keys = (TileSortKey*)malloc(sizeof(TileSortKey) * (size_t)total);
|
||||
if (!tiles || !keys) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
return 0;
|
||||
}
|
||||
int n = 0;
|
||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||
if (img->w <= 0 || img->h <= 0) continue;
|
||||
out->srcPixels += (long long)img->w * img->h;
|
||||
for (int oy = 0; oy < img->h; oy += atlasMaxH) {
|
||||
int th = img->h - oy;
|
||||
if (th > atlasMaxH) th = atlasMaxH;
|
||||
for (int ox = 0; ox < img->w; ox += atlasMaxW) {
|
||||
int tw = img->w - ox;
|
||||
if (tw > atlasMaxW) tw = atlasMaxW;
|
||||
tiles[n] = (PackTile){.img = img, .ox = ox, .oy = oy, .tw = tw, .th = th, .page = -1, .sx = -1, .sy = -1};
|
||||
keys[n] = (TileSortKey){.th = th, .idx = n};
|
||||
n++;
|
||||
}
|
||||
}
|
||||
}
|
||||
int truncated = 0;
|
||||
|
||||
// Pass 1a: height-sorted single page — the common case, minimal packed height
|
||||
// (byte-identical to the prior single-page packer when the frame fits one page).
|
||||
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
|
||||
int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[keys[i].idx];
|
||||
if (accepted >= maxQuads) break;
|
||||
int cx = cursorX, cy = cursorY, rh = rowH;
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) continue; // doesn't fit a single page
|
||||
t->page = 0;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
cursorX = cx + t->tw;
|
||||
cursorY = cy;
|
||||
rowH = (t->th > rh) ? t->th : rh;
|
||||
if (cy + t->th > packedH) packedH = cy + t->th;
|
||||
accepted++;
|
||||
}
|
||||
|
||||
if (accepted == n) {
|
||||
out->pageHeights[0] = packedH;
|
||||
out->pageQuads[0] = accepted;
|
||||
} else {
|
||||
// Pass 1b: the frame overflows one page. Re-pack in list order, starting a new
|
||||
// page whenever a tile won't fit the current one. List order keeps the page
|
||||
// index monotonic in painter order, so each page's quads stay one contiguous run.
|
||||
for (int i = 0; i < n; i++) {
|
||||
tiles[i].page = -1;
|
||||
tiles[i].sx = -1;
|
||||
tiles[i].sy = -1;
|
||||
}
|
||||
int requiredPages = 1;
|
||||
int page = 0, cx = 0, cy = 0, rh = 0, placed = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (cx + t->tw > atlasMaxW) {
|
||||
cy += rh;
|
||||
cx = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (cy + t->th > atlasMaxH) {
|
||||
page++;
|
||||
cx = 0;
|
||||
cy = 0;
|
||||
rh = 0;
|
||||
}
|
||||
if (page + 1 > requiredPages) requiredPages = page + 1;
|
||||
if (page < providedPages && placed < maxQuads) {
|
||||
t->page = page;
|
||||
t->sx = cx;
|
||||
t->sy = cy;
|
||||
if (cy + t->th > out->pageHeights[page]) out->pageHeights[page] = cy + t->th;
|
||||
out->pageQuads[page]++;
|
||||
placed++;
|
||||
}
|
||||
cx += t->tw;
|
||||
rh = (t->th > rh) ? t->th : rh;
|
||||
}
|
||||
if (requiredPages > ASS_PACK_MAX_PAGES || n > maxQuads) {
|
||||
// The frame can never fit the paged alpha atlas: its tiles exceed the page
|
||||
// cap or the vertex budget outright. Flatten instead of dropping the
|
||||
// painter-order tail (#1868).
|
||||
free(tiles);
|
||||
free(keys);
|
||||
memset(out->pageHeights, 0, sizeof(out->pageHeights));
|
||||
memset(out->pageQuads, 0, sizeof(out->pageQuads));
|
||||
compositeFrame(image, atlasPixels, atlasCap, pageBytes, vertices, vertexCap, out);
|
||||
return 1;
|
||||
}
|
||||
out->requiredPages = requiredPages;
|
||||
out->pageCount = (requiredPages < providedPages) ? requiredPages : providedPages;
|
||||
accepted = placed;
|
||||
truncated = n - placed;
|
||||
}
|
||||
|
||||
out->truncated = truncated;
|
||||
if (accepted == 0) {
|
||||
free(tiles);
|
||||
free(keys);
|
||||
memset(out->pageHeights, 0, sizeof(out->pageHeights));
|
||||
memset(out->pageQuads, 0, sizeof(out->pageQuads));
|
||||
return 1;
|
||||
}
|
||||
|
||||
// Clear only the packed rows of each written page.
|
||||
for (int p = 0; p < out->pageCount; p++) {
|
||||
memset(atlasPixels + (size_t)p * pageBytes, 0, (size_t)atlasMaxW * out->pageHeights[p]);
|
||||
}
|
||||
|
||||
// Emit tiles in list order (= libass's painter/blend order), copying each placed
|
||||
// tile into its page slot and emitting its quad. Monotonic page assignment makes
|
||||
// each page's quads a contiguous run, matching pageQuads[] for the per-page draw.
|
||||
int qi = 0;
|
||||
for (int i = 0; i < n; i++) {
|
||||
PackTile* t = &tiles[i];
|
||||
if (t->page < 0) continue;
|
||||
ASS_Image* img = t->img;
|
||||
const int px = t->sx;
|
||||
const int py = t->sy;
|
||||
uint8_t* pageBase = atlasPixels + (size_t)t->page * pageBytes;
|
||||
|
||||
for (int y = 0; y < t->th; y++) {
|
||||
uint8_t* dst = pageBase + (size_t)(py + y) * atlasMaxW + px;
|
||||
const uint8_t* src = img->bitmap + (size_t)(t->oy + y) * img->stride + t->ox;
|
||||
memcpy(dst, src, (size_t)t->tw);
|
||||
}
|
||||
|
||||
const unsigned int c = img->color;
|
||||
emitQuad(
|
||||
vertices + (size_t)qi * 48, (float)(img->dst_x + t->ox), (float)(img->dst_y + t->oy),
|
||||
(float)(img->dst_x + t->ox + t->tw), (float)(img->dst_y + t->oy + t->th), (float)px / (float)atlasMaxW,
|
||||
(float)py / (float)atlasMaxH, (float)(px + t->tw) / (float)atlasMaxW, (float)(py + t->th) / (float)atlasMaxH,
|
||||
(float)((c >> 24) & 0xFFu) / 255.0f, (float)((c >> 16) & 0xFFu) / 255.0f, (float)((c >> 8) & 0xFFu) / 255.0f,
|
||||
(float)(0xFFu - (c & 0xFFu)) / 255.0f);
|
||||
qi++;
|
||||
}
|
||||
|
||||
free(tiles);
|
||||
free(keys);
|
||||
out->quadCount = qi;
|
||||
out->atlasWidth = atlasMaxW;
|
||||
return 1;
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
// Pure-C frame packer behind the JNI atlas render entry point (AssKt.c).
|
||||
// Kept free of JNI/Android includes so desktop test harnesses can compile the
|
||||
// exact shipped packing/composite logic against a host libass build.
|
||||
#ifndef PLEZY_ASS_PACK_H
|
||||
#define PLEZY_ASS_PACK_H
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#include "ass/ass.h"
|
||||
|
||||
// Hard cap on atlas pages. 4 pages of a GL-max texture covers the worst real
|
||||
// frame measured for ordinary typesetting (a 4K-rendered full-screen letter
|
||||
// needs 3); denser frames (#1868: ~1500 overlapping paint-stroke images whose
|
||||
// summed area is ~19 pages at 4K) fall back to the RGBA composite below instead
|
||||
// of dropping tiles. Must match AssAtlasPipelineConfig.MAX_ATLAS_PAGES.
|
||||
#define ASS_PACK_MAX_PAGES 4
|
||||
|
||||
// Result modes. ATLAS: one or more ALPHA_8 pages plus per-quad vertices, the
|
||||
// common fast path. COMPOSITE: the frame's images were flattened CPU-side into
|
||||
// a single premultiplied RGBA rect (the union bounding box) drawn as one quad —
|
||||
// memory is bounded by frame area instead of the sum of per-image areas.
|
||||
#define ASS_PACK_MODE_ATLAS 0
|
||||
#define ASS_PACK_MODE_COMPOSITE 1
|
||||
|
||||
typedef struct {
|
||||
int mode; // ASS_PACK_MODE_*
|
||||
// ATLAS: row stride of every page. COMPOSITE: width of the RGBA rect.
|
||||
int atlasWidth;
|
||||
int quadCount;
|
||||
// Tiles dropped for capacity (frame incomplete). Composite output never
|
||||
// drops content; it reports truncated == totalTiles only in the
|
||||
// nothing-written grow request state (quadCount == 0).
|
||||
int truncated;
|
||||
// Pages of caller buffer capacity this frame needs to render completely.
|
||||
// When it exceeds the provided capacity the caller grows and re-renders.
|
||||
int requiredPages;
|
||||
int pageCount;
|
||||
int totalTiles; // tiles the frame splits into (caller-side logging)
|
||||
long long srcPixels; // summed source image area (caller-side logging)
|
||||
// ATLAS: packed rows per page / quads per page (contiguous vertex runs).
|
||||
// COMPOSITE: pageHeights[0] = RGBA rect height, pageQuads[0] = 1.
|
||||
int pageHeights[ASS_PACK_MAX_PAGES];
|
||||
int pageQuads[ASS_PACK_MAX_PAGES];
|
||||
} AssPackResult;
|
||||
|
||||
// Packs libass's image list for `atlasPixels`/`vertices` (layout documented at
|
||||
// the JNI entry point in AssKt.c). Never drops content for size: frames that
|
||||
// cannot fit ASS_PACK_MAX_PAGES alpha pages (or the vertex budget) flatten into
|
||||
// an RGBA composite; when the provided buffers are too small for either
|
||||
// representation, requiredPages tells the caller how much to grow before
|
||||
// re-rendering, and nothing is written. Returns 0 only on allocation failure.
|
||||
int ass_pack_frame(
|
||||
ASS_Image* image, uint8_t* atlasPixels, size_t atlasCap, int atlasMaxW, int atlasMaxH, float* vertices,
|
||||
size_t vertexCap, AssPackResult* out);
|
||||
|
||||
#endif // PLEZY_ASS_PACK_H
|
||||
@@ -44,7 +44,7 @@ set_target_properties(ass PROPERTIES
|
||||
IMPORTED_LOCATION "${LIBASS_ARCHIVE}")
|
||||
target_include_directories(ass INTERFACE "${LIBASS_ROOT}/include")
|
||||
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c SurfaceTxProbe.c)
|
||||
add_library(${CMAKE_PROJECT_NAME} SHARED AssKt.c AssPack.c SurfaceTxProbe.c)
|
||||
add_dependencies(${CMAKE_PROJECT_NAME} libass_prebuilt_${LIBASS_ABI_TARGET})
|
||||
# HarfBuzz brings C++; link the shared STL that the app already packages.
|
||||
# (SurfaceTxProbe resolves its libandroid/libsync entry points via dlsym, so no extra link.)
|
||||
|
||||
@@ -4,33 +4,44 @@ package com.edde746.plezy.libass
|
||||
* Result of a packed-atlas render. The atlas pixel data is stored in the direct ByteBuffer
|
||||
* that was passed into [AssRender.renderFrameAtlas]; the vertex stream is in the other.
|
||||
*
|
||||
* The atlas may span more than one *page* — a heavy full-screen sign can produce more
|
||||
* sub-pixels than a single GL-max texture holds. Pages are vertically stacked in the
|
||||
* atlas ByteBuffer (page `p` at byte offset `p * atlasWidth * atlasMaxHeight`), each its
|
||||
* own texture, and are drawn in turn. Quads are emitted in libass painter order and page
|
||||
* assignment is monotonic in that order, so each page's quads form one contiguous run in
|
||||
* the vertex stream ([pageQuadCounts]); the runner uploads page `p`, then draws its run.
|
||||
* In the common [MODE_ATLAS] the atlas may span more than one ALPHA_8 *page* — a heavy
|
||||
* full-screen sign can produce more sub-pixels than a single GL-max texture holds. Pages
|
||||
* are vertically stacked in the atlas ByteBuffer (page `p` at byte offset
|
||||
* `p * atlasWidth * atlasMaxHeight`), each its own texture, and are drawn in turn. Quads
|
||||
* are emitted in libass painter order and page assignment is monotonic in that order, so
|
||||
* each page's quads form one contiguous run in the vertex stream ([pageQuadCounts]); the
|
||||
* runner uploads page `p`, then draws its run.
|
||||
*
|
||||
* In [MODE_COMPOSITE] the frame was too dense for the paged atlas (its summed image area
|
||||
* exceeds every page: overlapping paint-stroke signs, #1868) and the native side flattened
|
||||
* it into one premultiplied RGBA rect of [atlasWidth] × `pageHeights[0]` pixels at the
|
||||
* start of the atlas ByteBuffer, drawn as the single emitted quad with UVs 0..1.
|
||||
*
|
||||
* Built in Kotlin by [AssRender.renderFrameAtlas] from the int[] header the native
|
||||
* renderer fills (see `writeAtlasHeader` in AssKt.c) — never constructed from JNI, so
|
||||
* the minifier may obfuscate it freely without breaking the native boundary.
|
||||
*
|
||||
* @param atlasWidth atlas row stride in pixels (= the allocated width; same for every
|
||||
* page; 0 when [changed] == 0)
|
||||
* page; 0 when [changed] == 0) — or the RGBA rect width in
|
||||
* [MODE_COMPOSITE]
|
||||
* @param pageHeights packed height (rows worth uploading) of each page; `size` = page count
|
||||
* @param pageQuadCounts quads on each page, contiguous in the vertex stream in this order;
|
||||
* `size` = page count, `sum` = [quadCount]
|
||||
* @param quadCount total quads; the vertex buffer holds [quadCount] * 6 vertices
|
||||
* @param changed libass change flag (0 = no change, 1 = positions, 2 = content)
|
||||
* @param truncated images dropped because the frame needed more than [requiredPages]
|
||||
* pages of capacity or exceeded the vertex budget; the frame is
|
||||
* incomplete but never stale (should be unreachable for real content)
|
||||
* @param requiredPages pages this frame needs to render completely. When it exceeds
|
||||
* [pageHeights].size the caller must grow the atlas buffer and
|
||||
* re-render; the rendered pages are still valid in the meantime.
|
||||
* @param truncated images dropped because the frame needed more capacity than the
|
||||
* buffer holds; the frame is incomplete but never stale. Recoverable
|
||||
* by growing to [requiredPages]; with the composite fallback,
|
||||
* unrecoverable truncation should be unreachable for real content
|
||||
* @param requiredPages pages of buffer capacity this frame needs to render completely.
|
||||
* When it exceeds [pageHeights].size the caller must grow the atlas
|
||||
* buffer and re-render; the rendered pages are still valid in the
|
||||
* meantime.
|
||||
* @param hasOutput true when libass reported at least one visible image for this
|
||||
* timestamp, even when [changed] is 0 and the buffers were not
|
||||
* rewritten. false means this timestamp should be blank.
|
||||
* @param mode [MODE_ATLAS] or [MODE_COMPOSITE]; must match the ASS_PACK_MODE_*
|
||||
* constants in AssPack.h
|
||||
*/
|
||||
class AssAtlasFrame(
|
||||
val atlasWidth: Int,
|
||||
@@ -40,8 +51,17 @@ class AssAtlasFrame(
|
||||
val changed: Int,
|
||||
val truncated: Int,
|
||||
val requiredPages: Int,
|
||||
val hasOutput: Boolean
|
||||
val hasOutput: Boolean,
|
||||
val mode: Int = MODE_ATLAS
|
||||
) {
|
||||
companion object {
|
||||
/** One or more ALPHA_8 atlas pages, per-quad colors in the vertex stream. */
|
||||
const val MODE_ATLAS = 0
|
||||
|
||||
/** One premultiplied RGBA rect at the start of the atlas buffer, one quad. */
|
||||
const val MODE_COMPOSITE = 1
|
||||
}
|
||||
|
||||
/** Number of atlas pages this frame occupies. */
|
||||
val pageCount: Int get() = pageHeights.size
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
|
||||
|
||||
companion object {
|
||||
|
||||
/** Must match MAX_ATLAS_PAGES + the header layout in AssKt.c (`writeAtlasHeader`). */
|
||||
/** Must match ASS_PACK_MAX_PAGES + the header layout in AssPack.h/AssKt.c (`writeAtlasHeader`). */
|
||||
private const val MAX_ATLAS_PAGES = 4
|
||||
private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES
|
||||
private const val HEADER_INTS = 7 + 2 * MAX_ATLAS_PAGES + 1
|
||||
|
||||
@JvmStatic
|
||||
external fun nativeAssRenderInit(ass: Long): Long
|
||||
@@ -175,7 +175,8 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
|
||||
changed = header[2],
|
||||
truncated = header[3],
|
||||
requiredPages = header[4],
|
||||
hasOutput = header[5] != 0
|
||||
hasOutput = header[5] != 0,
|
||||
mode = header[7 + 2 * MAX_ATLAS_PAGES]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+86
-10
@@ -58,9 +58,11 @@ internal object AssAtlasPipelineConfig {
|
||||
* Hard cap on vertically-stacked atlas pages per slot. A frame whose packed
|
||||
* sub-pixels exceed one [ATLAS_PIXEL_BUDGET] texture (a 4K-rendered full-screen
|
||||
* typeset sign) spills into extra pages so nothing is dropped (#1436); the atlas
|
||||
* buffer grows on demand toward this cap. 4 covers the worst frame measured (a 4K
|
||||
* letter needs 3); past it tiles are dropped and counted in `truncated`. Must match
|
||||
* MAX_ATLAS_PAGES in AssKt.c.
|
||||
* buffer grows on demand toward this cap. 4 covers the worst frame measured for
|
||||
* ordinary typesetting (a 4K letter needs 3); frames too dense even for that
|
||||
* (overlapping paint-stroke signs, #1868) flatten into a single RGBA composite
|
||||
* ([AssAtlasFrame.MODE_COMPOSITE]) instead of dropping tiles. Must match
|
||||
* ASS_PACK_MAX_PAGES in AssPack.h.
|
||||
*/
|
||||
internal const val MAX_ATLAS_PAGES = 4
|
||||
|
||||
@@ -718,8 +720,9 @@ private class AtlasLibassThread(
|
||||
var frame = render.renderFrameAtlas(timeMs, payload.atlasBuf, slots.atlasW, slots.atlasH, payload.vertexBuf)
|
||||
?: return null
|
||||
// A frame overflows one atlas page only on dense full-screen typesetting. When it
|
||||
// does, grow this slot's buffer to the pages it needs (capped) and render once more
|
||||
// — libass's caches make the re-render cheap, and the slot keeps the larger buffer
|
||||
// does — multi-page atlas or an RGBA composite rect needing more than one page —
|
||||
// grow this slot's buffer to the pages it needs (capped) and render once more:
|
||||
// libass's caches make the re-render cheap, and the slot keeps the larger buffer
|
||||
// so the same density never re-grows. The truncated first result is never handed off.
|
||||
if (frame.requiredPages > payload.pageCapacity && payload.pageCapacity < AssAtlasPipelineConfig.MAX_ATLAS_PAGES) {
|
||||
payload.growAtlas(
|
||||
@@ -794,7 +797,8 @@ private class AtlasLibassThread(
|
||||
"releaseLeadMs=${request.releaseLeadNs / 1_000_000} seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " +
|
||||
"libassMs=$lastLibassMs lockWaitMs=${assHandler.render?.lastLockWaitMs} " +
|
||||
"specHit=${outcome.specHit} changed=${payload.frame.changed} output=${payload.frame.hasOutput} quads=${payload.frame.quadCount} " +
|
||||
"atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated}"
|
||||
"atlas=${payload.frame.atlasWidth}x${payload.frame.atlasHeight} truncated=${payload.frame.truncated} " +
|
||||
"mode=${payload.frame.mode}"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1403,15 +1407,19 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
// u_Rgba switches the sampling mode: 0 = ALPHA_8 atlas mask tinted by the per-vertex
|
||||
// color (the common path), 1 = premultiplied RGBA composite sampled directly
|
||||
// (AssAtlasFrame.MODE_COMPOSITE). Both end premultiplied, matching the blend state.
|
||||
private val fragmentShaderCode = """
|
||||
precision mediump float;
|
||||
varying vec2 v_TexCoord;
|
||||
varying vec4 v_Color;
|
||||
uniform sampler2D u_Texture;
|
||||
uniform float u_Rgba;
|
||||
void main() {
|
||||
float mask = texture2D(u_Texture, v_TexCoord).a;
|
||||
float alpha = v_Color.a * mask;
|
||||
gl_FragColor = vec4(v_Color.rgb * alpha, alpha);
|
||||
vec4 texel = texture2D(u_Texture, v_TexCoord);
|
||||
float alpha = v_Color.a * texel.a;
|
||||
gl_FragColor = mix(vec4(v_Color.rgb * alpha, alpha), texel, u_Rgba);
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
@@ -1423,11 +1431,18 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
private var allocatedPages = 0
|
||||
private var vertexBufferId = 0
|
||||
|
||||
// Texture for MODE_COMPOSITE frames (premultiplied RGBA rect); allocated lazily on
|
||||
// the first composite frame and re-specced when the rect outgrows it.
|
||||
private var rgbaTexId = 0
|
||||
private var rgbaAllocW = 0
|
||||
private var rgbaAllocH = 0
|
||||
|
||||
private var aPosition = 0
|
||||
private var aTexCoord = 0
|
||||
private var aColor = 0
|
||||
private var uTexture = 0
|
||||
private var uSurfaceSize = 0
|
||||
private var uRgba = 0
|
||||
|
||||
private var atlasAllocatedW = 0
|
||||
private var atlasAllocatedH = 0
|
||||
@@ -1477,6 +1492,7 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
aColor = glProgram.getAttributeArrayLocationAndEnable("a_Color")
|
||||
uTexture = glProgram.getUniformLocation("u_Texture")
|
||||
uSurfaceSize = glProgram.getUniformLocation("u_SurfaceSize")
|
||||
uRgba = glProgram.getUniformLocation("u_Rgba")
|
||||
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
GLES20.glUniform1i(uTexture, 0)
|
||||
@@ -1527,11 +1543,22 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
GLES20.glVertexAttribPointer(aPosition, 2, GLES20.GL_FLOAT, false, stride, 0)
|
||||
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, stride, 8)
|
||||
GLES20.glVertexAttribPointer(aColor, 4, GLES20.GL_FLOAT, false, stride, 16)
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
|
||||
if (frame.mode == AssAtlasFrame.MODE_COMPOSITE) {
|
||||
// The whole frame is one premultiplied RGBA rect (atlasWidth × pageHeights[0])
|
||||
// at the start of the atlas buffer, drawn as the single emitted quad.
|
||||
GLES20.glUniform1f(uRgba, 1f)
|
||||
bindCompositeTexture()
|
||||
if (!reuseUploads) uploadComposite(payload.atlasBuf, frame.atlasWidth, frame.pageHeights[0])
|
||||
GLES20.glDrawArrays(GLES20.GL_TRIANGLES, 0, quadCount * 6)
|
||||
return
|
||||
}
|
||||
|
||||
GLES20.glUniform1f(uRgba, 0f)
|
||||
// Each atlas page is its own texture; its quads are one contiguous run in the
|
||||
// stream (page assignment is monotonic in painter order). Upload + draw each in
|
||||
// turn, which reproduces the libass blend order across pages.
|
||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||
var quadOffset = 0
|
||||
for (p in 0 until frame.pageCount) {
|
||||
val pageQuads = frame.pageQuadCounts[p]
|
||||
@@ -1545,6 +1572,48 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Generates (once) and binds the RGBA composite texture. */
|
||||
private fun bindCompositeTexture() {
|
||||
if (rgbaTexId == 0) {
|
||||
val tex = IntArray(1)
|
||||
GLES20.glGenTextures(1, tex, 0)
|
||||
rgbaTexId = tex[0]
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_CLAMP_TO_EDGE)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR)
|
||||
GLES20.glTexParameteri(GLES20.GL_TEXTURE_2D, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR)
|
||||
rgbaAllocW = 0
|
||||
rgbaAllocH = 0
|
||||
} else {
|
||||
GLES20.glBindTexture(GLES20.GL_TEXTURE_2D, rgbaTexId)
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads the composite RGBA rect from the start of the stacked atlas buffer into
|
||||
* the bound composite texture. GLES2 has no UNPACK_ROW_LENGTH, so a sub-image
|
||||
* update is only stride-correct at the allocated width; otherwise re-spec. */
|
||||
private fun uploadComposite(atlasBuf: ByteBuffer, width: Int, height: Int) {
|
||||
if (width <= 0 || height <= 0) return
|
||||
atlasBuf.clear()
|
||||
atlasBuf.limit(width * height * 4)
|
||||
atlasBuf.position(0)
|
||||
if (width == rgbaAllocW && height <= rgbaAllocH) {
|
||||
GLES20.glTexSubImage2D(
|
||||
GLES20.GL_TEXTURE_2D, 0, 0, 0, width, height,
|
||||
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf
|
||||
)
|
||||
} else {
|
||||
GLES20.glTexImage2D(
|
||||
GLES20.GL_TEXTURE_2D, 0, GLES20.GL_RGBA,
|
||||
width, height, 0,
|
||||
GLES20.GL_RGBA, GLES20.GL_UNSIGNED_BYTE, atlasBuf
|
||||
)
|
||||
rgbaAllocW = width
|
||||
rgbaAllocH = height
|
||||
}
|
||||
}
|
||||
|
||||
/** Uploads page [page]'s packed rows from the stacked atlas buffer into the
|
||||
* currently-bound page texture. */
|
||||
private fun uploadPage(atlasBuf: ByteBuffer, page: Int, atlasW: Int, pageH: Int) {
|
||||
@@ -1578,6 +1647,13 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
||||
atlasTexIds.fill(0)
|
||||
allocatedPages = 0
|
||||
}
|
||||
if (rgbaTexId != 0) {
|
||||
val tex = intArrayOf(rgbaTexId)
|
||||
GLES20.glDeleteTextures(1, tex, 0)
|
||||
rgbaTexId = 0
|
||||
rgbaAllocW = 0
|
||||
rgbaAllocH = 0
|
||||
}
|
||||
if (vertexBufferId != 0) {
|
||||
val buf = intArrayOf(vertexBufferId)
|
||||
GLES20.glDeleteBuffers(1, buf, 0)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata id="metadata3396">
|
||||
<rdf:RDF>
|
||||
|
||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,4 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
||||
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||
<metadata id="metadata3886">
|
||||
<rdf:RDF>
|
||||
|
||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -1,4 +1,3 @@
|
||||
# Uncomment this line to define a global platform for your project
|
||||
platform :ios, '15.5'
|
||||
|
||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||
|
||||
@@ -14,9 +14,7 @@ import MediaPlayer
|
||||
_ application: UIApplication,
|
||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||
) -> Bool {
|
||||
// Configure audio session for media playback. Not activated here: the
|
||||
// session is non-mixing, so activation stops other apps' audio — it is
|
||||
// claimed when playback actually starts.
|
||||
// Configure the non-mixing session; activate it only when playback starts.
|
||||
do {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
@@ -32,12 +30,10 @@ import MediaPlayer
|
||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||
|
||||
// Register MPV player plugin
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") {
|
||||
MpvPlayerPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
// Register the audio-only MPV player plugin (music playback)
|
||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") {
|
||||
MpvAudioPlayerPlugin.register(with: registrar)
|
||||
}
|
||||
|
||||
@@ -39,19 +39,13 @@ import UIKit
|
||||
|
||||
/// Delegate to notify the plugin of PiP lifecycle events
|
||||
protocol MpvPipDelegate: AnyObject {
|
||||
/// Called when PiP is about to start (system or app-initiated)
|
||||
func pipWillStart()
|
||||
func pipDidStart()
|
||||
/// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI).
|
||||
func pipDidStop(restored: Bool)
|
||||
func pipDidFailToStart(error: Error?)
|
||||
/// Forward play/pause commands from PiP overlay to mpv
|
||||
func pipSetPlaying(_ playing: Bool)
|
||||
/// Forward skip forward/backward commands from PiP overlay to mpv
|
||||
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
|
||||
/// Query whether mpv is currently playing
|
||||
var isPipPlaying: Bool { get }
|
||||
/// Get total duration in seconds
|
||||
var pipDuration: Double { get }
|
||||
}
|
||||
protocol MpvPictureInPictureControlling: AnyObject {
|
||||
@@ -151,7 +145,6 @@ import UIKit
|
||||
createPipController()
|
||||
}
|
||||
|
||||
/// Helper that conforms to the iOS 15+ delegate protocols
|
||||
private var delegateHelper: AnyObject?
|
||||
|
||||
private func createPipController() {
|
||||
|
||||
@@ -12,12 +12,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
private weak var registrar: FlutterPluginRegistrar?
|
||||
var nameToId: [String: Int] = [:]
|
||||
|
||||
// MpvPluginShared conformance
|
||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
|
||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||
|
||||
// PiP
|
||||
private var pipController: MpvPipController?
|
||||
private var pipChannel: FlutterMethodChannel?
|
||||
private var autoPipEnabled = false
|
||||
@@ -207,7 +205,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
return
|
||||
}
|
||||
pip.setAutoStart(true)
|
||||
// Warm the layer so the system considers PiP possible
|
||||
if let pc = self.playerCore {
|
||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||
}
|
||||
@@ -287,7 +284,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
||||
}
|
||||
}
|
||||
|
||||
/// Unified cleanup for all PiP exit paths
|
||||
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
||||
playerCore?.setPipSubtitleCompositing(false)
|
||||
playerCore?.isPipStarting = false
|
||||
|
||||
@@ -13,13 +13,10 @@ platform :ios do
|
||||
lane :deploy_appstore do
|
||||
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
||||
|
||||
# Build the Flutter app
|
||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
|
||||
|
||||
# Upload debug symbols
|
||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
|
||||
|
||||
# Upload to App Store
|
||||
upload_to_app_store(
|
||||
ipa: "../build/ios/ipa/Plezy.ipa",
|
||||
force: true,
|
||||
|
||||
@@ -119,7 +119,6 @@ class AppDatabase extends _$AppDatabase {
|
||||
// migrations while failures are still covered by this close/rethrow
|
||||
// boundary and the caller's startup download-recovery decision.
|
||||
await database.customSelect('SELECT 1').get();
|
||||
// It deliberately does not claim capacity for a later write.
|
||||
}
|
||||
final outcome = await _tvosRecoveryQueue.run(
|
||||
() => store.reconcile(
|
||||
|
||||
@@ -74,7 +74,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
||||
final double viewportHeight = scrollController.position.viewportDimension;
|
||||
final double viewportBottom = viewportTop + viewportHeight;
|
||||
|
||||
// Already fully visible — skip
|
||||
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
||||
|
||||
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
||||
@@ -104,7 +103,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
||||
|
||||
final backResult = handleBackKeyAction(event, () {
|
||||
if (movingIndex != null) {
|
||||
// Cancel move - restore original position
|
||||
setState(() {
|
||||
final originalOrder = _originalOrder;
|
||||
if (originalOrder != null) {
|
||||
|
||||
@@ -42,7 +42,6 @@ class FocusMemoryTracker {
|
||||
/// Restore focus to the last focused item, or fallback if provided
|
||||
/// Returns true if focus was successfully restored
|
||||
bool restoreFocus({String? fallbackKey}) {
|
||||
// Try to restore last focused item
|
||||
if (_lastFocusedKey != null) {
|
||||
final node = _nodes[_lastFocusedKey];
|
||||
if (node != null) {
|
||||
@@ -50,7 +49,6 @@ class FocusMemoryTracker {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// Fallback: focus the provided key if available
|
||||
if (fallbackKey != null) {
|
||||
final node = _nodes[fallbackKey];
|
||||
if (node != null) {
|
||||
@@ -69,7 +67,6 @@ class FocusMemoryTracker {
|
||||
_nodes.remove(key);
|
||||
_focused.remove(key);
|
||||
}
|
||||
// Clear last focused if it was pruned
|
||||
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
|
||||
_lastFocusedKey = null;
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@ class _FocusableButtonState extends State<FocusableButton> {
|
||||
final showFocus = _isFocused && isKeyboard;
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
final enabled = widget.onPressed != null;
|
||||
// In dpad mode: focused = full opacity, unfocused = dimmed
|
||||
final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0;
|
||||
|
||||
return FocusableWrapper(
|
||||
|
||||
@@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox {
|
||||
|
||||
/// A wrapper widget that makes its child focusable with D-pad navigation support.
|
||||
///
|
||||
/// Provides:
|
||||
/// - Visual focus indicator (border + scale animation)
|
||||
/// - Keyboard/D-pad event handling (Enter/Select to activate)
|
||||
/// - Optional auto-scroll to keep focused item visible
|
||||
/// - Long-press detection for SELECT key
|
||||
/// - Navigation callbacks (UP, BACK)
|
||||
class FocusableWrapper extends StatefulWidget {
|
||||
/// The child widget to wrap.
|
||||
final Widget child;
|
||||
@@ -287,12 +281,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
void didUpdateWidget(FocusableWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
|
||||
// Handle focusNode changes
|
||||
if (widget.focusNode != oldWidget.focusNode) {
|
||||
_bindFocusNode();
|
||||
}
|
||||
|
||||
// Update canRequestFocus
|
||||
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
|
||||
_focusNode.canRequestFocus = widget.canRequestFocus;
|
||||
}
|
||||
@@ -368,7 +360,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
final viewport = scrollable.context.findRenderObject() as RenderBox?;
|
||||
if (viewport == null) return;
|
||||
|
||||
// Get item's position relative to viewport
|
||||
final itemBox = renderObject as RenderBox;
|
||||
final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport);
|
||||
|
||||
@@ -376,17 +367,14 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
final itemHeight = itemBox.size.height;
|
||||
final itemVerticalCenter = itemPosition.dy + itemHeight / 2;
|
||||
|
||||
// Account for focus decoration when checking item visibility
|
||||
final itemTop = itemPosition.dy - _focusDecorationPadding;
|
||||
final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding;
|
||||
|
||||
if (widget.useComfortableZone) {
|
||||
// Define comfortable zone - if item (including focus decoration) is within middle 60% of viewport, don't scroll
|
||||
final comfortZoneTop = viewportHeight * 0.2;
|
||||
final comfortZoneBottom = viewportHeight * 0.8;
|
||||
|
||||
if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) {
|
||||
// Item is in comfortable zone, no need to scroll
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
@@ -394,22 +382,17 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
// close to target position (prevents jitter when navigating horizontally)
|
||||
final targetY = viewportHeight * widget.scrollAlignment;
|
||||
final distance = (itemVerticalCenter - targetY).abs();
|
||||
// Skip scroll if within half the item height of target
|
||||
if (distance < itemHeight / 2) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate target scroll offset for the immediate scrollable only.
|
||||
// This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables,
|
||||
// which can cause issues with nested scroll views (e.g., chips bar scrolling
|
||||
// out of view when focusing grid items in library browse tab).
|
||||
// Avoid Scrollable.ensureVisible, which scrolls all ancestor scrollables and
|
||||
// can move nested views (e.g. the chips bar) out of view when focusing grid items.
|
||||
final position = scrollable.position;
|
||||
final currentOffset = position.pixels;
|
||||
|
||||
// Target: item center should be at scrollAlignment of viewport
|
||||
// Add padding to ensure focus decoration is fully visible
|
||||
final targetViewportY = viewportHeight * widget.scrollAlignment;
|
||||
|
||||
var scrollDelta = itemVerticalCenter - targetViewportY;
|
||||
|
||||
// If item would be near the top edge, add extra scroll to show focus decoration
|
||||
@@ -471,7 +454,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
}
|
||||
}
|
||||
|
||||
// Handle SELECT key with optional long-press detection
|
||||
if (key.isSelectKey) {
|
||||
if (widget.enableLongPress) {
|
||||
final result = _selectLongPress.handleKeyEvent(
|
||||
@@ -547,7 +529,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
||||
} else {
|
||||
final duration = FocusTheme.getAnimationDuration(context);
|
||||
final controller = _ensureAnimationController();
|
||||
// Update animation duration if theme changes
|
||||
if (controller.duration != duration) {
|
||||
controller.duration = duration;
|
||||
}
|
||||
|
||||
@@ -85,9 +85,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
|
||||
// is identity-guarded — otherwise startup's bootstrap→app swap would leave
|
||||
// the live registration cleared.
|
||||
InputModeTracker._instance = this;
|
||||
// Initialize focus highlight strategy based on starting mode
|
||||
_updateFocusHighlightStrategy(_mode);
|
||||
// Listen to hardware keyboard events globally
|
||||
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||
}
|
||||
|
||||
|
||||
@@ -114,13 +114,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
DownloadProvider({required this._downloadManager, required this._database})
|
||||
: _syncRuleExecutor = SyncRuleExecutor(database: _database) {
|
||||
_metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged);
|
||||
// Listen to progress updates from the download manager
|
||||
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
||||
|
||||
// Listen to deletion progress updates
|
||||
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
||||
|
||||
// Load persisted downloads from database
|
||||
_initFuture = _loadPersistedDownloads();
|
||||
|
||||
// Lets the diagnostics service score whether downloads actually advance
|
||||
@@ -400,7 +397,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
// Initialize artwork directory path for synchronous access
|
||||
await storageService.getArtworkDirectory();
|
||||
|
||||
// Load all downloads from database
|
||||
final downloads = await _downloadManager.getAllDownloads();
|
||||
|
||||
// Bulk-load all pinned metadata across every backend in a single pass
|
||||
@@ -427,7 +423,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
|
||||
// Load sync rules from database
|
||||
await _loadSyncRules();
|
||||
|
||||
// Apply queued offline watch actions on top of the server-time metadata
|
||||
@@ -844,7 +839,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
return null;
|
||||
}
|
||||
|
||||
// Calculate aggregate statistics
|
||||
int completedCount = 0;
|
||||
int downloadingCount = 0;
|
||||
int queuedCount = 0;
|
||||
@@ -867,7 +861,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
}
|
||||
}
|
||||
|
||||
// Determine overall status
|
||||
final DownloadStatus overallStatus;
|
||||
if (completedCount == totalEpisodes) {
|
||||
overallStatus = DownloadStatus.completed;
|
||||
@@ -912,22 +905,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
/// For shows/seasons, returns aggregate progress of all child episodes
|
||||
/// For episodes/movies, returns direct progress
|
||||
DownloadProgress? getProgress(String globalKey) {
|
||||
// First check if we have direct progress (for episodes/movies)
|
||||
final directProgress = _downloads[globalKey];
|
||||
if (directProgress != null) {
|
||||
if (!_ownsDownloadKey(globalKey)) return null;
|
||||
return directProgress;
|
||||
}
|
||||
|
||||
// If no direct progress, check if this is a show or season
|
||||
// and calculate aggregate progress from episodes
|
||||
final parsed = parseGlobalKey(globalKey);
|
||||
if (parsed == null) return null;
|
||||
|
||||
final serverId = parsed.serverId;
|
||||
final ratingKey = parsed.ratingKey;
|
||||
|
||||
// Try to get metadata to determine type
|
||||
final meta = _metadata[globalKey];
|
||||
if (meta == null) {
|
||||
// No metadata stored yet, might be a container (show/season/artist/
|
||||
@@ -1294,11 +1283,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
||||
await _claimDownloadForProfile(globalKey, ownership, client);
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||
|
||||
// Update local state immediately for UI feedback
|
||||
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
||||
safeNotifyListeners();
|
||||
|
||||
// Actually trigger download via DownloadManagerService
|
||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
|
||||
return true;
|
||||
|
||||
@@ -303,10 +303,8 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
return libraries;
|
||||
}
|
||||
|
||||
// Create a map for quick lookup
|
||||
final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
|
||||
|
||||
// Build ordered list based on saved order
|
||||
final orderedLibraries = <MediaLibrary>[];
|
||||
for (final key in savedOrder) {
|
||||
final lib = libraryMap.remove(key);
|
||||
@@ -315,7 +313,6 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
||||
}
|
||||
}
|
||||
|
||||
// Add any new libraries that weren't in the saved order
|
||||
orderedLibraries.addAll(libraryMap.values);
|
||||
|
||||
return orderedLibraries;
|
||||
|
||||
@@ -175,7 +175,6 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
||||
if (_isInitialized) return;
|
||||
_isInitialized = true;
|
||||
|
||||
// Check initial connectivity
|
||||
await _updateConnectionFlags();
|
||||
|
||||
// Monitor connectivity changes — runZonedGuarded catches async errors from
|
||||
|
||||
@@ -26,7 +26,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
final DownloadProvider _downloadProvider;
|
||||
|
||||
OfflineWatchProvider({required this._syncService, required this._downloadProvider}) {
|
||||
// Listen to sync service changes to update UI
|
||||
_syncService.addListener(_onSyncServiceChanged);
|
||||
}
|
||||
|
||||
@@ -49,13 +48,11 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
///
|
||||
/// Returns true if watched, false otherwise.
|
||||
Future<bool> isWatched(String globalKey) async {
|
||||
// First check local offline action
|
||||
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
||||
if (localStatus != null) {
|
||||
return localStatus;
|
||||
}
|
||||
|
||||
// Fall back to cached metadata
|
||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||
if (metadata != null) {
|
||||
return metadata.isWatched;
|
||||
@@ -73,7 +70,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
/// Returns null if no position is available.
|
||||
@visibleForTesting
|
||||
Future<int?> getViewOffset(String globalKey) async {
|
||||
// First check local offline progress
|
||||
final localOffset = await _syncService.getLocalViewOffset(globalKey);
|
||||
if (localOffset != null) {
|
||||
return localOffset;
|
||||
@@ -82,7 +78,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
||||
if (localStatus == true) return null;
|
||||
|
||||
// Fall back to cached metadata
|
||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||
return metadata?.viewOffsetMs;
|
||||
}
|
||||
@@ -127,7 +122,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
||||
|
||||
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
||||
|
||||
// Find first unwatched episode
|
||||
for (final episode in episodes) {
|
||||
if (!watchStatuses[episode.globalKey]!) {
|
||||
return episode;
|
||||
|
||||
@@ -103,18 +103,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
final TvSpotlightController _spotlight = TvSpotlightController();
|
||||
bool _isTabVisible = true;
|
||||
|
||||
// Track initial load so we can focus hero when content first appears
|
||||
bool _initialLoadComplete = false;
|
||||
bool _pendingTvBrowseRailFocus = false;
|
||||
|
||||
// Hub navigation keys
|
||||
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
||||
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
|
||||
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||
final _hubFocusMemory = HubFocusMemory();
|
||||
|
||||
// Hero and app bar focus
|
||||
late FocusNode _heroFocusNode;
|
||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||
final _serverActivitiesButtonKey = GlobalKey<ServerActivitiesButtonState>();
|
||||
@@ -155,7 +152,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
||||
}
|
||||
|
||||
/// Get all hub states (continue watching + other hubs)
|
||||
List<GlobalKey<HubSectionState>> get _allHubKeys {
|
||||
final keys = <GlobalKey<HubSectionState>>[];
|
||||
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
|
||||
@@ -935,7 +931,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
if (_isLoading) LoadingIndicatorBox.sliver,
|
||||
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
||||
if (!_isLoading && _errorMessage == null) ...[
|
||||
// On Deck / Continue Watching
|
||||
if (continueWatchingHub != null)
|
||||
SliverToBoxAdapter(
|
||||
child: HubSection(
|
||||
@@ -1259,14 +1254,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
|
||||
);
|
||||
|
||||
// Determine content type label for chip
|
||||
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
|
||||
|
||||
// Spoiler protection
|
||||
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
|
||||
final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler;
|
||||
|
||||
// Build semantic label for hero item
|
||||
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
|
||||
|
||||
return Semantics(
|
||||
@@ -1420,10 +1412,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
],
|
||||
|
||||
// On small screens: show button before summary
|
||||
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||
|
||||
// Summary with episode info (Apple TV style)
|
||||
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
||||
const SizedBox(height: 12),
|
||||
RichText(
|
||||
@@ -1468,7 +1458,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
||||
),
|
||||
],
|
||||
|
||||
// On large screens: show button after summary
|
||||
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
|
||||
],
|
||||
),
|
||||
|
||||
@@ -42,7 +42,6 @@ class DownloadsScreen extends StatefulWidget {
|
||||
|
||||
class DownloadsScreenState extends State<DownloadsScreen>
|
||||
with TickerProviderStateMixin, TabNavigationMixin, FocusableTab {
|
||||
// Focus nodes for tab chips
|
||||
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
|
||||
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
|
||||
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
|
||||
@@ -60,7 +59,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
suppressAutoFocus = true; // Start suppressed
|
||||
initTabNavigation();
|
||||
}
|
||||
|
||||
@@ -92,7 +90,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
|
||||
/// Focus the first item in the currently active tab
|
||||
void _focusCurrentTab() {
|
||||
// Re-enable auto-focus since user is navigating into tab content
|
||||
setState(() {
|
||||
suppressAutoFocus = false;
|
||||
});
|
||||
@@ -115,7 +112,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
|
||||
/// Build the app bar title - either tabs on desktop or simple title on mobile
|
||||
Widget _buildAppBarTitle() {
|
||||
// On desktop/TV with side nav, show tabs in app bar
|
||||
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
||||
return TabChipStrip(
|
||||
children: [
|
||||
@@ -130,7 +126,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// On mobile, show simple title
|
||||
return Text(t.downloads.title);
|
||||
}
|
||||
|
||||
@@ -177,7 +172,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
builder: (context, hasPendingDownloads, _) =>
|
||||
BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads),
|
||||
),
|
||||
// Tab selector chips (only on mobile - desktop has them in app bar)
|
||||
if (!PlatformDetector.shouldUseSideNavigation(context))
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -197,7 +191,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
||||
),
|
||||
),
|
||||
),
|
||||
// Tab content
|
||||
Expanded(
|
||||
child: TabBarView(
|
||||
controller: tabController,
|
||||
|
||||
@@ -202,7 +202,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
setState(() {
|
||||
_filteredItems = List.from(_items);
|
||||
|
||||
// Apply sorting
|
||||
if (_selectedSort != null) {
|
||||
final sortKey = _selectedSort!.key;
|
||||
_filteredItems.sort((a, b) {
|
||||
@@ -540,21 +539,16 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
||||
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||
|
||||
// Determine hub content type for layout decisions
|
||||
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
|
||||
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
|
||||
|
||||
// Mixed hub = has both episodes AND non-episodes
|
||||
final isMixedHub = hasEpisodes && hasNonEpisodes;
|
||||
|
||||
// Episode-only = all items are episodes with thumbnails
|
||||
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
|
||||
|
||||
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
|
||||
final useWideLayout =
|
||||
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
|
||||
|
||||
// Music hubs render square album/artist artwork
|
||||
final isSquareHub =
|
||||
_filteredItems.isNotEmpty &&
|
||||
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
|
||||
|
||||
@@ -29,7 +29,6 @@ class AlphaJumpHelper {
|
||||
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
|
||||
|
||||
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
|
||||
// Collect characters with their sizes.
|
||||
final entries = <({String letter, int size})>[];
|
||||
final letterSizes = <String, int>{};
|
||||
|
||||
@@ -41,13 +40,11 @@ class AlphaJumpHelper {
|
||||
}
|
||||
}
|
||||
|
||||
// Re-sort by DUCET collation to match the content endpoint's ICU sort order.
|
||||
entries.sort((a, b) => ducetCompare(a.letter, b.letter));
|
||||
if (descending) {
|
||||
entries.setAll(0, entries.reversed.toList());
|
||||
}
|
||||
|
||||
// Build cumulative index map in the corrected order.
|
||||
final letters = <String>[];
|
||||
final letterToIndex = <String, int>{};
|
||||
int cumulative = 0;
|
||||
|
||||
@@ -176,12 +176,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Loading state (only show loading indicator if items list is empty)
|
||||
if (isLoading && items.isEmpty) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
|
||||
// Error state (only show error if items list is empty)
|
||||
if (errorMessage != null && items.isEmpty) {
|
||||
return ErrorStateWidget(
|
||||
message: errorMessage!,
|
||||
@@ -191,12 +189,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// Empty state
|
||||
if (items.isEmpty) {
|
||||
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
|
||||
}
|
||||
|
||||
// Content state - delegate to builder
|
||||
return builder(items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
ItemUpdatable,
|
||||
TickerProviderStateMixin,
|
||||
TabNavigationMixin {
|
||||
// GlobalKeys for tabs to enable refresh
|
||||
final _recommendedTabKey = GlobalKey();
|
||||
final _browseTabKey = GlobalKey();
|
||||
final _collectionsTabKey = GlobalKey();
|
||||
@@ -84,7 +83,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
/// Key for the library dropdown menu button.
|
||||
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
|
||||
|
||||
// Dynamic visible tabs and their focus nodes
|
||||
List<LibraryTabType> _visibleTabs = LibraryTabType.values;
|
||||
List<FocusNode> _tabFocusNodes = List.generate(
|
||||
LibraryTabType.values.length,
|
||||
@@ -94,10 +92,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
@override
|
||||
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
|
||||
|
||||
// App bar action bar
|
||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||
|
||||
// Scroll controller for the outer CustomScrollView
|
||||
final ScrollController _outerScrollController = ScrollController();
|
||||
|
||||
/// Reveal the floating header by jumping the outer NestedScrollView back
|
||||
@@ -153,25 +149,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
// Compute visible libraries for initial load
|
||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
||||
|
||||
// Load saved preferences
|
||||
final storage = await StorageService.getInstance();
|
||||
final savedLibraryKey = storage.getSelectedLibraryKey();
|
||||
|
||||
// Find the library by key in visible libraries
|
||||
String? libraryGlobalKeyToLoad;
|
||||
if (savedLibraryKey != null) {
|
||||
// Check if saved library exists and is visible
|
||||
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
|
||||
if (libraryExists) {
|
||||
libraryGlobalKeyToLoad = savedLibraryKey;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to first visible library if saved key not found
|
||||
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
|
||||
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
|
||||
}
|
||||
@@ -183,16 +174,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
@override
|
||||
void onTabChanged() {
|
||||
// Save tab name when changed (but not when restoring from storage)
|
||||
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
|
||||
// Only save if this was a user-initiated tab change, not a restore
|
||||
if (!_isRestoringTab) {
|
||||
StorageService.getInstance().then((storage) {
|
||||
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name);
|
||||
});
|
||||
|
||||
// Focus first item in the current tab (only for user-initiated changes)
|
||||
// But not when navigating via tab bar (suppressAutoFocus is true)
|
||||
if (!suppressAutoFocus) {
|
||||
_focusCurrentTab();
|
||||
}
|
||||
@@ -306,15 +293,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
|
||||
/// Handle when a tab's data has finished loading
|
||||
void _handleTabDataLoaded(int tabIndex) {
|
||||
// Track that this tab has loaded
|
||||
_loadedTabs.add(tabIndex);
|
||||
|
||||
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
|
||||
if (suppressAutoFocus) return;
|
||||
|
||||
// Only focus if this is the currently active tab
|
||||
if (tabController.index == tabIndex && mounted) {
|
||||
// Use post-frame callback to ensure the widget tree is fully built
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
|
||||
_focusCurrentTab();
|
||||
@@ -352,21 +335,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
||||
void _updateVisibleTabs(List<LibraryTabType> newTabs) {
|
||||
if (listEquals(_visibleTabs, newTabs)) return;
|
||||
|
||||
// Save current tab type before changing
|
||||
final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
|
||||
|
||||
// Dispose old focus nodes and controller
|
||||
for (final node in _tabFocusNodes) {
|
||||
node.dispose();
|
||||
}
|
||||
disposeTabNavigation();
|
||||
|
||||
// Build new
|
||||
_visibleTabs = newTabs;
|
||||
_tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
|
||||
initTabNavigation();
|
||||
|
||||
// Restore tab position: find current tab type in new set, default to first
|
||||
final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1;
|
||||
if (newIndex > 0) {
|
||||
tabController.index = newIndex;
|
||||
|
||||
@@ -49,7 +49,6 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
||||
_currentDescending = widget.isSortDescending;
|
||||
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
|
||||
|
||||
// Scroll to selected item, then handle focus
|
||||
final selectedIndex = widget.selectedSort != null
|
||||
? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key)
|
||||
: -1;
|
||||
|
||||
@@ -78,14 +78,14 @@ import '../watch_together/watch_together.dart';
|
||||
// browse rail can import the scope without an import cycle through this file.
|
||||
|
||||
@visibleForTesting
|
||||
bool shouldHandleMacOsRootEscape({
|
||||
required bool isMacOS,
|
||||
bool shouldHandleDesktopRootEscape({
|
||||
required bool isDesktop,
|
||||
required bool isPhysicalKeyboardEvent,
|
||||
required LogicalKeyboardKey logicalKey,
|
||||
required bool isCurrentRoute,
|
||||
required bool isHomeTab,
|
||||
}) {
|
||||
return isMacOS && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab;
|
||||
return isDesktop && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab;
|
||||
}
|
||||
|
||||
@visibleForTesting
|
||||
@@ -1348,13 +1348,16 @@ class _MainScreenState extends State<MainScreen>
|
||||
return KeyEventResult.handled;
|
||||
}
|
||||
|
||||
/// On macOS, native fullscreen is window state shared by every route.
|
||||
/// Player Escape therefore leaves it alone; only root Home owns the
|
||||
/// conventional Escape-to-leave-fullscreen behavior.
|
||||
KeyEventResult _handleMacOsRootEscape(KeyEvent event) {
|
||||
/// Desktop physical-keyboard Escape at root Home is reserved for leaving
|
||||
/// window fullscreen; it never arms the press-back-again quit, so an Escape
|
||||
/// aimed at fullscreen can't close the app (#1748). Remotes, gamepad B, and
|
||||
/// system back keep the double-press exit path. On macOS this also keeps
|
||||
/// player Escape away from native fullscreen, which is window state shared
|
||||
/// by every route.
|
||||
KeyEventResult _handleDesktopRootEscape(KeyEvent event) {
|
||||
final tabs = _getVisibleTabs(_isOffline);
|
||||
final shouldHandle = shouldHandleMacOsRootEscape(
|
||||
isMacOS: Platform.isMacOS,
|
||||
final shouldHandle = shouldHandleDesktopRootEscape(
|
||||
isDesktop: PlatformDetector.isDesktopOS(),
|
||||
isPhysicalKeyboardEvent: event.isPhysicalKeyboardEvent,
|
||||
logicalKey: event.logicalKey,
|
||||
isCurrentRoute: ModalRoute.of(context)?.isCurrent == true,
|
||||
@@ -1750,7 +1753,7 @@ class _MainScreenState extends State<MainScreen>
|
||||
canPop: false,
|
||||
child: Focus(
|
||||
onKeyEvent: (node, event) {
|
||||
final rootEscapeResult = _handleMacOsRootEscape(event);
|
||||
final rootEscapeResult = _handleDesktopRootEscape(event);
|
||||
if (rootEscapeResult == KeyEventResult.handled) return rootEscapeResult;
|
||||
final fullscreenResult = _handleFullscreenShortcut(event);
|
||||
if (fullscreenResult == KeyEventResult.handled) return fullscreenResult;
|
||||
|
||||
@@ -23,12 +23,11 @@ class PlaylistItemCard extends StatefulWidget {
|
||||
final VoidCallback? onRemove;
|
||||
final VoidCallback? onTap;
|
||||
final void Function(MediaItem source)? onRefresh;
|
||||
final bool canReorder; // Whether drag handle should be shown
|
||||
final bool canReorder;
|
||||
|
||||
// Focus state for keyboard/D-pad navigation
|
||||
final bool isFocused;
|
||||
final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button
|
||||
final bool isMoving; // Whether this item is being moved/reordered
|
||||
final int? focusedColumn;
|
||||
final bool isMoving;
|
||||
|
||||
const PlaylistItemCard({
|
||||
super.key,
|
||||
@@ -56,20 +55,16 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final textMuted = tokens(context).textMuted;
|
||||
|
||||
// Determine if row is focused (main content area)
|
||||
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
|
||||
|
||||
// Focus states for individual elements
|
||||
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
|
||||
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
|
||||
|
||||
// Determine card styling based on focus/move state
|
||||
Color? cardColor;
|
||||
ShapeBorder? cardShape;
|
||||
if (widget.isMoving) {
|
||||
cardColor = colorScheme.primaryContainer;
|
||||
} else if (isRowFocused) {
|
||||
// Row is focused - use visible border like FocusableWrapper
|
||||
cardColor = colorScheme.surfaceContainerHighest;
|
||||
cardShape = RoundedRectangleBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
@@ -126,18 +121,15 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
),
|
||||
),
|
||||
|
||||
// Poster thumbnail
|
||||
_buildPosterImage(context, item),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and metadata
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
mainAxisSize: .min,
|
||||
children: [
|
||||
// Title
|
||||
Text(
|
||||
item.displayTitle,
|
||||
style: const TextStyle(fontSize: 15, fontWeight: .w500),
|
||||
@@ -147,7 +139,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
// Subtitle (episode info or type)
|
||||
Text(
|
||||
_buildSubtitle(item),
|
||||
style: TextStyle(fontSize: 13, color: textMuted),
|
||||
@@ -160,13 +151,11 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Duration
|
||||
if (item.durationMs != null)
|
||||
Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)),
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Remove button
|
||||
Container(
|
||||
decoration: isRemoveButtonFocused
|
||||
? BoxDecoration(
|
||||
|
||||
@@ -29,7 +29,6 @@ class AboutScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// App Icon and Name
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -54,7 +53,6 @@ class AboutScreen extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// Open Source Licenses
|
||||
SettingsGroup(
|
||||
margin: EdgeInsets.zero,
|
||||
children: [
|
||||
|
||||
@@ -114,7 +114,6 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
// Package info card
|
||||
if (mergedLicense.allPackageNames.length > 1)
|
||||
Card(
|
||||
child: Padding(
|
||||
@@ -134,7 +133,6 @@ class _LicenseDetailScreen extends StatelessWidget {
|
||||
),
|
||||
if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16),
|
||||
|
||||
// License cards
|
||||
...licenseEntries.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final license = entry.value;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'dart:math' as math;
|
||||
import '../../providers/playback_state_provider.dart';
|
||||
import '../../services/playback_initialization_types.dart';
|
||||
|
||||
/// Position must be within this many ms of the best-known duration for a
|
||||
/// player EOF signal to count as the real end of the media.
|
||||
@@ -28,6 +29,54 @@ CompletionNavigationAction completionNavigationAction({
|
||||
return CompletionNavigationAction.exit;
|
||||
}
|
||||
|
||||
/// How many times an auto-play countdown may re-fire a transiently failed
|
||||
/// EOF advance before the Play Next prompt goes manual-only (#1867). Retries
|
||||
/// are spaced by the countdown plus the failed attempt itself (connect
|
||||
/// timeout + endpoint failover), so two retries cover a short connectivity
|
||||
/// blip without looping against a server that is genuinely down.
|
||||
const int maxPlayNextTransientRetries = 2;
|
||||
|
||||
/// How a failed EOF-driven advance should be re-presented to the user.
|
||||
enum PlayNextRetryPresentation {
|
||||
/// Keep the existing failure handling (rollback + error snackbar).
|
||||
none,
|
||||
|
||||
/// Re-present the Play Next prompt without a countdown — retry is the
|
||||
/// user's move.
|
||||
manual,
|
||||
|
||||
/// Re-present the Play Next prompt with the auto-play countdown so the
|
||||
/// advance retries by itself.
|
||||
countdown,
|
||||
}
|
||||
|
||||
/// Decide whether a failed episode advance re-presents the Play Next prompt.
|
||||
///
|
||||
/// A transient server blip at the exact moment of an EOF transition used to
|
||||
/// park the screen on the finished episode's last frame with no way forward
|
||||
/// but the transport controls (#1867) — while a retry seconds later
|
||||
/// typically succeeds. Only EOF-driven advances qualify: a mid-episode Next
|
||||
/// press rolls back to a still-valid playing stream, and non-transient
|
||||
/// failures (missing file, auth) must not retry-loop. Watch Together
|
||||
/// sessions never auto-retry — the sync layer owns transitions — but the
|
||||
/// manual prompt remains available, matching the Next button.
|
||||
PlayNextRetryPresentation playNextRetryPresentation({
|
||||
required bool wasAtCompletion,
|
||||
required PlaybackFailureReason? failureReason,
|
||||
required bool hasNext,
|
||||
required bool autoPlayEnabled,
|
||||
required bool inWatchTogetherSession,
|
||||
required int autoRetriesUsed,
|
||||
int maxAutoRetries = maxPlayNextTransientRetries,
|
||||
}) {
|
||||
if (!wasAtCompletion || !hasNext) return PlayNextRetryPresentation.none;
|
||||
if (failureReason != PlaybackFailureReason.serverUnavailable) {
|
||||
return PlayNextRetryPresentation.none;
|
||||
}
|
||||
final autoRetry = autoPlayEnabled && !inWatchTogetherSession && autoRetriesUsed < maxAutoRetries;
|
||||
return autoRetry ? PlayNextRetryPresentation.countdown : PlayNextRetryPresentation.manual;
|
||||
}
|
||||
|
||||
/// Classify a player EOF signal against the best-known media duration.
|
||||
///
|
||||
/// mpv reports a clean EOF when a network stream dies mid-file (a reaped
|
||||
|
||||
@@ -53,6 +53,13 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
if (!mounted) return;
|
||||
if (_nextEpisode == null || _isLoadingNext) return;
|
||||
|
||||
// EOF-driven advances (prompt confirm, auto-play countdown, PiP) run with
|
||||
// the completion latch set; a mid-episode Next press does not. Captured
|
||||
// before the prompt state below is cleared — a transiently failed advance
|
||||
// from EOF re-presents the Play Next prompt instead of parking on the
|
||||
// finished episode's last frame (#1867).
|
||||
final wasAtCompletion = _completionLatch.triggered;
|
||||
|
||||
_autoPlayTimer?.cancel();
|
||||
_unfocusPlayNextPrompt();
|
||||
_dismissStillWatching();
|
||||
@@ -64,7 +71,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
_showPlayNextDialog = false;
|
||||
});
|
||||
|
||||
await _navigateToEpisode(_nextEpisode!);
|
||||
final outcome = await _navigateToEpisode(_nextEpisode!);
|
||||
if (outcome == _MediaReloadOutcome.failed) {
|
||||
_presentPlayNextRetryPrompt(wasAtCompletion: wasAtCompletion);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _playPrevious() async {
|
||||
@@ -132,11 +142,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
}
|
||||
|
||||
/// Navigates to a new episode by reusing the current player whenever possible.
|
||||
Future<void> _navigateToEpisode(MediaItem episodeMetadata) async {
|
||||
///
|
||||
/// Returns the reload outcome so [_playNext] can distinguish a failed
|
||||
/// in-place swap (previous session still on screen) from rejected or
|
||||
/// superseded attempts. The screen-replacement fallback reports
|
||||
/// [_MediaReloadOutcome.rejected]: no in-place reload ran.
|
||||
Future<_MediaReloadOutcome> _navigateToEpisode(MediaItem episodeMetadata) async {
|
||||
_lastMediaReloadFailureReason = null;
|
||||
final currentPlayer = player;
|
||||
if (currentPlayer == null) {
|
||||
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
|
||||
return;
|
||||
return _MediaReloadOutcome.rejected;
|
||||
}
|
||||
|
||||
// Callers fire this without awaiting (auto-play countdown, PiP, the prompt), so an escaping
|
||||
@@ -174,7 +190,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
nativeTrack: currentPlayer.state.track.secondarySubtitle,
|
||||
sessionPreference: _sessionSecondarySubtitlePreference,
|
||||
);
|
||||
await _reloadMediaInPlace(
|
||||
return await _reloadMediaInPlace(
|
||||
metadata: episodeMetadata,
|
||||
selectedMediaIndex: _effectiveSelectedMediaIndex,
|
||||
selectedMediaSourceId: null,
|
||||
@@ -193,6 +209,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
appLogger.e('Failed to navigate to the next item', error: e, stackTrace: stackTrace);
|
||||
_clearEpisodeLoadingFlags();
|
||||
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||
return _MediaReloadOutcome.failed;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -695,7 +712,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
preferredSubtitleTrack: initializationSubtitleTrack,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
transcodeOffset: openResumePosition,
|
||||
),
|
||||
offlineLibraryMode: _offlineLibraryMode,
|
||||
);
|
||||
@@ -801,12 +817,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
externalSubtitles: subtitleSelection.sidecarsAtOpen,
|
||||
);
|
||||
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
|
||||
await _awaitTranscodeReadiness(
|
||||
client: mediaClient,
|
||||
isTranscoding: result.isTranscoding,
|
||||
videoUrl: result.videoUrl!,
|
||||
);
|
||||
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
||||
final openResult = await _openMediaOnPlayer(
|
||||
player: currentPlayer,
|
||||
settingsService: settingsService,
|
||||
@@ -946,6 +956,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
_nextEpisode = null;
|
||||
_previousEpisode = null;
|
||||
_nextEpisodeStatus = QueueNavigationStatus.failed;
|
||||
// A successful swap restores the transient-retry budget for the
|
||||
// next transition (#1867).
|
||||
_playNextTransientRetryCount = 0;
|
||||
});
|
||||
|
||||
try {
|
||||
@@ -963,6 +976,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
||||
return _MediaReloadOutcome.opened;
|
||||
} catch (e) {
|
||||
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
||||
// Record the classified reason so _playNext can re-present the Play
|
||||
// Next prompt when an EOF-driven advance merely hit a transient
|
||||
// server blip (#1867). Non-PlaybackException throws stay null —
|
||||
// they never qualify for a retry prompt.
|
||||
_lastMediaReloadFailureReason = e is PlaybackException ? e.reason : null;
|
||||
_completionLatch.reset();
|
||||
if (!didOpenReplacement) {
|
||||
// Nothing was opened: the previous session is still committed, so
|
||||
|
||||
@@ -149,5 +149,34 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
|
||||
_previousEpisode = adjacentEpisodes.previous;
|
||||
_nextEpisodeStatus = adjacentEpisodes.nextStatus;
|
||||
});
|
||||
_primeNextEpisodePlaybackMetadata(adjacentEpisodes.next);
|
||||
}
|
||||
|
||||
/// Best-effort prefetch of the next episode's full metadata row into the
|
||||
/// API cache while the current episode plays (#1867).
|
||||
///
|
||||
/// Adjacency comes from queue containers, so the per-item metadata row
|
||||
/// (Plex `/library/metadata/{id}`, Jellyfin `/Users/{uid}/Items/{id}`) is
|
||||
/// cold at the exact moment the transition needs it. Both backends'
|
||||
/// [MediaServerClient.fetchItem] fetch network-first and write that same
|
||||
/// row — the one playback initialization falls back to when the server is
|
||||
/// transiently unreachable — so a warm row turns a connectivity blip at
|
||||
/// the transition into a normal start instead of a failed advance.
|
||||
///
|
||||
/// Documented best-effort: the transition path performs its own fetch and
|
||||
/// error handling, so a failed prime costs nothing.
|
||||
void _primeNextEpisodePlaybackMetadata(MediaItem? next) {
|
||||
if (next == null || _offlineLibraryMode || !mounted) return;
|
||||
if (_primedNextEpisodeGlobalKey == next.globalKey) return;
|
||||
final client = context.tryGetMediaClientForServer(serverIdOrNull(next.serverId));
|
||||
if (client == null) return;
|
||||
_primedNextEpisodeGlobalKey = next.globalKey;
|
||||
unawaited(() async {
|
||||
try {
|
||||
await client.fetchItem(next.id);
|
||||
} catch (e) {
|
||||
appLogger.d('Next-episode metadata prime failed', error: e);
|
||||
}
|
||||
}());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -548,7 +548,7 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
/// mpv stream ring buffer for poorly interleaved MP4/MOV direct play (the
|
||||
/// ring absorbs the demuxer's audio↔video byte ping-pong so HTTP reads stay
|
||||
/// linear instead of dropping the connection on every byte seek — see
|
||||
/// [networkStreamRingBytes]). Both properties are always written, set or
|
||||
/// [networkStreamRingBytes]). Every property is always written, set or
|
||||
/// reset, so a reused player never carries one item's tuning into the next
|
||||
/// open. On Android with ExoPlayer active they are stashed natively and
|
||||
/// replayed on the exo→mpv fallback, so keep them unconditional.
|
||||
@@ -579,6 +579,25 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
await player.setProperty('stream-lavf-o', '');
|
||||
}
|
||||
|
||||
// Transcode (HLS) segment fetches happen inside ffmpeg's hls demuxer, not
|
||||
// mpv's stream layer, so the reconnect options above never reach them and
|
||||
// mpv's default network-timeout is inert there: a segment response PMS
|
||||
// leaves open without data or error — observed when the request races a
|
||||
// transcoder seek/restart — buffers forever (#1859). An explicit
|
||||
// network-timeout bounds each stalled read and the demuxer-level
|
||||
// reconnect options re-request the same segment instead of skipping its
|
||||
// content. 20s sits above the segment-serve latency of a struggling
|
||||
// transcode (reads that deliver any bytes reset the clock) and a false
|
||||
// trip is a Range-resumed reconnect, not an error.
|
||||
if (isNetworkVod && isTranscoding) {
|
||||
await player.setProperty('network-timeout', '20');
|
||||
await player.setProperty('demuxer-lavf-o', 'reconnect=1,reconnect_streamed=1,reconnect_on_network_error=1');
|
||||
} else {
|
||||
// mpv's documented default network-timeout.
|
||||
await player.setProperty('network-timeout', '60');
|
||||
await player.setProperty('demuxer-lavf-o', '');
|
||||
}
|
||||
|
||||
int? ringBytes;
|
||||
if (isNetworkVod && !isTranscoding) {
|
||||
// Transcode (HLS) playback only uses the mpv stream layer for the
|
||||
@@ -606,24 +625,6 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
||||
await player.setProperty('stream-buffer-size', '${ringBytes ?? mpvDefaultStreamBufferBytes}');
|
||||
}
|
||||
|
||||
/// Best-effort wait for an offset transcode session's segment at the
|
||||
/// resume point, run immediately before the player opens the URL so the
|
||||
/// wait hides behind the other pre-open work and the guarantee is fresh
|
||||
/// when the player attaches. A not-ready session still opens — mpv
|
||||
/// classifies whatever the server actually returns — and no-offset URLs
|
||||
/// return immediately. Starting a new probe aborts the previous one so a
|
||||
/// superseded open never leaves it polling out its window.
|
||||
Future<void> _awaitTranscodeReadiness({
|
||||
required MediaServerClient? client,
|
||||
required bool isTranscoding,
|
||||
required String videoUrl,
|
||||
}) async {
|
||||
if (!isTranscoding || client is! PlexClient) return;
|
||||
_transcodeReadinessAbort?.abort();
|
||||
final abort = _transcodeReadinessAbort = AbortController();
|
||||
await client.waitForTranscodeReady(videoUrl, abort: abort);
|
||||
}
|
||||
|
||||
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
|
||||
///
|
||||
/// [shouldContinue] is re-checked between the awaits so stale generations
|
||||
|
||||
@@ -134,6 +134,58 @@ extension _VideoPlayerPlaybackPromptMethods on VideoPlayerScreenState {
|
||||
});
|
||||
}
|
||||
|
||||
/// Re-present the Play Next prompt after an EOF-driven advance failed on a
|
||||
/// transient server blip (#1867).
|
||||
///
|
||||
/// The failed reload rolled back to the finished episode's last frame, so
|
||||
/// without a prompt the screen parks black until the device sleeps — while
|
||||
/// a retry seconds later typically succeeds (skipping manually did exactly
|
||||
/// that). [playNextRetryPresentation] owns the decision: only EOF-driven
|
||||
/// advances that failed with [PlaybackFailureReason.serverUnavailable]
|
||||
/// qualify, the auto-play countdown re-fires [_playNext] up to
|
||||
/// [maxPlayNextTransientRetries] times, and after that (with auto-play
|
||||
/// off, or in a Watch Together session) the prompt waits for a manual
|
||||
/// retry.
|
||||
void _presentPlayNextRetryPrompt({required bool wasAtCompletion}) async {
|
||||
if (!mounted || !_canNavigateMediaItems()) return;
|
||||
if (_isLoadingNext || _showPlayNextDialog || _showStillWatchingPrompt) return;
|
||||
|
||||
// Capture keyboard mode before the async gap, same as _onVideoCompleted.
|
||||
final isKeyboardMode = PlatformDetector.isTV() && InputModeTracker.isKeyboardMode(context, listen: false);
|
||||
final settings = await SettingsService.getInstance();
|
||||
if (!mounted || _isLoadingNext || _showPlayNextDialog) return;
|
||||
|
||||
final presentation = playNextRetryPresentation(
|
||||
wasAtCompletion: wasAtCompletion,
|
||||
failureReason: _lastMediaReloadFailureReason,
|
||||
hasNext: _nextEpisode != null,
|
||||
autoPlayEnabled: settings.read(SettingsService.autoPlayNextEpisode),
|
||||
inWatchTogetherSession: _activeWatchTogetherSession() != null,
|
||||
autoRetriesUsed: _playNextTransientRetryCount,
|
||||
);
|
||||
if (presentation == PlayNextRetryPresentation.none) return;
|
||||
|
||||
// The failed reload's rollback reset the latch; re-latch so a duplicate
|
||||
// EOF signal from the parked stream cannot stack a second prompt on top.
|
||||
if (!_completionLatch.triggered) _completionLatch.latch();
|
||||
|
||||
final countdown = presentation == PlayNextRetryPresentation.countdown;
|
||||
if (countdown) _playNextTransientRetryCount++;
|
||||
|
||||
_setPlayerState(() {
|
||||
_showPlayNextDialog = true;
|
||||
_autoPlayCountdown = countdown ? 5 : -1;
|
||||
});
|
||||
|
||||
if (isKeyboardMode) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted) _playNextConfirmFocusNode.requestFocus();
|
||||
});
|
||||
}
|
||||
|
||||
if (countdown) _startAutoPlayTimer();
|
||||
}
|
||||
|
||||
void _cancelAutoPlay() {
|
||||
_autoPlayTimer?.cancel();
|
||||
_unfocusPlayNextPrompt();
|
||||
|
||||
@@ -261,11 +261,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
||||
resumePosition: resumePosition,
|
||||
durationMs: _currentMetadata.durationMs,
|
||||
);
|
||||
await _awaitTranscodeReadiness(
|
||||
client: playbackContext.reportingClient,
|
||||
isTranscoding: result.isTranscoding,
|
||||
videoUrl: result.videoUrl!,
|
||||
);
|
||||
if (!attempt.isCurrent) return;
|
||||
final openResult = await _openMediaOnPlayer(
|
||||
player: currentPlayer,
|
||||
|
||||
@@ -75,7 +75,6 @@ import '../providers/shader_provider.dart';
|
||||
import '../providers/user_profile_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import '../utils/dialogs.dart';
|
||||
import '../utils/media_server_http_client.dart' show AbortController;
|
||||
import '../utils/log_redaction_manager.dart';
|
||||
import '../utils/live_tv_player_navigation.dart';
|
||||
import '../utils/player_utils.dart';
|
||||
@@ -462,6 +461,9 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
// Retryable sentinel until the fire-and-forget initial adjacency load
|
||||
// commits found, boundary, or unavailable.
|
||||
QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed;
|
||||
// globalKey of the adjacent episode whose playback metadata row was last
|
||||
// prefetched into the API cache — see _primeNextEpisodePlaybackMetadata.
|
||||
String? _primedNextEpisodeGlobalKey;
|
||||
bool _isResolvingCompletionAdjacency = false;
|
||||
bool _isLoadingNext = false;
|
||||
bool _isLoadingPrevious = false;
|
||||
@@ -473,9 +475,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Completer<void>? _playbackTransitionIdleCompleter;
|
||||
bool _playbackIntentShouldPlay = true;
|
||||
|
||||
/// In-flight transcode readiness probe, aborted by the next probe or by
|
||||
/// dispose so a superseded open never leaves it polling out its window.
|
||||
AbortController? _transcodeReadinessAbort;
|
||||
int _pendingSubtitleCycleCount = 0;
|
||||
bool _subtitleCycleDrainActive = false;
|
||||
|
||||
@@ -572,6 +571,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
Timer? _autoPlayTimer;
|
||||
int _autoPlayCountdown = 5;
|
||||
|
||||
// Transient episode-transition failure retry (#1867). A failed in-place
|
||||
// reload records the classified reason here so _playNext can distinguish
|
||||
// "server momentarily unreachable" (re-present the Play Next prompt,
|
||||
// optionally with an auto-retry countdown) from terminal failures.
|
||||
// _navigateToEpisode clears the field before each attempt; the counter
|
||||
// resets when a reload succeeds.
|
||||
PlaybackFailureReason? _lastMediaReloadFailureReason;
|
||||
int _playNextTransientRetryCount = 0;
|
||||
|
||||
// End-of-video Play Next latch. Completion comes from the player EOF signal;
|
||||
// position ticks only re-arm once playback is more than 2s from the end.
|
||||
final CompletionLatch _completionLatch = CompletionLatch(rearmWindowMs: 2000);
|
||||
@@ -1307,13 +1315,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||
sessionIdentifier: _playbackSessionIdentifier,
|
||||
transcodeSessionId: _playbackTranscodeSessionId,
|
||||
// The initial resume position is the server view offset (the
|
||||
// online open resolves the same value later), so a resumed
|
||||
// transcode starts producing at the resume point instead of
|
||||
// seeking a stream that begins at zero.
|
||||
transcodeOffset: _currentMetadata.viewOffsetMs != null
|
||||
? Duration(milliseconds: _currentMetadata.viewOffsetMs!)
|
||||
: null,
|
||||
),
|
||||
offlineLibraryMode: false,
|
||||
);
|
||||
@@ -1828,7 +1829,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
||||
@override
|
||||
void dispose() {
|
||||
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
|
||||
_transcodeReadinessAbort?.abort();
|
||||
_playerInitializationGeneration++;
|
||||
_frameRate.dispose();
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
|
||||
@@ -43,10 +43,8 @@ class AmbientLightingService {
|
||||
|
||||
appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
|
||||
|
||||
// Set video-aspect-override to fill the entire output area
|
||||
await _player.setProperty('video-aspect-override', outputAspect.toString());
|
||||
|
||||
// Append ambient lighting shader
|
||||
await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]);
|
||||
|
||||
_enabled = true;
|
||||
|
||||
@@ -139,7 +139,6 @@ class DiscordRPCService {
|
||||
_playbackSpeed = 1.0;
|
||||
|
||||
if (_isEnabled && _isConnected) {
|
||||
// Upload thumbnail in background, don't block playback
|
||||
unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client));
|
||||
}
|
||||
}
|
||||
@@ -148,9 +147,7 @@ class DiscordRPCService {
|
||||
void updatePosition(Duration position) {
|
||||
final isSeek = _timeline.updatePosition(position);
|
||||
|
||||
// Update presence if position jumped significantly (seek detected)
|
||||
if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) {
|
||||
// Throttle updates to max once per second
|
||||
final now = DateTime.now();
|
||||
if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) {
|
||||
_lastPresenceUpdate = now;
|
||||
@@ -172,7 +169,6 @@ class DiscordRPCService {
|
||||
Future<void> resumePlayback() async {
|
||||
if (_currentMetadata == null) return;
|
||||
|
||||
// Reset start time for elapsed time display
|
||||
_playbackStartTime = DateTime.now();
|
||||
|
||||
if (_isEnabled && _isConnected) {
|
||||
@@ -182,7 +178,6 @@ class DiscordRPCService {
|
||||
|
||||
/// Pause - clear timestamp but keep showing what's playing
|
||||
Future<void> pausePlayback() async {
|
||||
// Clear start time so Discord stops counting
|
||||
_playbackStartTime = null;
|
||||
|
||||
if (_isEnabled && _isConnected) {
|
||||
@@ -309,12 +304,9 @@ class DiscordRPCService {
|
||||
|
||||
Future<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async {
|
||||
try {
|
||||
// Get the thumbnail path (prefer show poster for episodes)
|
||||
final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath;
|
||||
if (thumbPath == null || thumbPath.isEmpty) return null;
|
||||
|
||||
// Check cache first (with expiry check). Key by backend so the same
|
||||
// path on Plex and Jellyfin doesn't collide.
|
||||
final cacheKey = '${client.backend.id}:$thumbPath';
|
||||
final cached = _posterUrlCache[cacheKey];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
|
||||
@@ -192,25 +192,20 @@ class GamepadService with WindowListener {
|
||||
_tabNavigationHandlers.clear();
|
||||
}
|
||||
|
||||
// Deadzone for analog sticks (0.0 to 1.0)
|
||||
static const double _stickDeadzone = 0.5;
|
||||
|
||||
// Auto-repeat timing for held directional inputs (D-pad / stick)
|
||||
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
|
||||
static const Duration _repeatInterval = Duration(milliseconds: 80);
|
||||
|
||||
key_sim.KeyEventSimulatorController? _keyEventSimulator;
|
||||
|
||||
// Track stick state to detect deadzone crossings
|
||||
bool _leftStickUp = false;
|
||||
bool _leftStickDown = false;
|
||||
bool _leftStickLeft = false;
|
||||
bool _leftStickRight = false;
|
||||
|
||||
// Track button states to prevent repeated events from button holds
|
||||
final Set<GamepadButton> _pressedButtons = {};
|
||||
final Set<GamepadButton> _suppressedButtons = {};
|
||||
// Whether the app window is currently focused — ignore gamepad input when false
|
||||
bool _windowFocused = true;
|
||||
bool _nativeKeyHandlerRegistered = false;
|
||||
bool _nativeTextInputFocused = false;
|
||||
|
||||
@@ -758,20 +758,39 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
||||
return _mapItem(data);
|
||||
} on MediaServerHttpException catch (e) {
|
||||
if (e.statusCode == 404) return null;
|
||||
// An answered request (401/403/5xx) or a client-side cancellation must
|
||||
// surface as-is. Pure transport failures (dead socket, DNS, connect
|
||||
// timeout) carry no status code — and the HTTP layer wraps them into
|
||||
// [MediaServerHttpException], so they arrive here rather than in the
|
||||
// generic catch below. Apply the documented cache fallback (#1867).
|
||||
if (e.statusCode != null || e.isCancellation) rethrow;
|
||||
appLogger.w('JellyfinClient.fetchItem network call failed', error: e);
|
||||
final cached = await _cachedItemFallback(endpoint);
|
||||
if (cached != null) return cached;
|
||||
rethrow;
|
||||
} catch (e) {
|
||||
// Transport-layer failure: socket error, DNS, TLS, etc. Try cache.
|
||||
// Non-HTTP failure while handling the response (e.g. mapping). Same
|
||||
// best-effort fallback before surfacing.
|
||||
appLogger.w('JellyfinClient.fetchItem network call failed', error: e);
|
||||
try {
|
||||
final cached = await cache.get(ServerId(cacheServerId), endpoint);
|
||||
if (cached is Map<String, dynamic>) return _mapItem(cached);
|
||||
} catch (cacheError, st) {
|
||||
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st);
|
||||
}
|
||||
final cached = await _cachedItemFallback(endpoint);
|
||||
if (cached != null) return cached;
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort cached-row read for [_fetchItemOnce]'s failure fallbacks.
|
||||
/// Returns null on miss or on a cache/mapping error (logged) so the caller
|
||||
/// rethrows its original failure.
|
||||
Future<MediaItem?> _cachedItemFallback(String endpoint) async {
|
||||
try {
|
||||
final cached = await cache.get(ServerId(cacheServerId), endpoint);
|
||||
if (cached is Map<String, dynamic>) return _mapItem(cached);
|
||||
} catch (e, st) {
|
||||
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: e, stackTrace: st);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<MediaItem>> fetchChildren(String parentId) => _fetchChildrenInternal(parentId);
|
||||
|
||||
|
||||
@@ -500,6 +500,13 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
||||
externalSubtitles.add(
|
||||
PlaybackSubtitleSidecar(
|
||||
sourceStreamId: track.id,
|
||||
// A real external file is a cheap static fetch, so it loads with the
|
||||
// media whether or not it is selected — that is what lets the track
|
||||
// sheet offer it as a secondary subtitle without a reopen (#1860).
|
||||
// An embedded row extracted on a transcode stays lazy: extraction can
|
||||
// stall while the transcoder spins up, which is exactly what used to
|
||||
// trip the sidecar open guard (#1738).
|
||||
preload: track.isExternalFile,
|
||||
track: SubtitleTrack.uri(
|
||||
url,
|
||||
title:
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -246,6 +246,9 @@ class PlaybackInitializationService {
|
||||
subtitles.add(
|
||||
PlaybackSubtitleSidecar(
|
||||
sourceStreamId: trackId,
|
||||
// Local files cost nothing to attach, and preloading keeps every
|
||||
// downloaded sidecar selectable as a secondary subtitle (#1860).
|
||||
preload: true,
|
||||
track: SubtitleTrack.uri(
|
||||
Uri.file(entity.path).toString(),
|
||||
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? t.videoControls.subtitleFile(name: fileName),
|
||||
|
||||
@@ -61,13 +61,6 @@ class PlaybackInitializationOptions {
|
||||
/// for Plex transcode.
|
||||
final String? transcodeSessionId;
|
||||
|
||||
/// Absolute VOD position at which a new Plex transcode must begin. Sent
|
||||
/// with both Plex's decision and HLS start request so the server and the
|
||||
/// player agree on the first available segment. Only the Plex client
|
||||
/// consumes this today; Jellyfin's StartTimeTicks equivalent is
|
||||
/// intentionally unwired.
|
||||
final Duration? transcodeOffset;
|
||||
|
||||
const PlaybackInitializationOptions({
|
||||
required this.metadata,
|
||||
required this.selectedMediaIndex,
|
||||
@@ -80,7 +73,6 @@ class PlaybackInitializationOptions {
|
||||
this.preferredSubtitleTrack,
|
||||
this.sessionIdentifier,
|
||||
this.transcodeSessionId,
|
||||
this.transcodeOffset,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+129
-218
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import '../utils/isolate_helper.dart';
|
||||
import '../utils/json_utils.dart';
|
||||
import 'package:clock/clock.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:uuid/uuid.dart';
|
||||
@@ -86,15 +85,51 @@ part 'plex_client/parts/metadata_edit.dart';
|
||||
const _plexVideoTranscodeBaseEndpoint = '/video/:/transcode/universal';
|
||||
const _plexVideoHlsStartEndpoint = '$_plexVideoTranscodeBaseEndpoint/start.m3u8';
|
||||
const _plexVideoHlsProtocol = 'hls';
|
||||
const _plexHlsVideoTranscodeTarget =
|
||||
|
||||
/// VOD transcode target: HLS with fragmented-MP4 segments.
|
||||
///
|
||||
/// Every non-Original request pins `directStream=0`, so this codec list is a
|
||||
/// menu of *encode* outputs, never copy targets. HEVC must not be offered in
|
||||
/// an mpegts target: a Plex Pass server with HEVC encoding enabled obliges,
|
||||
/// and its hardware HEVC encode → TS segmenter path emits parameter sets mpv
|
||||
/// rejects ("PPS changed between slices", issue #1859). Apple's HLS spec
|
||||
/// likewise requires fMP4 for HEVC. fMP4 decisions and segment output were
|
||||
/// verified against PMS 1.22 through 1.43; servers older than 1.22 fail the
|
||||
/// decision request itself regardless of container, so no version gate.
|
||||
const _plexHlsVodVideoTranscodeTarget =
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=hls&container=mp4&videoCodec=h264%2Chevc'
|
||||
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
|
||||
|
||||
/// Fallback VOD target for a server whose decision does not honour the fMP4
|
||||
/// container: H.264-only MPEG-TS, the combination Plex's own legacy clients
|
||||
/// request. HEVC stays out — in a TS target it is reachable only as the
|
||||
/// broken encode output described on [_plexHlsVodVideoTranscodeTarget].
|
||||
const _plexHlsVodTsVideoTranscodeTarget =
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=hls&container=mpegts&videoCodec=h264'
|
||||
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
|
||||
|
||||
/// Live TV target: MPEG-TS with the broadcast codecs. Live sessions are
|
||||
/// copy-dominant (TS→TS remux — hevc/mpeg2video here are copy targets, and
|
||||
/// HEVC *copy* into TS is verified clean), so this deliberately does not
|
||||
/// follow the VOD target to fMP4. Residual risk accepted: a Plex Pass server
|
||||
/// electing to HEVC-*encode* a live channel would hit the same TS bug.
|
||||
const _plexHlsLiveVideoTranscodeTarget =
|
||||
'add-transcode-target(type=videoProfile&context=streaming'
|
||||
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
|
||||
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
|
||||
|
||||
const _plexHlsSubtitleTranscodeTarget =
|
||||
'add-transcode-target(type=subtitleProfile&context=streaming'
|
||||
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)';
|
||||
|
||||
String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) {
|
||||
/// Containers the VOD decision must echo back before a start path is handed
|
||||
/// to the player (see `requiredContainer` on [_runTranscodeDecision]).
|
||||
const _plexHlsVodContainer = 'mp4';
|
||||
const _plexHlsVodTsContainer = 'mpegts';
|
||||
|
||||
String _buildPlexHlsClientProfileExtra({required String videoTranscodeTarget, int? maxVideoBitrateKbps}) {
|
||||
final clauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
|
||||
if (maxVideoBitrateKbps != null) {
|
||||
clauses.add(
|
||||
@@ -103,7 +138,7 @@ String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) {
|
||||
);
|
||||
}
|
||||
clauses
|
||||
..add(_plexHlsVideoTranscodeTarget)
|
||||
..add(videoTranscodeTarget)
|
||||
..add(_plexHlsSubtitleTranscodeTarget);
|
||||
return clauses.join('+');
|
||||
}
|
||||
@@ -2612,6 +2647,16 @@ class PlexClient
|
||||
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
||||
/// seeks + quality/version/audio switches within one playback so the
|
||||
/// server-side transcode session is preserved.
|
||||
///
|
||||
/// Deliberately no `offset` request parameter: the start URL always
|
||||
/// describes the full title and the player seeks in-band by requesting the
|
||||
/// segment at the resume position (`Media(start:)`). Pre-warming the
|
||||
/// transcoder at the resume point looked cheaper but never was — mpv's
|
||||
/// stream probing reads segment zero first, which is itself a Plex seek, so
|
||||
/// an offset start forced the transcoder through seek→0→seek within a
|
||||
/// couple of seconds. PMS can leave the segment response that races such a
|
||||
/// restart open without data or error, which the player waits out as
|
||||
/// endless buffering (#1859).
|
||||
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
|
||||
required String ratingKey,
|
||||
required int mediaIndex,
|
||||
@@ -2620,13 +2665,12 @@ class PlexClient
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
int? audioStreamId,
|
||||
Duration? offset,
|
||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||
int? partId,
|
||||
}) async {
|
||||
try {
|
||||
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
|
||||
final allParams = _buildTranscodeParams(
|
||||
Map<String, String> paramsFor({required bool useTsFallbackTarget}) => _buildTranscodeParams(
|
||||
ratingKey: ratingKey,
|
||||
mediaIndex: mediaIndex,
|
||||
partIndex: partIndex,
|
||||
@@ -2634,219 +2678,42 @@ class PlexClient
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
audioStreamId: audioStreamId,
|
||||
offset: offset,
|
||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||
useTsFallbackTarget: useTsFallbackTarget,
|
||||
);
|
||||
return await _runTranscodeDecision(
|
||||
|
||||
final primary = await _runTranscodeDecision(
|
||||
startEndpoint: _plexVideoHlsStartEndpoint,
|
||||
allParams: allParams,
|
||||
allParams: paramsFor(useTsFallbackTarget: false),
|
||||
isOriginal: preset.isOriginal,
|
||||
requiredContainer: _plexHlsVodContainer,
|
||||
);
|
||||
if (primary.containerHonored) {
|
||||
return (startPath: primary.startPath, outcome: primary.outcome);
|
||||
}
|
||||
|
||||
// The decision succeeded but ignored the fMP4 target. Never hand the
|
||||
// player a container it did not negotiate — a mis-declared stream is
|
||||
// exactly the corruption mode of issue #1859 — so re-ask with the
|
||||
// TS/H.264 fallback profile before giving up.
|
||||
appLogger.i('Retrying transcode decision with the TS fallback profile');
|
||||
final fallback = await _runTranscodeDecision(
|
||||
startEndpoint: _plexVideoHlsStartEndpoint,
|
||||
allParams: paramsFor(useTsFallbackTarget: true),
|
||||
isOriginal: preset.isOriginal,
|
||||
requiredContainer: _plexHlsVodTsContainer,
|
||||
);
|
||||
if (fallback.containerHonored) {
|
||||
return (startPath: fallback.startPath, outcome: fallback.outcome);
|
||||
}
|
||||
appLogger.w('Transcode decision honoured neither requested container; falling back to direct play');
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Absolute media position a transcode start URL was requested at, or null
|
||||
/// when the URL is not an offset HLS start request. Matched on decoded
|
||||
/// path segments so a percent-encoded spelling of the same URL cannot
|
||||
/// silently switch the readiness probe off.
|
||||
static Duration? transcodeStreamOffsetFromUrl(String videoUrl) {
|
||||
final uri = Uri.tryParse(videoUrl);
|
||||
if (uri == null || !'/${uri.pathSegments.join('/')}'.endsWith('/video/:/transcode/universal/start.m3u8')) {
|
||||
return null;
|
||||
}
|
||||
final offsetSeconds = double.tryParse(uri.queryParameters['offset'] ?? '');
|
||||
if (offsetSeconds == null || offsetSeconds <= 0) return null;
|
||||
return Duration(microseconds: (offsetSeconds * Duration.microsecondsPerSecond).round());
|
||||
}
|
||||
|
||||
/// Picks the playlist entry the readiness probe should touch: the segment
|
||||
/// whose duration window contains [offset].
|
||||
///
|
||||
/// Plex media playlists always cover the full title from segment zero, so
|
||||
/// probing the first entry would steer the transcoder back to the start —
|
||||
/// requesting a segment is how a client seeks a Plex HLS session. A master
|
||||
/// playlist (no `#EXTINF` durations) descends into its first variant. A
|
||||
/// media playlist whose durations never cross [offset] returns null: it
|
||||
/// cannot say where the offset lives, and a probe aimed at the wrong
|
||||
/// segment would seek the session, so the caller skips probing instead.
|
||||
@visibleForTesting
|
||||
static String? selectReadinessProbeTarget(String body, Duration offset) {
|
||||
String? firstEntry;
|
||||
var sawSegmentDurations = false;
|
||||
var cumulative = Duration.zero;
|
||||
var pending = Duration.zero;
|
||||
for (final raw in body.split(RegExp(r'\r?\n'))) {
|
||||
final line = raw.trim();
|
||||
if (line.isEmpty) continue;
|
||||
if (line.startsWith('#')) {
|
||||
if (line.startsWith('#EXTINF:')) {
|
||||
sawSegmentDurations = true;
|
||||
final seconds = double.tryParse(line.substring('#EXTINF:'.length).split(',').first);
|
||||
if (seconds != null) pending = Duration(microseconds: (seconds * Duration.microsecondsPerSecond).round());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
firstEntry ??= line;
|
||||
cumulative += pending;
|
||||
pending = Duration.zero;
|
||||
if (sawSegmentDurations && cumulative > offset) return line;
|
||||
}
|
||||
return sawSegmentDurations ? null : (firstEntry ?? '');
|
||||
}
|
||||
|
||||
/// Waits for a just-started Plex offset HLS session to serve the segment at
|
||||
/// the requested offset before a native player opens its playlist. Plex can
|
||||
/// return a manifest before the segment is ready; mpv treats that 404 as an
|
||||
/// HLS error and races through the rest of the manifest.
|
||||
///
|
||||
/// Best-effort by design: the probe never fails an open, it only stops
|
||||
/// waiting, and callers ignore the returned bool — it exists for tests. The
|
||||
/// player then sees whatever the server is actually doing and the existing
|
||||
/// log-stream classification applies unchanged. To that end a 500 stops the
|
||||
/// wait immediately — a persistent 500 must keep failing fast so the
|
||||
/// server-limit dialog appears promptly — whether it arrives as a response
|
||||
/// or inside a decode exception, and a cancellation ([abort] fired or the
|
||||
/// owning client closing) stops it too rather than sleeping out the window.
|
||||
/// URLs without an offset return immediately: probing a no-offset playlist
|
||||
/// would touch segment zero, and requesting a segment is how a client seeks
|
||||
/// a Plex HLS session.
|
||||
///
|
||||
/// Other non-2xx responses are the expected not-ready signal. `_http.get`
|
||||
/// does not throw on the status, though its body decode can throw carrying
|
||||
/// one — both paths share [handOffStatus] so they cannot drift. Every
|
||||
/// not-ready round waits [pollInterval], doubling up to 4x after three
|
||||
/// consecutive failed round-trips so a stalled transcode is not hammered;
|
||||
/// the accepted trade is that a session whose segments 404 for real
|
||||
/// reaches the player, and its media-unreadable dialog, one probe window
|
||||
/// later than an unprobed open would. The probe carries this retry budget
|
||||
/// itself, so its requests bypass endpoint failover, and each request has a
|
||||
/// hard timeout (5s, shrinking as the overall deadline approaches) so a
|
||||
/// single hung request cannot consume the entire window.
|
||||
Future<bool> waitForTranscodeReady(
|
||||
String videoUrl, {
|
||||
Duration timeout = const Duration(seconds: 15),
|
||||
Duration pollInterval = const Duration(milliseconds: 500),
|
||||
AbortController? abort,
|
||||
}) async {
|
||||
final startUri = Uri.tryParse(videoUrl);
|
||||
final probeOffset = transcodeStreamOffsetFromUrl(videoUrl);
|
||||
if (startUri == null || probeOffset == null) return true;
|
||||
|
||||
// One rule for terminal statuses, applied to responses and to
|
||||
// status-bearing exceptions alike.
|
||||
bool handOffStatus(int? statusCode) {
|
||||
if (statusCode != 500) return false;
|
||||
// Hand off without classifying: mpv opens the URL, hits the same 500,
|
||||
// and the log-stream path raises the server-limit dialog.
|
||||
appLogger.i('Plex transcode readiness probe handing off on HTTP 500');
|
||||
return true;
|
||||
}
|
||||
|
||||
final deadline = clock.now().add(timeout);
|
||||
var candidate = startUri;
|
||||
var playlistDepth = 0;
|
||||
var consecutiveFailures = 0;
|
||||
int? lastStatus;
|
||||
while (true) {
|
||||
final remaining = deadline.difference(clock.now());
|
||||
if (remaining <= Duration.zero) break;
|
||||
if (abort?.isAborted ?? false) return false;
|
||||
try {
|
||||
final requestTimeout = remaining < const Duration(seconds: 5) ? remaining : const Duration(seconds: 5);
|
||||
final isPlaylist = candidate.path.toLowerCase().endsWith('.m3u8');
|
||||
// The default Accept is application/json (PlexConfig.headers); the
|
||||
// probe mirrors the player's request shape instead. Segments go
|
||||
// through getStatus so a server that ignores Range never routes a
|
||||
// full media segment through text decoding.
|
||||
final int statusCode;
|
||||
var body = '';
|
||||
Uri? effectiveUri;
|
||||
if (isPlaylist) {
|
||||
final response = await _http.get(
|
||||
candidate.toString(),
|
||||
headers: const {'Accept': '*/*'},
|
||||
timeout: requestTimeout,
|
||||
abort: abort,
|
||||
allowEndpointFailover: false,
|
||||
);
|
||||
statusCode = response.statusCode;
|
||||
body = response.data?.toString() ?? '';
|
||||
effectiveUri = response.effectiveUri;
|
||||
} else {
|
||||
final response = await _http.getStatus(
|
||||
candidate.toString(),
|
||||
headers: const {'Range': 'bytes=0-0', 'Accept': '*/*'},
|
||||
timeout: requestTimeout,
|
||||
abort: abort,
|
||||
);
|
||||
statusCode = response.statusCode;
|
||||
}
|
||||
lastStatus = statusCode;
|
||||
if (statusCode >= 200 && statusCode < 300) {
|
||||
consecutiveFailures = 0;
|
||||
if (body.trimLeft().startsWith('#EXTM3U')) {
|
||||
final child = selectReadinessProbeTarget(body, probeOffset);
|
||||
if (child == null) {
|
||||
// The playlist has segments but its durations never reach the
|
||||
// offset — a playlist shape this client has never observed
|
||||
// against a real PMS. It cannot say where the offset lives,
|
||||
// and a probe aimed at the wrong segment would seek the
|
||||
// session, so skip probing and let the player negotiate.
|
||||
return true;
|
||||
}
|
||||
if (child.isNotEmpty) {
|
||||
candidate = (effectiveUri ?? candidate).resolve(child);
|
||||
playlistDepth++;
|
||||
if (playlistDepth > 4) {
|
||||
appLogger.w('Plex transcode readiness exceeded the HLS playlist depth limit');
|
||||
return false;
|
||||
}
|
||||
// Descending into a child playlist is progress, not a poll.
|
||||
continue;
|
||||
}
|
||||
// A manifest with no media entries yet: not ready, poll again.
|
||||
} else if (!isPlaylist) {
|
||||
// The segment at the offset answered: the session is ready.
|
||||
return true;
|
||||
}
|
||||
} else if (handOffStatus(statusCode)) {
|
||||
return false;
|
||||
} else {
|
||||
consecutiveFailures++;
|
||||
}
|
||||
} on MediaServerHttpException catch (e) {
|
||||
if (e.isCancellation) {
|
||||
// Cancellation is not a not-ready signal, so stop instead of
|
||||
// sleeping out the window.
|
||||
return false;
|
||||
}
|
||||
lastStatus = e.statusCode ?? lastStatus;
|
||||
if (handOffStatus(e.statusCode)) return false;
|
||||
// Transport failure — same treatment as a not-ready response.
|
||||
consecutiveFailures++;
|
||||
appLogger.d('Plex transcode readiness probe transport failure', error: e);
|
||||
} catch (e) {
|
||||
consecutiveFailures++;
|
||||
appLogger.d('Plex transcode readiness probe transport failure', error: e);
|
||||
}
|
||||
var delay = pollInterval;
|
||||
if (consecutiveFailures > 3) {
|
||||
delay = pollInterval * (1 << (consecutiveFailures - 3).clamp(0, 2));
|
||||
}
|
||||
final timeLeft = deadline.difference(clock.now());
|
||||
if (timeLeft <= Duration.zero) break;
|
||||
await Future<void>.delayed(delay < timeLeft ? delay : timeLeft);
|
||||
}
|
||||
appLogger.w(
|
||||
'Plex transcode did not become ready within ${timeout.inMilliseconds}ms '
|
||||
'(playlistDepth=$playlistDepth, lastStatus=${lastStatus ?? 'none'}, consecutiveFailures=$consecutiveFailures)',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Point the part's server-side subtitle selection at [track] so an imminent
|
||||
/// `subtitles=burn` transcode burns *that* stream.
|
||||
///
|
||||
@@ -2899,11 +2766,12 @@ class PlexClient
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
);
|
||||
return await _runTranscodeDecision(
|
||||
final result = await _runTranscodeDecision(
|
||||
startEndpoint: _musicTranscodeStartEndpoint,
|
||||
allParams: allParams,
|
||||
isOriginal: preset.isOriginal,
|
||||
);
|
||||
return (startPath: result.startPath, outcome: result.outcome);
|
||||
} catch (e, st) {
|
||||
appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st);
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
@@ -2917,10 +2785,17 @@ class PlexClient
|
||||
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
|
||||
/// path (token stripped) on success. [startEndpoint] includes the container
|
||||
/// extension (`start.m3u8` / `start.mp3`).
|
||||
Future<({String? startPath, TranscodeDecisionOutcome outcome})> _runTranscodeDecision({
|
||||
///
|
||||
/// When [requiredContainer] is set and the decision converts, the selected
|
||||
/// media entry must echo that container back; `containerHonored: false`
|
||||
/// otherwise. PMS applies whatever transcode target the client profile
|
||||
/// names, so a mismatch means the server substituted a container the
|
||||
/// player never negotiated — the caller must not open that stream.
|
||||
Future<({String? startPath, TranscodeDecisionOutcome outcome, bool containerHonored})> _runTranscodeDecision({
|
||||
required String startEndpoint,
|
||||
required Map<String, String> allParams,
|
||||
required bool isOriginal,
|
||||
String? requiredContainer,
|
||||
}) async {
|
||||
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
||||
|
||||
@@ -2938,15 +2813,41 @@ class PlexClient
|
||||
|
||||
if (decisionResponse.statusCode != 200) {
|
||||
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed, containerHonored: true);
|
||||
}
|
||||
|
||||
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
|
||||
if (outcome == TranscodeDecisionOutcome.failed) {
|
||||
return (startPath: null, outcome: outcome);
|
||||
return (startPath: null, outcome: outcome, containerHonored: true);
|
||||
}
|
||||
|
||||
return (startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint), outcome: outcome);
|
||||
var containerHonored = true;
|
||||
if (requiredContainer != null && outcome == TranscodeDecisionOutcome.transcodeOk) {
|
||||
final selected = _decisionSelectedContainer(decisionResponse.data);
|
||||
containerHonored = selected == requiredContainer;
|
||||
if (!containerHonored) {
|
||||
appLogger.w('Transcode decision did not honour container=$requiredContainer (got ${selected ?? 'none'})');
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
startPath: _buildTranscodeStartPathFromParams(allParams, endpoint: startEndpoint),
|
||||
outcome: outcome,
|
||||
containerHonored: containerHonored,
|
||||
);
|
||||
}
|
||||
|
||||
/// Container of the selected media entry in a transcode decision body, or
|
||||
/// null when the decision carries no media selection.
|
||||
static String? _decisionSelectedContainer(dynamic data) {
|
||||
if (data is! Map) return null;
|
||||
final container = data['MediaContainer'];
|
||||
final metadata = container is Map ? container['Metadata'] : null;
|
||||
final media = metadata is List && metadata.isNotEmpty && metadata.first is Map
|
||||
? (metadata.first as Map)['Media']
|
||||
: null;
|
||||
final selected = media is List && media.isNotEmpty && media.first is Map ? media.first as Map : null;
|
||||
return selected?['container']?.toString();
|
||||
}
|
||||
|
||||
String _buildTranscodeStartPathFromParams(
|
||||
@@ -2974,12 +2875,13 @@ class PlexClient
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
int? audioStreamId,
|
||||
Duration? offset,
|
||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||
bool useTsFallbackTarget = false,
|
||||
}) {
|
||||
final isOriginal = preset.isOriginal;
|
||||
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
|
||||
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
||||
videoTranscodeTarget: useTsFallbackTarget ? _plexHlsVodTsVideoTranscodeTarget : _plexHlsVodVideoTranscodeTarget,
|
||||
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
||||
);
|
||||
|
||||
@@ -3003,10 +2905,16 @@ class PlexClient
|
||||
'location': 'lan',
|
||||
'addDebugOverlay': '0',
|
||||
'autoAdjustQuality': '0',
|
||||
// The preset's resolution/quality caps ride as plain query params — the
|
||||
// bitrate limitation clause alone leaves a 4K source at 2160p, starving
|
||||
// the encode and breaking the picker's "1080p" promise (issue #1859).
|
||||
// Both are honoured by the decision and start endpoints on a real PMS.
|
||||
// Null exactly for the original preset.
|
||||
if (preset.videoResolution != null) 'videoResolution': preset.videoResolution!,
|
||||
if (preset.videoQuality != null) 'videoQuality': preset.videoQuality!.toString(),
|
||||
'directStreamAudio': '1',
|
||||
'mediaBufferSize': '102400',
|
||||
'session': transcodeSessionId,
|
||||
if (offset != null && offset > Duration.zero) 'offset': (offset.inMilliseconds / 1000).toStringAsFixed(6),
|
||||
// `subtitles` is the only subtitle knob this endpoint honours. Which
|
||||
// stream gets burned comes from the part's server-side selection, not
|
||||
// from here: measured against a real PMS, passing `subtitleStreamID` for
|
||||
@@ -3043,8 +2951,8 @@ class PlexClient
|
||||
required String sessionIdentifier,
|
||||
required String transcodeSessionId,
|
||||
int? audioStreamId,
|
||||
Duration? offset,
|
||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||
bool useTsFallbackTarget = false,
|
||||
}) {
|
||||
return _buildTranscodeParams(
|
||||
ratingKey: ratingKey,
|
||||
@@ -3054,8 +2962,8 @@ class PlexClient
|
||||
sessionIdentifier: sessionIdentifier,
|
||||
transcodeSessionId: transcodeSessionId,
|
||||
audioStreamId: audioStreamId,
|
||||
offset: offset,
|
||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||
useTsFallbackTarget: useTsFallbackTarget,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3592,7 +3500,6 @@ class PlexClient
|
||||
sessionIdentifier: options.sessionIdentifier!,
|
||||
transcodeSessionId: options.transcodeSessionId!,
|
||||
audioStreamId: resolvedAudioId,
|
||||
offset: options.transcodeOffset,
|
||||
selectedSubtitleTrack: requestedSubtitleTrack,
|
||||
partId: data.mediaInfo?.getPartId(),
|
||||
);
|
||||
@@ -3842,6 +3749,10 @@ class PlexClient
|
||||
externalSubtitles.add(
|
||||
PlaybackSubtitleSidecar(
|
||||
sourceStreamId: plexTrack.id,
|
||||
// Every row here is a real external file: preload it with the
|
||||
// media so the non-selected tracks stay selectable as secondary
|
||||
// subtitles without a reopen (#1860).
|
||||
preload: true,
|
||||
track: SubtitleTrack.uri(
|
||||
url,
|
||||
title:
|
||||
|
||||
@@ -696,7 +696,9 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
|
||||
'copyts': '0',
|
||||
'Accept-Language': 'en',
|
||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
||||
'X-Plex-Client-Profile-Extra': _buildPlexHlsClientProfileExtra(),
|
||||
'X-Plex-Client-Profile-Extra': _buildPlexHlsClientProfileExtra(
|
||||
videoTranscodeTarget: _plexHlsLiveVideoTranscodeTarget,
|
||||
),
|
||||
'X-Plex-Incomplete-Segments': '1',
|
||||
'X-Plex-Product': config.product,
|
||||
'X-Plex-Version': config.version,
|
||||
|
||||
@@ -1,248 +0,0 @@
|
||||
import 'package:connectivity_plus/connectivity_plus.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../connection/connection_registry.dart';
|
||||
import '../database/app_database.dart';
|
||||
import '../media/ids.dart';
|
||||
import '../media/media_item.dart';
|
||||
import '../media/media_server_client.dart';
|
||||
import '../profiles/active_profile_binder.dart';
|
||||
import '../profiles/active_profile_provider.dart';
|
||||
import '../profiles/plex_home_service.dart';
|
||||
import '../profiles/profile_connection_registry.dart';
|
||||
import '../profiles/profile_registry.dart';
|
||||
import '../providers/multi_server_provider.dart';
|
||||
import '../utils/app_logger.dart';
|
||||
import 'data_aggregation_service.dart';
|
||||
import 'jellyfin_api_cache.dart';
|
||||
import 'multi_server_manager.dart';
|
||||
import 'plex_api_cache.dart';
|
||||
import 'settings_service.dart';
|
||||
import 'storage_service.dart';
|
||||
import 'system_shelf_service.dart';
|
||||
|
||||
const MethodChannel _watchNextChannel = MethodChannel('com.plezy/watch_next');
|
||||
|
||||
/// Matches DiscoverProvider.continueWatchingPreviewLimit — the shelf mirrors
|
||||
/// the Continue Watching preview row.
|
||||
const int _shelfItemLimit = 20;
|
||||
|
||||
/// Android Watch Next background refresh entrypoint.
|
||||
///
|
||||
/// `ShelfRefreshWorker` runs this on a headless FlutterEngine via
|
||||
/// `DartEntrypoint` while no UI engine is alive, so the launcher row keeps
|
||||
/// tracking Continue Watching without the app in the foreground. The worker
|
||||
/// resolves its result from the terminal `backgroundSyncComplete` call this
|
||||
/// isolate always makes, and destroys the engine afterwards.
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> systemShelfBackgroundMain() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
await runSystemShelfBackgroundSync(
|
||||
readActiveProfileId: () async => (await StorageService.getInstance()).getActiveProfileId(),
|
||||
openSession: openProductionSystemShelfSession,
|
||||
reportCompletion: _reportCompletion,
|
||||
);
|
||||
}
|
||||
|
||||
/// Everything the background sync needs from the app's cold-start stack once
|
||||
/// the active profile is bound. Production wiring lives in
|
||||
/// [openProductionSystemShelfSession]; tests inject handwritten fakes.
|
||||
class SystemShelfBackgroundSession {
|
||||
SystemShelfBackgroundSession({
|
||||
required this.profileId,
|
||||
required this.fetchContinueWatching,
|
||||
required this.clientForServer,
|
||||
required this.hideSpoilers,
|
||||
required this.dispose,
|
||||
});
|
||||
|
||||
/// Resolved owner id for the shelf session. May differ from the raw stored
|
||||
/// id when [ActiveProfileProvider] falls back during resolution; the shelf
|
||||
/// owner must match what the foreground app would publish under.
|
||||
final String profileId;
|
||||
final Future<List<MediaItem>> Function() fetchContinueWatching;
|
||||
final MediaServerClient? Function(ServerId serverId) clientForServer;
|
||||
final bool hideSpoilers;
|
||||
final Future<void> Function() dispose;
|
||||
}
|
||||
|
||||
/// Core of the background refresh, seamed for tests.
|
||||
///
|
||||
/// Always calls [reportCompletion] exactly once — the native worker blocks on
|
||||
/// that callback — and always disposes an opened session, success or not.
|
||||
/// Returns the reported success value.
|
||||
@visibleForTesting
|
||||
Future<bool> runSystemShelfBackgroundSync({
|
||||
required Future<String?> Function() readActiveProfileId,
|
||||
required Future<SystemShelfBackgroundSession?> Function(String profileId) openSession,
|
||||
required Future<void> Function(bool success) reportCompletion,
|
||||
SystemShelfService? shelfService,
|
||||
}) async {
|
||||
var success = false;
|
||||
SystemShelfBackgroundSession? session;
|
||||
try {
|
||||
final profileId = await readActiveProfileId();
|
||||
if (profileId == null || profileId.isEmpty) {
|
||||
appLogger.i('System shelf background sync skipped: no active profile');
|
||||
return false;
|
||||
}
|
||||
session = await openSession(profileId);
|
||||
if (session == null) return false;
|
||||
final openedSession = session;
|
||||
|
||||
final items = await openedSession.fetchContinueWatching();
|
||||
final syncable = items
|
||||
.where((item) {
|
||||
final serverId = item.serverId;
|
||||
return serverId != null && openedSession.clientForServer(ServerId(serverId)) != null;
|
||||
})
|
||||
.toList(growable: false);
|
||||
|
||||
final shelf = shelfService ?? SystemShelfService();
|
||||
shelf.beginProfileSession(openedSession.profileId);
|
||||
success = await shelf.syncFromContinueWatching(openedSession.profileId, syncable, (serverId) {
|
||||
final client = openedSession.clientForServer(serverId);
|
||||
if (client == null) throw StateError('No owning client available for $serverId');
|
||||
return client;
|
||||
}, hideSpoilers: openedSession.hideSpoilers);
|
||||
} catch (e, st) {
|
||||
appLogger.e('System shelf background sync failed', error: e, stackTrace: st);
|
||||
success = false;
|
||||
} finally {
|
||||
try {
|
||||
await session?.dispose();
|
||||
} catch (e, st) {
|
||||
appLogger.w('System shelf background session teardown failed', error: e, stackTrace: st);
|
||||
}
|
||||
await reportCompletion(success);
|
||||
}
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Builds the same stack `main.dart` assembles on cold start, minus UI:
|
||||
/// database + registries, [MultiServerManager]/[MultiServerProvider],
|
||||
/// [ActiveProfileProvider], and an [ActiveProfileBinder] whose PIN prompt
|
||||
/// always declines — a background isolate must never prompt, so a protected
|
||||
/// Plex Home profile simply fails its bind and this run completes false.
|
||||
///
|
||||
/// Returns null (after tearing down whatever was opened) when the device is
|
||||
/// offline, no connections are stored, no profile resolves, or the bind fails.
|
||||
Future<SystemShelfBackgroundSession?> openProductionSystemShelfSession(String profileId) async {
|
||||
// Mirror SetupScreen's offline fast path: with no network the binder would
|
||||
// only burn the worker's budget failing every connect. A probe failure is
|
||||
// treated as online, exactly like startup.
|
||||
try {
|
||||
final connectivity = await Connectivity().checkConnectivity().timeout(
|
||||
const Duration(seconds: 3),
|
||||
onTimeout: () => [ConnectivityResult.other],
|
||||
);
|
||||
if (connectivity.contains(ConnectivityResult.none)) {
|
||||
appLogger.i('System shelf background sync skipped: device is offline');
|
||||
return null;
|
||||
}
|
||||
} catch (_) {
|
||||
// connectivity_plus can throw on platforms without a network manager.
|
||||
}
|
||||
|
||||
final storage = await StorageService.getInstance();
|
||||
final settings = await SettingsService.getInstance();
|
||||
final bootstrap = await AppDatabase.open();
|
||||
final database = bootstrap.database;
|
||||
|
||||
MultiServerManager? serverManager;
|
||||
MultiServerProvider? multiServerProvider;
|
||||
ActiveProfileProvider? activeProfile;
|
||||
ActiveProfileBinder? binder;
|
||||
PlexHomeService? plexHome;
|
||||
|
||||
Future<void> tearDown() async {
|
||||
binder?.dispose();
|
||||
multiServerProvider?.dispose();
|
||||
final manager = serverManager;
|
||||
if (manager != null) {
|
||||
await manager.disconnectAllGracefully();
|
||||
manager.dispose();
|
||||
}
|
||||
activeProfile?.dispose();
|
||||
await plexHome?.dispose();
|
||||
await database.close();
|
||||
}
|
||||
|
||||
try {
|
||||
final connections = ConnectionRegistry(database);
|
||||
if ((await connections.list()).isEmpty) {
|
||||
appLogger.i('System shelf background sync skipped: no stored connections');
|
||||
await tearDown();
|
||||
return null;
|
||||
}
|
||||
|
||||
PlexApiCache.initialize(database);
|
||||
JellyfinApiCache.initialize(database);
|
||||
|
||||
final profileConnections = ProfileConnectionRegistry(database);
|
||||
final profileRegistry = ProfileRegistry(database);
|
||||
plexHome = PlexHomeService(connections: connections, profileConnections: profileConnections, storage: storage);
|
||||
await plexHome.start();
|
||||
|
||||
activeProfile = ActiveProfileProvider(
|
||||
registry: profileRegistry,
|
||||
plexHome: plexHome,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
storage: storage,
|
||||
);
|
||||
await activeProfile.initialize();
|
||||
final resolvedProfileId = activeProfile.activeId;
|
||||
if (resolvedProfileId == null) {
|
||||
appLogger.i('System shelf background sync skipped: stored profile id did not resolve');
|
||||
await tearDown();
|
||||
return null;
|
||||
}
|
||||
|
||||
serverManager = MultiServerManager();
|
||||
serverManager.onJellyfinConnectionUpdated = connections.upsert;
|
||||
final aggregation = DataAggregationService(serverManager);
|
||||
multiServerProvider = MultiServerProvider(serverManager, aggregation);
|
||||
|
||||
binder = ActiveProfileBinder(
|
||||
activeProfile: activeProfile,
|
||||
connections: connections,
|
||||
profileConnections: profileConnections,
|
||||
serverManager: serverManager,
|
||||
multiServerProvider: multiServerProvider,
|
||||
pinPrompt: (profile, {errorMessage}) async => null,
|
||||
);
|
||||
binder.start();
|
||||
final bound = await activeProfile.awaitBindingSettle();
|
||||
if (!bound) {
|
||||
appLogger.w('System shelf background sync skipped: profile bind failed');
|
||||
await tearDown();
|
||||
return null;
|
||||
}
|
||||
final manager = serverManager;
|
||||
|
||||
return SystemShelfBackgroundSession(
|
||||
profileId: resolvedProfileId,
|
||||
// Hidden-library filtering is deliberately skipped: it lives in the
|
||||
// profile-scoped HiddenLibrariesProvider subtree that only exists with
|
||||
// a UI session, and the foreground app re-syncs the shelf with the
|
||||
// filter applied on next launch, correcting any transient difference.
|
||||
fetchContinueWatching: () async => (await aggregation.getOnDeckFromAllServers(limit: _shelfItemLimit)).items,
|
||||
clientForServer: manager.getClient,
|
||||
hideSpoilers: settings.read(SettingsService.hideSpoilers),
|
||||
dispose: tearDown,
|
||||
);
|
||||
} catch (e) {
|
||||
await tearDown();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _reportCompletion(bool success) async {
|
||||
try {
|
||||
await _watchNextChannel.invokeMethod<void>('backgroundSyncComplete', success);
|
||||
} catch (e) {
|
||||
appLogger.w('Failed to report background shelf sync completion', error: e);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -177,45 +177,6 @@ class MediaServerHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
/// Issue a GET and return only status and headers, draining the body
|
||||
/// unread — the shape for probes that ask "does this answer?" rather than
|
||||
/// "what does it say?".
|
||||
///
|
||||
/// Unlike [getBytes] the status code is surfaced instead of only logged.
|
||||
/// Unlike [get] nothing is ever decoded, so a body that fails decoding
|
||||
/// cannot convert a status into an exception, and — because
|
||||
/// [FailoverHttpClient] overrides [get] alone — this method structurally
|
||||
/// never enters the endpoint-failover cascade. Non-2xx is returned, not
|
||||
/// thrown, matching [get].
|
||||
Future<MediaServerResponse> getStatus(
|
||||
String url, {
|
||||
Map<String, String>? headers,
|
||||
Duration? timeout,
|
||||
AbortController? abort,
|
||||
}) {
|
||||
return _perform<MediaServerResponse>(
|
||||
'GET',
|
||||
url,
|
||||
headers: headers,
|
||||
timeout: timeout,
|
||||
abort: abort,
|
||||
consume: (streamed, scope) async {
|
||||
final effectiveUri = switch (streamed) {
|
||||
http.BaseResponseWithUrl(:final url) => url,
|
||||
_ => scope.uri,
|
||||
};
|
||||
await scope.receive(streamed.stream.drain<void>());
|
||||
scope.logResponse(streamed.statusCode);
|
||||
return MediaServerResponse(
|
||||
statusCode: streamed.statusCode,
|
||||
headers: streamed.headers,
|
||||
requestUri: scope.uri,
|
||||
effectiveUri: effectiveUri,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Stream-download a URL directly into a file.
|
||||
Future<void> downloadFile(
|
||||
String url,
|
||||
|
||||
@@ -68,7 +68,6 @@ class BottomSheetHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final usesBackButton = leading == null && onBack != null;
|
||||
|
||||
// Determine the leading widget based on priority: leading > onBack > icon
|
||||
Widget? resolvedLeading;
|
||||
if (leading != null) {
|
||||
resolvedLeading = leading;
|
||||
|
||||
@@ -92,7 +92,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
@override
|
||||
void didUpdateWidget(DownloadTreeView oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
// When suppressAutoFocus changes from true to false, focus the first item
|
||||
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _firstItemFocusNode.canRequestFocus) {
|
||||
@@ -121,13 +120,11 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
/// Build the download tree from flat download list
|
||||
List<DownloadTreeNode> _buildTree() {
|
||||
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
|
||||
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
|
||||
final List<DownloadTreeNode> movies = [];
|
||||
|
||||
// Group downloads
|
||||
for (final entry in widget.downloads.entries) {
|
||||
final globalKey = entry.key;
|
||||
final download = entry.value;
|
||||
@@ -136,17 +133,14 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
if (meta == null) continue;
|
||||
|
||||
if (meta.isEpisode) {
|
||||
// Group episodes by show
|
||||
final showKey = meta.grandparentId ?? 'unknown';
|
||||
showGroups.putIfAbsent(showKey, () => []);
|
||||
showGroups[showKey]!.add(entry);
|
||||
} else if (meta.kind == MediaKind.track) {
|
||||
// Group tracks by album (single level — no per-disc tier)
|
||||
final albumKey = meta.parentId ?? 'unknown';
|
||||
albumGroups.putIfAbsent(albumKey, () => []);
|
||||
albumGroups[albumKey]!.add(entry);
|
||||
} else if (meta.isMovie) {
|
||||
// Movies go at top level
|
||||
movies.add(
|
||||
DownloadTreeNode(
|
||||
key: globalKey,
|
||||
@@ -161,7 +155,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
}
|
||||
}
|
||||
|
||||
// Build show nodes
|
||||
final List<DownloadTreeNode> shows = [];
|
||||
for (final showEntry in showGroups.entries) {
|
||||
final showKey = showEntry.key;
|
||||
@@ -169,11 +162,9 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
if (episodes.isEmpty) continue;
|
||||
|
||||
// Get show metadata from first episode
|
||||
final firstEpisode = widget.metadata[episodes.first.key];
|
||||
final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow;
|
||||
|
||||
// Group episodes by season
|
||||
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
|
||||
for (final episode in episodes) {
|
||||
final meta = widget.metadata[episode.key];
|
||||
@@ -184,7 +175,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
seasonGroups[seasonKey]!.add(episode);
|
||||
}
|
||||
|
||||
// Build season nodes
|
||||
final List<DownloadTreeNode> seasons = [];
|
||||
for (final seasonEntry in seasonGroups.entries) {
|
||||
final seasonKey = seasonEntry.key;
|
||||
@@ -192,7 +182,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
if (seasonEpisodes.isEmpty) continue;
|
||||
|
||||
// Get season metadata from first episode
|
||||
final firstEpisode = widget.metadata[seasonEpisodes.first.key];
|
||||
final seasonNumber = firstEpisode?.parentIndex;
|
||||
final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true
|
||||
@@ -201,7 +190,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
? t.common.seasonNumber(number: seasonNumber)
|
||||
: t.downloads.unknownSeason;
|
||||
|
||||
// Build episode nodes
|
||||
final List<DownloadTreeNode> episodeNodes = [];
|
||||
for (final episodeEntry in seasonEpisodes) {
|
||||
final globalKey = episodeEntry.key;
|
||||
@@ -228,14 +216,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort episodes by episode number only (not by status)
|
||||
episodeNodes.sort((a, b) {
|
||||
final aIndex = a.metadata?.index ?? 0;
|
||||
final bIndex = b.metadata?.index ?? 0;
|
||||
return aIndex.compareTo(bIndex);
|
||||
});
|
||||
|
||||
// Calculate aggregate season progress
|
||||
final seasonProgress = episodeNodes.isEmpty
|
||||
? 0.0
|
||||
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length;
|
||||
@@ -255,14 +241,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
|
||||
seasons.removeWhere((s) => s.children.isEmpty);
|
||||
|
||||
// Sort seasons by season number
|
||||
seasons.sort((a, b) {
|
||||
final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0;
|
||||
final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0;
|
||||
return aSeasonNum.compareTo(bSeasonNum);
|
||||
});
|
||||
|
||||
// Calculate aggregate show progress
|
||||
final showProgress = seasons.isEmpty
|
||||
? 0.0
|
||||
: seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length;
|
||||
@@ -280,14 +264,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
// Build album nodes (album -> tracks)
|
||||
final List<DownloadTreeNode> albums = [];
|
||||
for (final albumEntry in albumGroups.entries) {
|
||||
final albumKey = albumEntry.key;
|
||||
final tracks = albumEntry.value;
|
||||
if (tracks.isEmpty) continue;
|
||||
|
||||
// Album/artist names from any track's parent fields
|
||||
final firstTrack = widget.metadata[tracks.first.key];
|
||||
final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum;
|
||||
final artistTitle = firstTrack?.albumArtistTitle;
|
||||
@@ -314,7 +296,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
}
|
||||
if (trackNodes.isEmpty) continue;
|
||||
|
||||
// Sort tracks by disc then track number
|
||||
trackNodes.sort((a, b) {
|
||||
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
|
||||
if (byDisc != 0) return byDisc;
|
||||
@@ -336,17 +317,13 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
);
|
||||
}
|
||||
|
||||
// Sort shows, albums, and movies by status and title
|
||||
_sortNodesByStatusAndTitle(shows);
|
||||
_sortNodesByStatusAndTitle(albums);
|
||||
_sortNodesByStatusAndTitle(movies);
|
||||
|
||||
// Combine movies, shows, and albums
|
||||
return [...movies, ...shows, ...albums];
|
||||
}
|
||||
|
||||
/// Determine aggregate status from child statuses
|
||||
/// Priority: downloading > queued > paused > completed > failed
|
||||
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
|
||||
if (statuses.isEmpty) return DownloadStatus.queued;
|
||||
|
||||
@@ -365,7 +342,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
return DownloadStatus.completed;
|
||||
}
|
||||
|
||||
/// Compare statuses for sorting (downloading first, then queued, etc.)
|
||||
int _compareByStatus(DownloadStatus a, DownloadStatus b) {
|
||||
const statusOrder = {
|
||||
DownloadStatus.downloading: 0,
|
||||
@@ -378,7 +354,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
|
||||
}
|
||||
|
||||
/// Sort nodes by status (downloading first) then by title
|
||||
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
|
||||
nodes.sort((a, b) {
|
||||
final statusCompare = _compareByStatus(a.status, b.status);
|
||||
@@ -414,7 +389,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
||||
});
|
||||
}
|
||||
|
||||
/// Build a tree item widget
|
||||
Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) {
|
||||
return _DownloadTreeItem(
|
||||
node: node,
|
||||
@@ -590,9 +564,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
return widget.node.status;
|
||||
}
|
||||
|
||||
// Focus node for row content (only created if not provided externally)
|
||||
FocusNode? _ownedRowFocusNode;
|
||||
// Focus nodes for action buttons (up to 3 buttons max)
|
||||
final List<FocusNode> _buttonFocusNodes = [];
|
||||
|
||||
FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!;
|
||||
@@ -676,10 +648,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
// Row content
|
||||
Expanded(child: _buildRowContent(theme, canExpand)),
|
||||
|
||||
// Action buttons
|
||||
if (actions.isNotEmpty)
|
||||
Row(
|
||||
mainAxisSize: .min,
|
||||
@@ -696,7 +666,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
Widget _buildRowContent(ThemeData theme, bool canExpand) {
|
||||
return Row(
|
||||
children: [
|
||||
// Expand/collapse icon
|
||||
if (canExpand)
|
||||
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
|
||||
else
|
||||
@@ -704,12 +673,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
||||
|
||||
const SizedBox(width: 8),
|
||||
|
||||
// Status icon
|
||||
DownloadStatusIcon(status: _effectiveStatus, size: 20),
|
||||
|
||||
const SizedBox(width: 12),
|
||||
|
||||
// Title and info
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: .start,
|
||||
|
||||
@@ -275,7 +275,6 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key down and repeat events
|
||||
if (!event.isActionable) {
|
||||
return KeyEventResult.ignored;
|
||||
}
|
||||
|
||||
@@ -289,7 +289,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
final ScrollController _dialogScrollController = ScrollController();
|
||||
final ScrollController _sheetScrollController = ScrollController();
|
||||
|
||||
// Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button.
|
||||
@override
|
||||
List<MediaLibrary> get reorderItems => _tempLibraries;
|
||||
|
||||
@@ -468,16 +467,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
||||
final isHidden = hiddenLibraryKeys.contains(library.globalKey);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
|
||||
// Determine background color based on state
|
||||
Color? tileColor;
|
||||
if (isMoving) {
|
||||
tileColor = colorScheme.primaryContainer;
|
||||
} else if (isFocused && focusedColumn == 0) {
|
||||
// Only highlight row when row itself is focused (column 0)
|
||||
tileColor = colorScheme.surfaceContainerHighest;
|
||||
}
|
||||
|
||||
// Button focus states
|
||||
final isVisibilityButtonFocused = isFocused && focusedColumn == 1;
|
||||
final isOptionsButtonFocused = isFocused && focusedColumn == 2;
|
||||
|
||||
|
||||
@@ -615,13 +615,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
mainAxisSize: .min,
|
||||
crossAxisAlignment: .start,
|
||||
children: [
|
||||
// Poster with overlay
|
||||
if (posterHeight != null)
|
||||
SizedBox(width: double.infinity, height: posterHeight, child: poster)
|
||||
else
|
||||
Expanded(child: poster),
|
||||
const SizedBox(height: 2),
|
||||
// Title (flattened — no inner Column)
|
||||
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
|
||||
_ClickableText(
|
||||
text: item.displayTitle,
|
||||
@@ -637,7 +635,6 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
|
||||
),
|
||||
),
|
||||
// Subtitle
|
||||
if (item is MediaPlaylist)
|
||||
_MediaCardHelpers.buildPlaylistMeta(context, item)
|
||||
else if (item is MediaItem)
|
||||
@@ -1131,7 +1128,6 @@ class _MediaCardHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
// For collections, show item count
|
||||
if (mi.kind == MediaKind.collection) {
|
||||
final count = mi.childCount ?? mi.leafCount;
|
||||
if (count != null && count > 0) {
|
||||
@@ -1146,14 +1142,12 @@ class _MediaCardHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
// For albums, show the album artist
|
||||
if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) {
|
||||
return ExcludeSemantics(
|
||||
child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||
);
|
||||
}
|
||||
|
||||
// For tracks, show "Artist • duration"
|
||||
if (mi.kind == MediaKind.track) {
|
||||
final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)];
|
||||
if (parts.isNotEmpty) {
|
||||
@@ -1163,7 +1157,6 @@ class _MediaCardHelpers {
|
||||
}
|
||||
}
|
||||
|
||||
// For episodes, show "S# · Episode Title" with clickable season link
|
||||
if (mi.isEpisode && mi.parentIndex != null) {
|
||||
if (enableDetailLinks && mi.parentId != null) {
|
||||
return _buildEpisodeSubtitleRow(
|
||||
@@ -1185,7 +1178,6 @@ class _MediaCardHelpers {
|
||||
);
|
||||
}
|
||||
|
||||
// For other media types, show subtitle/parent/year
|
||||
if (mi.displaySubtitle != null) {
|
||||
return ExcludeSemantics(
|
||||
child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user