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 }}
|
MACOS_CERTIFICATE_PASSWORD: ${{ secrets.MACOS_CERTIFICATE_PASSWORD }}
|
||||||
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }}
|
||||||
run: |
|
run: |
|
||||||
# Create temporary keychain
|
|
||||||
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
|
KEYCHAIN_PATH=$RUNNER_TEMP/build.keychain
|
||||||
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
security create-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
security set-keychain-settings -lut 21600 $KEYCHAIN_PATH
|
||||||
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
security unlock-keychain -p "$KEYCHAIN_PASSWORD" $KEYCHAIN_PATH
|
||||||
|
|
||||||
# Import certificate to keychain
|
|
||||||
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
CERTIFICATE_PATH=$RUNNER_TEMP/certificate.p12
|
||||||
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
echo "$MACOS_CERTIFICATE_BASE64" | base64 --decode -o $CERTIFICATE_PATH
|
||||||
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
security import $CERTIFICATE_PATH -k $KEYCHAIN_PATH -P "$MACOS_CERTIFICATE_PASSWORD" -T /usr/bin/codesign
|
||||||
|
|||||||
+1
-3
@@ -18,9 +18,7 @@ migrate_working_dir/
|
|||||||
*.iws
|
*.iws
|
||||||
.idea/
|
.idea/
|
||||||
|
|
||||||
# The .vscode folder contains launch configuration and tasks you configure in
|
# Keep .vscode/ available for contributors' local configuration.
|
||||||
# VS Code which you may wish to be included in version control, so this line
|
|
||||||
# is commented out by default.
|
|
||||||
#.vscode/
|
#.vscode/
|
||||||
|
|
||||||
# Flutter/Dart/Pub related
|
# Flutter/Dart/Pub related
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ tags:
|
|||||||
- pressKey: "Remote Dpad Up"
|
- pressKey: "Remote Dpad Up"
|
||||||
- pressKey: "Remote Dpad Right"
|
- pressKey: "Remote Dpad Right"
|
||||||
- pressKey: "Remote Dpad Right"
|
- pressKey: "Remote Dpad Right"
|
||||||
# The grid row must be matchable before the sort sheet opens, otherwise the
|
# Wait for the row before opening the sheet so occlusion is tested against the
|
||||||
# occlusion assertion below would pass for the wrong reason.
|
# grid, not an absent or stale match.
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*Alpha Archive.*"
|
visible: "(?s).*Alpha Archive.*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
@@ -31,9 +31,8 @@ tags:
|
|||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Title"
|
visible: "Title"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
# The host renders the grid, the barrier, and the sheet in one Stack and never
|
# The grid remains mounted under the Stack; BlockSemantics is required for
|
||||||
# unmounts the grid, so before 8e1904dd wrapped the barrier in BlockSemantics
|
# occluded rows to disappear from Maestro's accessibility tree.
|
||||||
# an occluded row still read as visible to accessibility and to Maestro.
|
|
||||||
- assertNotVisible: "(?s).*Alpha Archive.*"
|
- assertNotVisible: "(?s).*Alpha Archive.*"
|
||||||
- pressKey: "Remote Dpad Center"
|
- pressKey: "Remote Dpad Center"
|
||||||
- pressKey: "Remote Dpad Left"
|
- pressKey: "Remote Dpad Left"
|
||||||
|
|||||||
@@ -16,13 +16,9 @@ tags:
|
|||||||
visible: "(?s)^(Play|Resume).*$"
|
visible: "(?s)^(Play|Resume).*$"
|
||||||
commands:
|
commands:
|
||||||
- pressKey: "Remote Dpad Center"
|
- pressKey: "Remote Dpad Center"
|
||||||
# The TV player opens with its chrome down so the OSD and timebar never sit
|
# TV playback starts with chrome hidden (#1765), so wait for its loading label
|
||||||
# over the opening seconds of the picture (#1765), so the Pause button can no
|
# rather than treating the Pause button as readiness. A failed open reaches the
|
||||||
# longer stand in for "the player has finished loading". Wait for the detail
|
# transport assertions below.
|
||||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
|
||||||
# player route owns the frame, and it only clears once playback reports its
|
|
||||||
# first frame or gives up, so this can never advance before the media opens.
|
|
||||||
# A give-up lands on the transport assertions below, which is where it belongs.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
notVisible: "Overview"
|
notVisible: "Overview"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
@@ -30,18 +26,14 @@ tags:
|
|||||||
notVisible: "Loading video"
|
notVisible: "Loading video"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
- assertNotVisible: "(?s)^(Play|Pause)$"
|
- assertNotVisible: "(?s)^(Play|Pause)$"
|
||||||
# Hardware transport keys drive playback without raising the chrome (#1676):
|
# Transport keys keep chrome hidden (#1676); pause first so seeks cannot race
|
||||||
# the player answers with a centred disc (pause) or a side readout (seek), never the
|
# the end of this short fixture.
|
||||||
# full chrome, so subtitles stay readable.
|
|
||||||
# Pause first — it also freezes the clip, so the seek assertions below cannot
|
|
||||||
# race the end of a short fixture item.
|
|
||||||
- pressKey: "Remote Media Play Pause"
|
- pressKey: "Remote Media Play Pause"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Paused"
|
visible: "Paused"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertNotVisible: "(?s)^Play$"
|
- assertNotVisible: "(?s)^Play$"
|
||||||
# D-pad seeking reports through the skip badge, never the scrub bar, and
|
# D-pad seeks use the cumulative skip badge, not the scrub bar.
|
||||||
# consecutive presses in one direction stack into a running total.
|
|
||||||
- pressKey: "Remote Dpad Right"
|
- pressKey: "Remote Dpad Right"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^Seek forward 10 seconds$"
|
visible: "(?s)^Seek forward 10 seconds$"
|
||||||
@@ -56,7 +48,7 @@ tags:
|
|||||||
visible: "Playing"
|
visible: "Playing"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertNotVisible: "(?s)^Pause$"
|
- assertNotVisible: "(?s)^Pause$"
|
||||||
# Select stays the deliberate way to bring the chrome back.
|
# Select is the explicit way to restore the chrome.
|
||||||
- pressKey: "Remote Dpad Center"
|
- pressKey: "Remote Dpad Center"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^(Play|Pause)$"
|
visible: "(?s)^(Play|Pause)$"
|
||||||
|
|||||||
@@ -7,10 +7,8 @@ tags:
|
|||||||
- playback
|
- playback
|
||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
- runFlow: ../subflows/onboard_jellyfin_tv.yaml
|
||||||
# Reach Search with the D-pad, the way 10_tv_settings_navigation.yaml does. A
|
# Reach Search by D-pad: taps switch to pointer mode and hide the TV rail label,
|
||||||
# tap here flips InputModeTracker into pointer mode, which collapses the rail
|
# while percentage coordinates are not portable across TV sizes.
|
||||||
# to icons and leaves no "Search" label to hit — and a percentage coordinate
|
|
||||||
# does not survive the jump from a phone in forced TV mode to a 4K television.
|
|
||||||
- pressKey: "Remote Dpad Left"
|
- pressKey: "Remote Dpad Left"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Search"
|
visible: "Search"
|
||||||
@@ -38,11 +36,8 @@ tags:
|
|||||||
- waitForAnimationToEnd:
|
- waitForAnimationToEnd:
|
||||||
timeout: 5000
|
timeout: 5000
|
||||||
- tapOn: "(?s)^Play S1E1$"
|
- tapOn: "(?s)^Play S1E1$"
|
||||||
# The TV player opens with its chrome down (#1765), so the Pause button can no
|
# The player starts with chrome hidden (#1765); wait for its loading label
|
||||||
# longer stand in for "the player has finished loading". Wait for the detail
|
# rather than using Pause as readiness.
|
||||||
# screen to go first: the labelled spinner is necessarily up the moment the
|
|
||||||
# player route owns the frame, and it only clears once playback reports its
|
|
||||||
# first frame or gives up, so this can never advance before the media opens.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
notVisible: "(?s)^Play S1E1$"
|
notVisible: "(?s)^Play S1E1$"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
@@ -51,9 +46,7 @@ tags:
|
|||||||
timeout: 30000
|
timeout: 30000
|
||||||
- pressKey: "Remote Media Fast Forward"
|
- pressKey: "Remote Media Fast Forward"
|
||||||
- pressKey: "Remote Media Fast Forward"
|
- pressKey: "Remote Media Fast Forward"
|
||||||
# Gate on the prompt's own Cancel action, the label the iOS branch below taps.
|
# Wait for Cancel, not Next Episode (the credits-skip action), before pressing Back.
|
||||||
# "Next Episode" is the credits skip button at this point in the episode, so
|
|
||||||
# waiting on it would let Back fire before the prompt ever opened.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^Cancel$"
|
visible: "(?s)^Cancel$"
|
||||||
timeout: 20000
|
timeout: 20000
|
||||||
|
|||||||
@@ -8,10 +8,8 @@ tags:
|
|||||||
---
|
---
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- tapOn: "(?s)^Libraries.*"
|
- tapOn: "(?s)^Libraries.*"
|
||||||
# Every seeded alphabet title carries the same dateadded, so which of them the
|
# All seeded titles share dateadded, so accept any playable Recently Added movie
|
||||||
# "Recently Added" rail returns is not stable across scans. This flow only
|
# instead of relying on an unstable tie-break.
|
||||||
# needs some playable movie, so accept any of them rather than pinning one and
|
|
||||||
# failing whenever the tie-break lands elsewhere.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
visible: "(?s).*(?:Alpha Archive|Bravo Beacon|Charlie Circuit|Delta Drive|Echo Engine|Foxtrot Frame|Gamma Garden|Hotel Horizon|India Index|Juliet Junction|Kilo Key|Lima Loop|Mike Matrix|November Node|Oscar Orbit|Papa Pipeline|Quebec Queue|Romeo Relay|Sierra Signal|Tango Track|Uniform Update|Victor View|Whiskey Widget|Xray XML|Yankee Yield|Zulu Zone).*, movie, .*"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|||||||
@@ -7,20 +7,9 @@ tags:
|
|||||||
- settings
|
- settings
|
||||||
- sheets
|
- sheets
|
||||||
---
|
---
|
||||||
# Guards 8e1904dd and c48cbf70 together.
|
# Guards 8e1904dd and c48cbf70: phone Settings must host the sheet so one Back
|
||||||
#
|
# dismisses only the sheet, while BlockSemantics hides rows behind the barrier.
|
||||||
# On a phone, Settings is a pushed route. Before c48cbf70 it carried no
|
# Start clean because preceding TV flows may leave Force TV mode enabled.
|
||||||
# OverlaySheetHost, so Manage Libraries fell back to showModalBottomSheet and a
|
|
||||||
# single Back tore down the sheet *and* Settings. Before 8e1904dd the host
|
|
||||||
# answered the platform pop with a TV-only dedup marker that could already be
|
|
||||||
# spent, stranding the sheet open with no way out. Either defect fails the
|
|
||||||
# "Manage Libraries is still there after one Back" assertion below.
|
|
||||||
#
|
|
||||||
# The same commit blocks semantics behind the barrier, so the occlusion
|
|
||||||
# assertions pin that an open sheet actually hides the rows underneath.
|
|
||||||
# Pristine state on purpose. The TV regressions in the same CI group leave
|
|
||||||
# "Force TV mode" enabled, and this flow asserts the handheld Settings route,
|
|
||||||
# so it must not inherit whichever layout the previous flow left behind.
|
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- runFlow: ../subflows/open_settings.yaml
|
- runFlow: ../subflows/open_settings.yaml
|
||||||
- assertVisible: "(?s)^Services.*"
|
- assertVisible: "(?s)^Services.*"
|
||||||
@@ -29,10 +18,8 @@ tags:
|
|||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Maestro Movies"
|
visible: "Maestro Movies"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
# The barrier swallows every pointer event, so the rows behind it must leave
|
# The barrier removes covered rows from semantics; the sheet title remains
|
||||||
# the semantics tree too. Without BlockSemantics an occluded row still reads as
|
# visible because it belongs to the sheet.
|
||||||
# visible and a tap on it lands on the barrier instead. "Manage Libraries" is
|
|
||||||
# excluded on purpose: it is also the sheet's own title.
|
|
||||||
- assertNotVisible: "(?s)^Services.*"
|
- assertNotVisible: "(?s)^Services.*"
|
||||||
- assertNotVisible: "(?s)^Video Playback.*"
|
- assertNotVisible: "(?s)^Video Playback.*"
|
||||||
- assertNotVisible: "(?s)^Appearance.*"
|
- assertNotVisible: "(?s)^Appearance.*"
|
||||||
@@ -41,15 +28,13 @@ tags:
|
|||||||
- waitForAnimationToEnd:
|
- waitForAnimationToEnd:
|
||||||
timeout: 3000
|
timeout: 3000
|
||||||
- assertNotVisible: "Maestro Movies"
|
- assertNotVisible: "Maestro Movies"
|
||||||
# One Back closed only the sheet: Settings is still the current route.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^Manage Libraries.*"
|
visible: "(?s)^Manage Libraries.*"
|
||||||
timeout: 10000
|
timeout: 10000
|
||||||
- assertVisible: "(?s)^Services.*"
|
- assertVisible: "(?s)^Services.*"
|
||||||
- assertVisible: "(?s)^Video Playback.*"
|
- assertVisible: "(?s)^Video Playback.*"
|
||||||
- assertNotVisible: "Discover"
|
- assertNotVisible: "Discover"
|
||||||
# Reopening proves the host did not strand the one-shot dedup marker: a second
|
# Reopening verifies the one-shot pop deduplication marker was not stranded.
|
||||||
# dismissal has to work exactly like the first.
|
|
||||||
- tapOn: "(?s)^Manage Libraries.*"
|
- tapOn: "(?s)^Manage Libraries.*"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Maestro Movies"
|
visible: "Maestro Movies"
|
||||||
|
|||||||
@@ -7,20 +7,10 @@ tags:
|
|||||||
- playback
|
- playback
|
||||||
- subtitles
|
- subtitles
|
||||||
---
|
---
|
||||||
# Guards 468d6804. When a source advertises tracks the native list has not
|
# Guards 468d6804: a user pick must retire TrackManager's pending automatic
|
||||||
# produced yet, TrackManager arms an automatic selection pass with a 5s first
|
# selection pass, or the server preference can overwrite it up to 30s later.
|
||||||
# attempt and a 25s deadline. A user pick made inside that window used to only
|
# The codec fixture supplies non-default audio and subtitle choices, and clean
|
||||||
# persist the preference, leaving the pass armed, so the choice snapped back to
|
# state prevents remembered selections from making the assertions vacuous.
|
||||||
# the server/profile preferred track up to ~30s later. The pick now retires the
|
|
||||||
# pending pass, and the wait below is long enough to outlive both timers.
|
|
||||||
#
|
|
||||||
# Fixture: Codec H264 EAC3 Multisub advertises three E-AC-3 audio streams
|
|
||||||
# (Hindi and Japanese flagged default, English not) and 36 SRT subtitle
|
|
||||||
# streams, so both picks below are genuine changes away from the automatic one.
|
|
||||||
# Pristine state on purpose, for two reasons: the TV regressions in the same
|
|
||||||
# CI group leave "Force TV mode" enabled, and a remembered track selection
|
|
||||||
# from an earlier run would pre-select the very rows this flow picks, making
|
|
||||||
# every assertion below vacuous.
|
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- runFlow:
|
- runFlow:
|
||||||
file: ../subflows/open_codec_sample.yaml
|
file: ../subflows/open_codec_sample.yaml
|
||||||
|
|||||||
@@ -7,16 +7,9 @@ tags:
|
|||||||
- settings
|
- settings
|
||||||
- i18n
|
- i18n
|
||||||
---
|
---
|
||||||
# Guards 7677d159 and 100d7729. AppLocale is switched over exhaustively in
|
# Guards 7677d159 and 100d7729: selecting Turkish and reading root navigation
|
||||||
# appearance_settings_screen.dart, so a missing arm is a compile error, but a
|
# labels proves the generated locale is wired end to end. Start clean because
|
||||||
# locale whose JSON never reached the generator ships a picker entry that falls
|
# the language preference persists and affects every later selector.
|
||||||
# back to English. Selecting Turkish and reading root-level navigation labels
|
|
||||||
# proves the generated locale is wired end to end.
|
|
||||||
#
|
|
||||||
# This flow mutates a persisted pref, and the app language decides every
|
|
||||||
# selector after the tap, so it starts from a cleared install rather than
|
|
||||||
# ensure_onboarded: a run that dies mid-flow must not leave the next flow
|
|
||||||
# hunting for English strings in a Turkish tree.
|
|
||||||
- runFlow: ../subflows/onboard_jellyfin.yaml
|
- runFlow: ../subflows/onboard_jellyfin.yaml
|
||||||
- runFlow: ../subflows/open_settings.yaml
|
- runFlow: ../subflows/open_settings.yaml
|
||||||
- tapOn: "(?s)^Appearance.*"
|
- tapOn: "(?s)^Appearance.*"
|
||||||
@@ -54,9 +47,7 @@ tags:
|
|||||||
- assertVisible: "Keşfet"
|
- assertVisible: "Keşfet"
|
||||||
- assertNotVisible: "(?s)^Libraries.*"
|
- assertNotVisible: "(?s)^Libraries.*"
|
||||||
- assertNotVisible: "Discover"
|
- assertNotVisible: "Discover"
|
||||||
# Walk back through the Turkish tree and restore English. The pref outlives a
|
# Restore English before leaving the device; the locale preference outlives launchApp.
|
||||||
# plain launchApp, so leaving the device in Turkish would break every later
|
|
||||||
# flow in the suite.
|
|
||||||
- repeat:
|
- repeat:
|
||||||
times: 3
|
times: 3
|
||||||
while:
|
while:
|
||||||
|
|||||||
@@ -7,21 +7,11 @@ tags:
|
|||||||
- tv
|
- tv
|
||||||
- settings
|
- settings
|
||||||
---
|
---
|
||||||
# Guards ef310459 and the Apple-only gates touched by 6c14049e, and pins TV
|
# Guards ef310459 and the Apple-only gates touched by 6c14049e.
|
||||||
# settings navigation generally.
|
# This flow intentionally does not pin 15b54e2e's row density: semantics stay
|
||||||
#
|
# stable across that change, and Maestro has no DPR-relative height assertion.
|
||||||
# It deliberately does NOT guard the 15b54e2e density change. That commit took
|
# Use D-pad only; a tap switches InputModeTracker to pointer mode and removes
|
||||||
# every settings row from ~80dp to ~61dp, but the rows below the fold stay in
|
# the TV number spinner.
|
||||||
# the semantics tree either way, so this flow passes identically on both sides
|
|
||||||
# of it — measured, not assumed. A real guard would need a pixel `height`
|
|
||||||
# assertion, and Maestro has no DPR-relative form, so pinning one here would
|
|
||||||
# only hold for the box it was written on. Row geometry belongs to
|
|
||||||
# test/widgets/setting_tile_test.dart.
|
|
||||||
#
|
|
||||||
# Every step uses D-pad keys, never taps. InputModeTracker flips to pointer
|
|
||||||
# mode on the first touch event and the TV number spinner only builds in
|
|
||||||
# keyboard mode, so a single tapOn anywhere above would silently swap the
|
|
||||||
# spinner for a plain text field.
|
|
||||||
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
- runFlow: ../subflows/onboard_jellyfin_tv_device.yaml
|
||||||
- pressKey: "Remote Dpad Left"
|
- pressKey: "Remote Dpad Left"
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
appId: com.edde746.plezy
|
appId: com.edde746.plezy
|
||||||
---
|
---
|
||||||
# Reach a signed-in Home without paying for onboarding that already happened.
|
# Reach a signed-in Home without repeating onboarding. A plain launch preserves
|
||||||
#
|
# stored state (~16s versus ~59s for retyping credentials); fall back to full
|
||||||
# `onboard_jellyfin.yaml` clears app state and retypes the server URL and
|
# onboarding when no session exists so each flow remains runnable alone.
|
||||||
# credentials, which measures ~59s per flow on a Pixel 7 and dominates every
|
|
||||||
# flow that only needs "signed in, on Home". A plain `launchApp` keeps stored
|
|
||||||
# data and cold-starts straight back to Home, which measures ~16s.
|
|
||||||
#
|
|
||||||
# The guard keeps each flow runnable on its own: when no session is stored --
|
|
||||||
# a fresh install, or a run after the logout flow -- "Discover" is absent and
|
|
||||||
# this falls through to the real onboarding. Flows that must prove onboarding
|
|
||||||
# itself, or that need pristine state, keep calling onboard_jellyfin.yaml.
|
|
||||||
- launchApp
|
- launchApp
|
||||||
# Settle on a known screen first. Branching straight off `launchApp` races the
|
# Wait for the splash to resolve before deciding whether onboarding is needed.
|
||||||
# splash: "Discover" has not rendered yet, the guard reads it as absent, and
|
|
||||||
# every flow pays for a full re-onboarding it did not need.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
visible: "(?s)^(?:Discover|Sign in with Plex|Connect to Jellyfin|Wait).*"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
|
|||||||
@@ -10,10 +10,8 @@ appId: com.edde746.plezy
|
|||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
visible: "(?s)^(?:Connect to Jellyfin|Wait).*"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
# A miss on `tapOn ... optional: true` still runs Maestro's full element
|
# Optional taps still pay Maestro's full search cost when they miss; guard them
|
||||||
# search before giving up, which measured 3s here and 7.8s for "Sign in"
|
# with one visibility check instead.
|
||||||
# on every onboarding. These taps normally find nothing, so gate them on
|
|
||||||
# a single visibility check instead.
|
|
||||||
- runFlow:
|
- runFlow:
|
||||||
when:
|
when:
|
||||||
visible: "Wait"
|
visible: "Wait"
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
appId: com.edde746.plezy
|
appId: com.edde746.plezy
|
||||||
---
|
---
|
||||||
# Onboarding for a device that already reports itself as a TV.
|
# Onboard a device that reports itself as a TV; unlike the phone flow, it starts
|
||||||
#
|
# in TV layout and needs no Force TV mode walk.
|
||||||
# `onboard_jellyfin_tv.yaml` exists for the phone emulator CI runs on: it
|
|
||||||
# onboards through the handheld layout and then walks Settings to flip
|
|
||||||
# "Force TV mode". A real Android TV box takes the TV layout from the first
|
|
||||||
# frame, so that walk has no handheld chrome to tap and the toggle would only
|
|
||||||
# re-assert what PlatformDetector already reports.
|
|
||||||
- retry:
|
- retry:
|
||||||
maxRetries: 1
|
maxRetries: 1
|
||||||
commands:
|
commands:
|
||||||
@@ -32,9 +27,8 @@ appId: com.edde746.plezy
|
|||||||
- tapOn: "(?s)Server URLs.*"
|
- tapOn: "(?s)Server URLs.*"
|
||||||
- inputText: ${JELLYFIN_URL}
|
- inputText: ${JELLYFIN_URL}
|
||||||
- tapOn: "Find server"
|
- tapOn: "Find server"
|
||||||
# A TV-sized layout offers Quick Connect first because typing on a remote is
|
# Quick Connect is awkward on a remote and needs another device, so use the
|
||||||
# slow. The code path needs a second device to approve, so fall back to the
|
# username form as fallback.
|
||||||
# username form the phone flow already uses.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^(?:Username|Cancel)$"
|
visible: "(?s)^(?:Username|Cancel)$"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
|
|||||||
@@ -17,10 +17,8 @@ appId: com.edde746.plezy
|
|||||||
platform: Android
|
platform: Android
|
||||||
commands:
|
commands:
|
||||||
- hideKeyboard
|
- hideKeyboard
|
||||||
# A card announces its watch state three ways, and the codec samples keep a
|
# Codec cards retain watch state and resume position between flows, so accept
|
||||||
# resume position on the server once any earlier flow has played them, so the
|
# any valid watch-state label to keep this subflow rerunnable.
|
||||||
# partial form has to be accepted for this subflow to be re-runnable against a
|
|
||||||
# fixture container that is not thrown away between suites.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
visible: "(?s)^${SAMPLE_TITLE}, movie, (?:watched|unwatched|[0-9]+ percent watched)$"
|
||||||
timeout: 15000
|
timeout: 15000
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
appId: com.edde746.plezy
|
appId: com.edde746.plezy
|
||||||
---
|
---
|
||||||
# Reach Settings from a signed-in Home on a phone layout.
|
# Reach phone Settings from signed-in Home. The profile menu is an overlay
|
||||||
#
|
# sheet; retry while the previous route settles.
|
||||||
# The profile menu is itself an overlay sheet on the MainScreen host, so a tap
|
|
||||||
# issued while the previous route is still settling opens nothing. The guarded
|
|
||||||
# repeat re-opens the menu instead of failing the flow, matching the pattern
|
|
||||||
# already used by flows/07_profiles_settings.yaml.
|
|
||||||
- extendedWaitUntil:
|
- extendedWaitUntil:
|
||||||
visible: "Discover"
|
visible: "Discover"
|
||||||
timeout: 30000
|
timeout: 30000
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ linter:
|
|||||||
prefer_final_in_for_each: true
|
prefer_final_in_for_each: true
|
||||||
avoid_print: true
|
avoid_print: true
|
||||||
|
|
||||||
# Do not register DCL as an analyzer plugin: its analyzer 10 integration crashes
|
# DCL's analyzer 10 plugin crashes on Linux; CI runs check-unused commands directly.
|
||||||
# on Linux. CI invokes the supported check-unused commands explicitly.
|
|
||||||
dart_code_linter:
|
dart_code_linter:
|
||||||
rules-exclude:
|
rules-exclude:
|
||||||
- "test/**"
|
- "test/**"
|
||||||
@@ -27,7 +26,7 @@ dart_code_linter:
|
|||||||
- package:dart_code_linter/presets/recommended.yaml
|
- package:dart_code_linter/presets/recommended.yaml
|
||||||
|
|
||||||
rules:
|
rules:
|
||||||
# --- Flutter rules (on top of recommended) ---
|
# Flutter-specific rules.
|
||||||
- avoid-border-all
|
- avoid-border-all
|
||||||
- avoid-shrink-wrap-in-lists
|
- avoid-shrink-wrap-in-lists
|
||||||
- avoid-expanded-as-spacer
|
- avoid-expanded-as-spacer
|
||||||
@@ -37,7 +36,7 @@ dart_code_linter:
|
|||||||
- prefer-define-hero-tag
|
- prefer-define-hero-tag
|
||||||
- use-setstate-synchronously
|
- use-setstate-synchronously
|
||||||
|
|
||||||
# --- Additional useful Dart rules ---
|
# Additional Dart rules.
|
||||||
- avoid-cascade-after-if-null
|
- avoid-cascade-after-if-null
|
||||||
- avoid-collection-methods-with-unrelated-types
|
- avoid-collection-methods-with-unrelated-types
|
||||||
- avoid-unnecessary-type-assertions
|
- avoid-unnecessary-type-assertions
|
||||||
@@ -48,7 +47,7 @@ dart_code_linter:
|
|||||||
- prefer-enums-by-name
|
- prefer-enums-by-name
|
||||||
- prefer-commenting-analyzer-ignores
|
- prefer-commenting-analyzer-ignores
|
||||||
|
|
||||||
# --- Disable noisy rules from recommended preset ---
|
# Disabled noisy rules.
|
||||||
- no-magic-number: false
|
- no-magic-number: false
|
||||||
- avoid-dynamic: false
|
- avoid-dynamic: false
|
||||||
- format-comment: false
|
- format-comment: false
|
||||||
|
|||||||
@@ -336,8 +336,6 @@ android {
|
|||||||
|
|
||||||
defaultConfig {
|
defaultConfig {
|
||||||
applicationId = "com.edde746.plezy"
|
applicationId = "com.edde746.plezy"
|
||||||
// You can update the following values to match your application needs.
|
|
||||||
// For more information, see: https://flutter.dev/to/review-gradle-config.
|
|
||||||
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
minSdk = 25 // Fire OS 6.x (API 25); overrides libmpv-android's minSdk=26
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
@@ -518,8 +516,10 @@ dependencies {
|
|||||||
// Android TV Watch Next integration
|
// Android TV Watch Next integration
|
||||||
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
implementation("androidx.tvprovider:tvprovider:1.1.0")
|
||||||
|
|
||||||
// Periodic Watch Next background refresh (ShelfRefreshWorker). Same version
|
// Only used to cancel the legacy periodic shelf refresh job (2.13.0's
|
||||||
// background_downloader pins, so the merged classpath stays coherent.
|
// 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")
|
implementation("androidx.work:work-runtime-ktx:2.11.0")
|
||||||
|
|
||||||
// Media3 ExoPlayer for Android
|
// Media3 ExoPlayer for Android
|
||||||
|
|||||||
+1
-1
@@ -203,7 +203,7 @@ class TrueHdSpeedTransitionTest {
|
|||||||
@Test
|
@Test
|
||||||
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
|
fun aRateFamilyMismatchFallsBackToTheDecoderInsteadOfGoingSilent() {
|
||||||
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
if (!supportsTrueHdMatCarrier(context)) {
|
if (!supportsTrueHdMatCarrier()) {
|
||||||
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
|
Log.i(TAG, "==== MISMATCH SKIPPED: device has no carrier route ====")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,17 +9,14 @@
|
|||||||
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
<!-- Allow minSdk=25 despite libmpv-android declaring minSdk=26 -->
|
||||||
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
<uses-sdk tools:overrideLibrary="dev.jdtech.mpv" />
|
||||||
|
|
||||||
<!-- Internet access permissions -->
|
|
||||||
<uses-permission android:name="android.permission.INTERNET"/>
|
<uses-permission android:name="android.permission.INTERNET"/>
|
||||||
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS"/>
|
||||||
|
|
||||||
|
|
||||||
<!-- PIP and media session foreground service permissions -->
|
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK"/>
|
||||||
<uses-permission android:name="android.permission.REORDER_TASKS"/>
|
<uses-permission android:name="android.permission.REORDER_TASKS"/>
|
||||||
|
|
||||||
<!-- Background download notifications and foreground service -->
|
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC"/>
|
||||||
|
|
||||||
@@ -30,15 +27,11 @@
|
|||||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" tools:node="remove" />
|
||||||
|
|
||||||
|
|
||||||
<!-- Android TV support (not required, but allow detection) -->
|
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
<!-- Android TV Watch Next integration -->
|
|
||||||
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
<uses-permission android:name="com.android.providers.tv.permission.WRITE_EPG_DATA"/>
|
||||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||||
<!-- Touchscreen not required for TV -->
|
|
||||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||||
|
|
||||||
<!-- Android Automotive OS support -->
|
|
||||||
<uses-feature android:name="android.hardware.type.automotive" android:required="false" />
|
<uses-feature android:name="android.hardware.type.automotive" android:required="false" />
|
||||||
|
|
||||||
<application
|
<application
|
||||||
@@ -63,10 +56,6 @@
|
|||||||
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
android:configChanges="orientation|keyboardHidden|keyboard|navigation|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
|
||||||
android:hardwareAccelerated="true"
|
android:hardwareAccelerated="true"
|
||||||
android:windowSoftInputMode="adjustResize">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<!-- Specifies an Android theme to apply to this Activity as soon as
|
|
||||||
the Android process has started. This theme is visible to the user
|
|
||||||
while the Flutter UI initializes. After that, this theme continues
|
|
||||||
to determine the Window background behind the Flutter UI. -->
|
|
||||||
<meta-data
|
<meta-data
|
||||||
android:name="io.flutter.embedding.android.NormalTheme"
|
android:name="io.flutter.embedding.android.NormalTheme"
|
||||||
android:resource="@style/NormalTheme"
|
android:resource="@style/NormalTheme"
|
||||||
@@ -77,7 +66,6 @@
|
|||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN"/>
|
<action android:name="android.intent.action.MAIN"/>
|
||||||
<category android:name="android.intent.category.LAUNCHER"/>
|
<category android:name="android.intent.category.LAUNCHER"/>
|
||||||
<!-- Allow app to appear in Android TV launcher -->
|
|
||||||
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
<category android:name="android.intent.category.LEANBACK_LAUNCHER"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
<!-- Deep link handler for Watch Next items -->
|
<!-- Deep link handler for Watch Next items -->
|
||||||
@@ -88,7 +76,6 @@
|
|||||||
<data android:scheme="plezy" android:host="play"/>
|
<data android:scheme="plezy" android:host="play"/>
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<!-- FileProvider for sharing downloaded files with external players -->
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="com.edde746.plezy.fileprovider"
|
android:authorities="com.edde746.plezy.fileprovider"
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ class MainActivity : FlutterActivity() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Auto PiP state
|
|
||||||
private var autoPipReady = false
|
private var autoPipReady = false
|
||||||
private var autoPipWidth: Int = 16
|
private var autoPipWidth: Int = 16
|
||||||
private var autoPipHeight: Int = 9
|
private var autoPipHeight: Int = 9
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import android.media.AudioTrack
|
|||||||
import android.os.Build
|
import android.os.Build
|
||||||
import android.util.Log
|
import android.util.Log
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
|
import androidx.annotation.RequiresApi
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.MimeTypes
|
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
|
* 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.
|
* 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,
|
* Both are consulted: `getMinBufferSize` proves a track can be built, and a direct-playback oracle
|
||||||
* `getDirectPlaybackSupport` proves the route will actually bitstream it rather than silently
|
* proves the route will actually bitstream it rather than silently decode or wedge. Sizing alone is
|
||||||
* decode or wedge.
|
* 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 {
|
internal fun supportsTrueHdMatCarrier(): Boolean = trueHdMatCarrierSupported(
|
||||||
// getMinBufferSize alone is not sufficient. On a Shield it answers yes for the 192kHz/7.1 IEC
|
sdkInt = Build.VERSION.SDK_INT,
|
||||||
// tuple and the AudioTrack then fails to initialise; it reports that a buffer can be sized, not
|
canSizeCarrierBuffer = {
|
||||||
// that the route will carry the format. Without getDirectPlaybackSupport there is no way to tell
|
try {
|
||||||
// the two apart, so below API 33 the carrier is not offered and TrueHD decodes as before.
|
AudioTrack.getMinBufferSize(
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) return false
|
TrueHdMatPacker.CARRIER_SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_OUT_7POINT1_SURROUND,
|
||||||
val rate = TrueHdMatPacker.CARRIER_SAMPLE_RATE
|
AudioFormat.ENCODING_IEC61937
|
||||||
val mask = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND
|
) > 0
|
||||||
val sizedOk = try {
|
} catch (error: Exception) {
|
||||||
AudioTrack.getMinBufferSize(rate, mask, AudioFormat.ENCODING_IEC61937) > 0
|
false
|
||||||
} 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()
|
* [supportsTrueHdMatCarrier] with the platform probes injected. Probes are only consulted on the
|
||||||
.setContentType(C.AUDIO_CONTENT_TYPE_MOVIE)
|
* API tiers where they exist: [bitstreamSupported] (`getDirectPlaybackSupport`) on 33+ and
|
||||||
.setUsage(C.USAGE_MEDIA)
|
* [directPlaybackSupported] (`AudioTrack.isDirectPlaybackSupported`) on 29–32.
|
||||||
.build()
|
*/
|
||||||
.getPlatformAudioAttributes()
|
internal fun trueHdMatCarrierSupported(
|
||||||
val probe = AudioFormat.Builder()
|
sdkInt: Int,
|
||||||
.setEncoding(AudioFormat.ENCODING_IEC61937)
|
canSizeCarrierBuffer: () -> Boolean,
|
||||||
.setChannelMask(mask)
|
bitstreamSupported: () -> Boolean,
|
||||||
.setSampleRate(rate)
|
directPlaybackSupported: () -> Boolean
|
||||||
.build()
|
): Boolean = when {
|
||||||
val support = AudioManager.getDirectPlaybackSupport(probe, audioAttributes)
|
sdkInt < Build.VERSION_CODES.Q -> false
|
||||||
(support and AudioManager.DIRECT_PLAYBACK_BITSTREAM_SUPPORTED) != 0
|
!canSizeCarrierBuffer() -> false
|
||||||
} catch (error: Exception) {
|
sdkInt >= Build.VERSION_CODES.TIRAMISU -> bitstreamSupported()
|
||||||
Log.w(TAG, "IEC 61937 carrier probe failed; not offering the TrueHD carrier", error)
|
else -> directPlaybackSupported()
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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(
|
return TrueHdCarrierSink(
|
||||||
defaultSink = processedSink,
|
defaultSink = processedSink,
|
||||||
carrierSink = buildCarrierSink(context, bufferSizeProvider),
|
carrierSink = buildCarrierSink(context, bufferSizeProvider),
|
||||||
carrierRouteAvailable = { supportsTrueHdMatCarrier(context) },
|
carrierRouteAvailable = { supportsTrueHdMatCarrier() },
|
||||||
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
|
directOutputBlocked = { format -> shouldBlockDirectAudioOutput?.invoke(format) == true },
|
||||||
log = audioDiagnosticsLogger
|
log = audioDiagnosticsLogger
|
||||||
).also { trueHdCarrierSink = it }
|
).also { trueHdCarrierSink = it }
|
||||||
|
|||||||
@@ -106,7 +106,6 @@ internal class TrueHdCarrierSink(
|
|||||||
@Volatile
|
@Volatile
|
||||||
private var mismatchGeneration = -1
|
private var mismatchGeneration = -1
|
||||||
|
|
||||||
// --- Selection ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when this format should ride the carrier.
|
* True when this format should ride the carrier.
|
||||||
@@ -193,7 +192,6 @@ internal class TrueHdCarrierSink(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Stream path: active delegate only ---
|
|
||||||
|
|
||||||
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
|
override fun handleBuffer(buffer: ByteBuffer, presentationTimeUs: Long, encodedAccessUnitCount: Int): Boolean {
|
||||||
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
|
if (!carrierActive) return defaultSink.handleBuffer(buffer, presentationTimeUs, encodedAccessUnitCount)
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ class MpvPlayerCore private constructor(
|
|||||||
// output (#1482).
|
// output (#1482).
|
||||||
private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1)
|
private val mpvWriteDispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||||
|
|
||||||
// Frame rate matching
|
|
||||||
private var frameRateManager: FrameRateManager? = null
|
private var frameRateManager: FrameRateManager? = null
|
||||||
private val handler = Handler(Looper.getMainLooper())
|
private val handler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
@@ -110,7 +109,6 @@ class MpvPlayerCore private constructor(
|
|||||||
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
if (Looper.myLooper() == Looper.getMainLooper()) block() else mainHandler.post(block)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Audio focus
|
|
||||||
private var audioFocusManager: AudioFocusManager? = null
|
private var audioFocusManager: AudioFocusManager? = null
|
||||||
|
|
||||||
@Volatile private var cachedPaused: Boolean = true
|
@Volatile private var cachedPaused: Boolean = true
|
||||||
@@ -275,7 +273,6 @@ class MpvPlayerCore private constructor(
|
|||||||
Log.d(TAG, "SurfaceView added to content view")
|
Log.d(TAG, "SurfaceView added to content view")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create MpvPlayer on background thread via coroutine
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
try {
|
try {
|
||||||
if (disposing) {
|
if (disposing) {
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ open class MpvPlayerPlugin(
|
|||||||
private var initAttemptCounter = 0
|
private var initAttemptCounter = 0
|
||||||
private var activeInitAttempt: Int? = null
|
private var activeInitAttempt: Int? = null
|
||||||
|
|
||||||
// FlutterPlugin
|
|
||||||
|
|
||||||
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
|
||||||
applicationContext = binding.applicationContext
|
applicationContext = binding.applicationContext
|
||||||
@@ -104,7 +103,6 @@ open class MpvPlayerPlugin(
|
|||||||
takeCoreForTeardown()?.dispose()
|
takeCoreForTeardown()?.dispose()
|
||||||
}
|
}
|
||||||
|
|
||||||
// ActivityAware
|
|
||||||
|
|
||||||
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
override fun onAttachedToActivity(binding: ActivityPluginBinding) {
|
||||||
activity = binding.activity
|
activity = binding.activity
|
||||||
@@ -141,7 +139,6 @@ open class MpvPlayerPlugin(
|
|||||||
Log.d(tag, "Detached from activity for config changes")
|
Log.d(tag, "Detached from activity for config changes")
|
||||||
}
|
}
|
||||||
|
|
||||||
// EventChannel.StreamHandler
|
|
||||||
|
|
||||||
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
|
||||||
channels.listen(events)
|
channels.listen(events)
|
||||||
@@ -151,7 +148,6 @@ open class MpvPlayerPlugin(
|
|||||||
channels.cancel()
|
channels.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MethodChannel.MethodCallHandler
|
|
||||||
|
|
||||||
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
|
||||||
when (call.method) {
|
when (call.method) {
|
||||||
|
|||||||
@@ -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.BroadcastReceiver
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
|
import android.util.Log
|
||||||
|
import androidx.work.WorkManager
|
||||||
import java.util.concurrent.Executor
|
import java.util.concurrent.Executor
|
||||||
import java.util.concurrent.ExecutorService
|
import java.util.concurrent.ExecutorService
|
||||||
import java.util.concurrent.Executors
|
import java.util.concurrent.Executors
|
||||||
@@ -15,6 +17,19 @@ class SystemShelfUpdateReceiver private constructor(
|
|||||||
constructor() : this(Executors.newSingleThreadExecutor(), true)
|
constructor() : this(Executors.newSingleThreadExecutor(), true)
|
||||||
internal constructor(executor: Executor) : this(executor, false)
|
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) {
|
override fun onReceive(context: Context, intent: Intent) {
|
||||||
val action = intent.action
|
val action = intent.action
|
||||||
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
|
if (action != Intent.ACTION_MY_PACKAGE_REPLACED && action != Intent.ACTION_BOOT_COMPLETED) return
|
||||||
@@ -22,22 +37,25 @@ class SystemShelfUpdateReceiver private constructor(
|
|||||||
executor.execute {
|
executor.execute {
|
||||||
try {
|
try {
|
||||||
val provider = WatchNextProvider.forMaintenance(context.applicationContext)
|
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) {
|
if (action == Intent.ACTION_MY_PACKAGE_REPLACED) {
|
||||||
|
cancelLegacyShelfRefreshWork(context.applicationContext)
|
||||||
provider.migrateShelfSchema()
|
provider.migrateShelfSchema()
|
||||||
} else {
|
} else {
|
||||||
provider.restoreReadGrants()
|
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 {
|
} finally {
|
||||||
pending?.finish()
|
pending?.finish()
|
||||||
if (ownsExecutor) (executor as ExecutorService).shutdown()
|
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
|
internal const val SCHEMA_VERSION = 3
|
||||||
private var pendingDeepLink: String? = null
|
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? {
|
fun handleIntent(intent: Intent?): String? {
|
||||||
val data = intent?.data ?: return null
|
val data = intent?.data ?: return null
|
||||||
return if (data.scheme == "plezy" && data.authority == "play") {
|
return if (data.scheme == "plezy" && data.authority == "play") {
|
||||||
@@ -111,7 +104,6 @@ class WatchNextPlugin() :
|
|||||||
"clear" -> handleClear(call, result)
|
"clear" -> handleClear(call, result)
|
||||||
"remove" -> handleRemove(call, result)
|
"remove" -> handleRemove(call, result)
|
||||||
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
"getInitialDeepLink" -> handleGetInitialDeepLink(result)
|
||||||
"backgroundSyncComplete" -> handleBackgroundSyncComplete(call, result)
|
|
||||||
else -> result.notImplemented()
|
else -> result.notImplemented()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -144,12 +136,7 @@ class WatchNextPlugin() :
|
|||||||
val provider = session.provider ?: return@executeOnIo false
|
val provider = session.provider ?: return@executeOnIo false
|
||||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||||
if (!session.isOpen()) return@executeOnIo false
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
val synced = provider.syncWatchNextPrograms(owner, generation, items, ownership, session::isOpen)
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,10 +150,7 @@ class WatchNextPlugin() :
|
|||||||
val provider = session.provider ?: return@executeOnIo false
|
val provider = session.provider ?: return@executeOnIo false
|
||||||
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
val ownership = provider.claimOwnership(owner, generation) ?: return@executeOnIo false
|
||||||
if (!session.isOpen()) return@executeOnIo false
|
if (!session.isOpen()) return@executeOnIo false
|
||||||
val cleared = provider.clearAll(owner, generation, ownership, session::isOpen)
|
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
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,12 +192,6 @@ class WatchNextPlugin() :
|
|||||||
result.success(contentId)
|
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? {
|
private fun parseWatchNextItem(data: Map<String, Any?>): WatchNextProvider.WatchNextItem? {
|
||||||
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
|
val contentId = (data["contentId"] as? String)?.takeIf(String::isNotBlank) ?: return null
|
||||||
val title = data["title"] as? String ?: return null
|
val title = data["title"] as? String ?: return null
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ internal object SystemShelfLifecycle {
|
|||||||
private var claimToken = 0L
|
private var claimToken = 0L
|
||||||
private var currentOwner = ""
|
private var currentOwner = ""
|
||||||
private var currentGeneration = 0L
|
private var currentGeneration = 0L
|
||||||
private var leaseHeld = false
|
|
||||||
|
|
||||||
fun acquire(): Lease = acquireIf { true }!!
|
fun acquire(): Lease = acquireIf { true }!!
|
||||||
|
|
||||||
@@ -36,7 +35,6 @@ internal object SystemShelfLifecycle {
|
|||||||
claimToken += 1
|
claimToken += 1
|
||||||
currentOwner = ""
|
currentOwner = ""
|
||||||
currentGeneration = 0
|
currentGeneration = 0
|
||||||
leaseHeld = true
|
|
||||||
Lease(token)
|
Lease(token)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -47,19 +45,11 @@ internal object SystemShelfLifecycle {
|
|||||||
if (token == lease.token) {
|
if (token == lease.token) {
|
||||||
token += 1
|
token += 1
|
||||||
claimToken += 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) {
|
fun claim(lease: Lease, ownerId: String, generation: Long): Ownership? = synchronized(operationLock) {
|
||||||
synchronized(lock) {
|
synchronized(lock) {
|
||||||
if (
|
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) }
|
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(
|
internal fun syncWatchNextPrograms(
|
||||||
ownerId: String,
|
ownerId: String,
|
||||||
generation: Long,
|
generation: Long,
|
||||||
|
|||||||
@@ -82,4 +82,70 @@ class AudioOutputPolicyTest {
|
|||||||
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
fun spdifListIsEmptyForPcmOnlyRoutes() {
|
||||||
assertEquals("", mpvSpdifCodecs { false })
|
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
|
loading = loading
|
||||||
)
|
)
|
||||||
|
|
||||||
// The loader's own state, for the case media3 never answers
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
|
fun aLoaderThatStoppedAskingForDataCountsAsEnoughBuffer() {
|
||||||
@@ -51,7 +50,6 @@ class BufferingStallPolicyTest {
|
|||||||
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
|
assertEquals(Verdict.STALLED, evaluate(bufferedAheadMs = 2_000L, loading = false, loadControlReady = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// The load control's own verdict
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
|
fun aByteCappedBufferBelowTheDurationBarIsStillIndicted() {
|
||||||
@@ -83,7 +81,6 @@ class BufferingStallPolicyTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Playback speed
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
|
fun aFastForwardNeedsProportionallyMoreMediaBeforeItIsIndicted() {
|
||||||
@@ -107,7 +104,6 @@ class BufferingStallPolicyTest {
|
|||||||
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
|
assertEquals(Verdict.STALLED, evaluate(playbackSpeed = -1f))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Progress
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun advancingPositionIsHealthy() {
|
fun advancingPositionIsHealthy() {
|
||||||
@@ -138,7 +134,6 @@ class BufferingStallPolicyTest {
|
|||||||
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
|
assertEquals(Verdict.STALLED, evaluate(currentPositionMs = position - 5_000))
|
||||||
}
|
}
|
||||||
|
|
||||||
// Timeout
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun frozenPositionWaitsOutTheTimeout() {
|
fun frozenPositionWaitsOutTheTimeout() {
|
||||||
@@ -151,7 +146,6 @@ class BufferingStallPolicyTest {
|
|||||||
assertEquals(Verdict.STALLED, evaluate())
|
assertEquals(Verdict.STALLED, evaluate())
|
||||||
}
|
}
|
||||||
|
|
||||||
// Starvation — the loader's problem, not the renderer's
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun emptyBufferIsStarved() {
|
fun emptyBufferIsStarved() {
|
||||||
@@ -203,7 +197,6 @@ class BufferingStallPolicyTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stall clock ownership
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun starvationAndProgressBothRestartTheStallClock() {
|
fun starvationAndProgressBothRestartTheStallClock() {
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ class DvBitstreamSanitizerTest {
|
|||||||
|
|
||||||
private val sanitizer = DvBitstreamSanitizer()
|
private val sanitizer = DvBitstreamSanitizer()
|
||||||
|
|
||||||
// --- HDR10+ SEI stripping (native DV codec path) ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun stripsHdr10PlusPrefixSeiBetweenVclNals() {
|
fun stripsHdr10PlusPrefixSeiBetweenVclNals() {
|
||||||
@@ -85,7 +84,6 @@ class DvBitstreamSanitizerTest {
|
|||||||
assertArrayEquals(original, remainingBytes(buffer))
|
assertArrayEquals(original, remainingBytes(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- DV RPU/EL stripping (HEVC fallback path) ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() {
|
fun rpuModeStripsRpuAndElButKeepsHdr10PlusSei() {
|
||||||
@@ -111,7 +109,6 @@ class DvBitstreamSanitizerTest {
|
|||||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Buffer handling ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun respectsPositionAndRestoresIt() {
|
fun respectsPositionAndRestoresIt() {
|
||||||
@@ -245,7 +242,6 @@ class DvBitstreamSanitizerTest {
|
|||||||
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
assertArrayEquals(concat(vcl1, vcl2), remainingBytes(buffer))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
|
||||||
|
|
||||||
/** Builds an HEVC NAL unit: start code + 2-byte NAL header encoding [nalUnitType] + payload. */
|
/** Builds an HEVC NAL unit: start code + 2-byte NAL header encoding [nalUnitType] + payload. */
|
||||||
private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray {
|
private fun annexBNal(nalUnitType: Int, payload: ByteArray, startCodeLen: Int = 4): ByteArray {
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ class EndOfStreamPolicyTest {
|
|||||||
frameStallMs = frameStallMs
|
frameStallMs = frameStallMs
|
||||||
)
|
)
|
||||||
|
|
||||||
// isFinishedFile
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
|
fun stuckPastDurationWithNoPictureIsTheEndOfTheFile() {
|
||||||
@@ -65,7 +64,6 @@ class EndOfStreamPolicyTest {
|
|||||||
assertFalse(isFinishedFile(hasPlaybackOutput = false))
|
assertFalse(isFinishedFile(hasPlaybackOutput = false))
|
||||||
}
|
}
|
||||||
|
|
||||||
// fallbackStartPositionMs
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun fallbackResumesStrictlyInsideTheMedia() {
|
fun fallbackResumesStrictlyInsideTheMedia() {
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ private const val MIB = 1024 * 1024
|
|||||||
|
|
||||||
class LoadControlPolicyTest {
|
class LoadControlPolicyTest {
|
||||||
|
|
||||||
// autoTargetBufferBytes
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() {
|
fun neverExceedsMedia3sOwnTargetEvenWithHugeMemory() {
|
||||||
@@ -64,7 +63,6 @@ class LoadControlPolicyTest {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// readAheadSeconds
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun readAheadReportsSecondsAtAKnownBitrate() {
|
fun readAheadReportsSecondsAtAKnownBitrate() {
|
||||||
@@ -86,7 +84,6 @@ class LoadControlPolicyTest {
|
|||||||
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
|
assertNull(LoadControlPolicy.readAheadSeconds(64 * MIB, -1L))
|
||||||
}
|
}
|
||||||
|
|
||||||
// bufferDurations
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
|
* media3 validates the ordering with Guava `Preconditions`, which is a plain throw rather than
|
||||||
@@ -172,7 +169,6 @@ class LoadControlPolicyTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// BufferTier.fromWire
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun theWireNamesMatchTheDartNativeValues() {
|
fun theWireNamesMatchTheDartNativeValues() {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ class RawPositionAudioOutputTest {
|
|||||||
return output to listener
|
return output to listener
|
||||||
}
|
}
|
||||||
|
|
||||||
// Release reporting
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A parked track is never going to release, so its flush has to be answered at once. Deferring
|
* A parked track is never going to release, so its flush has to be answered at once. Deferring
|
||||||
@@ -116,7 +115,6 @@ class RawPositionAudioOutputTest {
|
|||||||
assertEquals(1, listener.releasedCount)
|
assertEquals(1, listener.releasedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reuse
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
|
fun aParkedOutputIsHandedBackForAnIdenticalConfig() {
|
||||||
@@ -178,7 +176,6 @@ class RawPositionAudioOutputTest {
|
|||||||
assertEquals(1, secondListener.underrunCount)
|
assertEquals(1, secondListener.underrunCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Eviction — the overlap media3 itself tolerates, kept tolerable
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The replacement is deliberately built while the evicted track is still going away. Refusing
|
* The replacement is deliberately built while the evicted track is still going away. Refusing
|
||||||
@@ -221,7 +218,6 @@ class RawPositionAudioOutputTest {
|
|||||||
assertEquals(1, listener.releasedCount)
|
assertEquals(1, listener.releasedCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fakes
|
|
||||||
|
|
||||||
private class RecordingListener : AudioOutput.Listener {
|
private class RecordingListener : AudioOutput.Listener {
|
||||||
var releasedCount = 0
|
var releasedCount = 0
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import org.junit.Test
|
|||||||
|
|
||||||
class ResumeStallPolicyTest {
|
class ResumeStallPolicyTest {
|
||||||
|
|
||||||
// checkWindowMs
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun windowFloorsAtDefaultForNormalFrameRates() {
|
fun windowFloorsAtDefaultForNormalFrameRates() {
|
||||||
@@ -39,7 +38,6 @@ class ResumeStallPolicyTest {
|
|||||||
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
|
assertEquals(1000L, ResumeStallPolicy.checkWindowMs(formatFps = null, detectedFps = null, speed = 1f))
|
||||||
}
|
}
|
||||||
|
|
||||||
// evaluate
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun advancingFramesAreHealthy() {
|
fun advancingFramesAreHealthy() {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class TrueHdCarrierSinkTest {
|
|||||||
blocked: Boolean = false
|
blocked: Boolean = false
|
||||||
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
|
) = TrueHdCarrierSink(normal, carrier, { routeAvailable }, { blocked })
|
||||||
|
|
||||||
// --- Selection ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
|
* TrueHD is the carrier or it is decoded. Falling through to the normal sink would hand media3
|
||||||
@@ -154,7 +153,6 @@ class TrueHdCarrierSinkTest {
|
|||||||
assertEquals(0, listener.capabilityInvalidations)
|
assertEquals(0, listener.capabilityInvalidations)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Rate-family mismatch discovered mid-stream ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Selection reads Format.sampleRate; the rate family is only certain once a major sync is parsed.
|
* Selection reads Format.sampleRate; the rate family is only certain once a major sync is parsed.
|
||||||
@@ -289,7 +287,6 @@ class TrueHdCarrierSinkTest {
|
|||||||
assertTrue(carrierSink.supportsFormat(ac3))
|
assertTrue(carrierSink.supportsFormat(ac3))
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Back pressure ---
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The regression this exists for: a delegate that refuses a burst part-way through a sample must
|
* The regression this exists for: a delegate that refuses a burst part-way through a sample must
|
||||||
@@ -336,7 +333,6 @@ class TrueHdCarrierSinkTest {
|
|||||||
assertTrue(carrierSink.hasPendingData())
|
assertTrue(carrierSink.hasPendingData())
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Routing of controls ---
|
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun persistentControlsReachBothDelegates() {
|
fun persistentControlsReachBothDelegates() {
|
||||||
|
|||||||
@@ -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.net.Uri
|
||||||
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
import android.os.ParcelFileDescriptor.AutoCloseInputStream
|
||||||
import androidx.tvprovider.media.tv.TvContractCompat
|
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.embedding.engine.plugins.FlutterPlugin
|
||||||
import io.flutter.plugin.common.BinaryMessenger
|
import io.flutter.plugin.common.BinaryMessenger
|
||||||
import io.flutter.plugin.common.MethodCall
|
import io.flutter.plugin.common.MethodCall
|
||||||
@@ -720,6 +727,26 @@ class WatchNextProviderTest {
|
|||||||
assertFalse(context.cacheDir.resolve("system_shelf_artwork").exists())
|
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
|
@Test
|
||||||
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
|
fun providerFailureDeletesNewArtworkAndPreservesCommittedArtwork() {
|
||||||
ScriptedHttpServer(
|
ScriptedHttpServer(
|
||||||
@@ -1605,3 +1632,15 @@ private class ManualExecutorService : AbstractExecutorService() {
|
|||||||
tasks.removeFirst().run()
|
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
|
platform :android do
|
||||||
desc "Build and deploy to Google Play Store"
|
desc "Build and deploy to Google Play Store"
|
||||||
lane :release do
|
lane :release do
|
||||||
# Get version from pubspec.yaml
|
|
||||||
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
|
pubspec_path = File.join(PROJECT_ROOT, "pubspec.yaml")
|
||||||
pubspec_content = File.read(pubspec_path)
|
pubspec_content = File.read(pubspec_path)
|
||||||
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
|
version_match = pubspec_content.match(/version:\s*(.+)\+(\d+)/)
|
||||||
@@ -26,7 +25,6 @@ platform :android do
|
|||||||
|
|
||||||
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
||||||
|
|
||||||
# Build the Flutter app
|
|
||||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json")
|
sh("cd #{PROJECT_ROOT_ARG} && flutter build appbundle --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=play-store --dart-define=SENTRY_DIST=play-store --obfuscate --split-debug-info=debug-info/android-aab --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-aab/obfuscation.map.json")
|
||||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab")
|
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=play-store ./scripts/upload-symbols.sh android-aab")
|
||||||
|
|
||||||
@@ -37,7 +35,6 @@ platform :android do
|
|||||||
aab: "../build/app/outputs/bundle/release/app-release.aab"
|
aab: "../build/app/outputs/bundle/release/app-release.aab"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Build universal APK for Amazon Appstore (doesn't accept AABs, excludes x86 via env var)
|
|
||||||
sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json")
|
sh("cd #{PROJECT_ROOT_ARG} && AMAZON=1 flutter build apk --release --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=amazon --dart-define=SENTRY_DIST=amazon --obfuscate --split-debug-info=debug-info/android-apk --extra-gen-snapshot-options=--save-obfuscation-map=debug-info/android-apk/obfuscation.map.json")
|
||||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
|
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=amazon ./scripts/upload-symbols.sh android-apk")
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ static inline long long nowMs(void) {
|
|||||||
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
return (long long)ts.tv_sec * 1000 + ts.tv_nsec / 1000000;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#include "AssPack.h"
|
||||||
#include "ass/ass.h"
|
#include "ass/ass.h"
|
||||||
|
|
||||||
#define LOG_TAG "SubtitleRenderer"
|
#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
|
// Packing/composite policy lives in AssPack.c (pure C, desktop-testable); this
|
||||||
// texture is far above the worst real frame measured (a 4K-rendered full-screen
|
// file owns the JNI boundary, buffer plumbing and logging.
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
|
|
||||||
static int imageListHasOutput(ASS_Image* image) {
|
static int imageListHasOutput(ASS_Image* image) {
|
||||||
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
for (ASS_Image* img = image; img != NULL; img = img->next) {
|
||||||
@@ -293,11 +267,12 @@ static int truncationLogCounter = 0;
|
|||||||
// [5]=hasOutput [6]=pageCount
|
// [5]=hasOutput [6]=pageCount
|
||||||
// [7 .. 7+MAX-1] = pageHeights[pageCount]
|
// [7 .. 7+MAX-1] = pageHeights[pageCount]
|
||||||
// [7+MAX .. 7+2*MAX-1] = pageQuadCounts[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(
|
static jint writeAtlasHeader(
|
||||||
JNIEnv* env, jintArray headerBuf, int atlasWidth, int quadCount, int changed, int truncated, int requiredPages,
|
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];
|
int hdr[ASS_HEADER_INTS];
|
||||||
memset(hdr, 0, sizeof(hdr));
|
memset(hdr, 0, sizeof(hdr));
|
||||||
hdr[0] = atlasWidth;
|
hdr[0] = atlasWidth;
|
||||||
@@ -307,36 +282,32 @@ static jint writeAtlasHeader(
|
|||||||
hdr[4] = requiredPages;
|
hdr[4] = requiredPages;
|
||||||
hdr[5] = hasOutput;
|
hdr[5] = hasOutput;
|
||||||
hdr[6] = pageCount;
|
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 + 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);
|
(*env)->SetIntArrayRegion(env, headerBuf, 0, ASS_HEADER_INTS, hdr);
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Renders a frame into the provided atlas + vertex direct ByteBuffers.
|
// Renders a frame into the provided atlas + vertex direct ByteBuffers.
|
||||||
//
|
//
|
||||||
// - atlasBuf holds one or more vertically-stacked ALPHA_8 *pages*, each atlasMaxW ×
|
// - In the common ATLAS mode, atlasBuf holds one or more vertically-stacked ALPHA_8
|
||||||
// atlasMaxH (row stride atlasMaxW); page p starts at byte offset p*atlasMaxW*atlasMaxH.
|
// *pages*, each atlasMaxW × atlasMaxH (row stride atlasMaxW); page p starts at byte
|
||||||
// The buffer's capacity bounds how many pages this render may fill; AssAtlasFrame
|
// offset p*atlasMaxW*atlasMaxH. The buffer's capacity bounds how many pages this
|
||||||
// reports pageHeights (rows worth uploading per page) and requiredPages.
|
// 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)
|
// - 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
|
// floats = 48 floats = 192 bytes per quad). Must match BYTES_PER_QUAD/VERTEX in
|
||||||
// AssSubtitleAtlasPipeline.kt. UVs are page-local, normalized against atlasMaxW ×
|
// AssSubtitleAtlasPipeline.kt. Vertices are emitted in libass's painter order.
|
||||||
// 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).
|
|
||||||
//
|
//
|
||||||
// Never fails on content size: when the frame needs more pages than the buffer holds
|
// Never drops content for size: when the frame needs more capacity than the buffer
|
||||||
// (requiredPages > pageHeights.size) the caller grows the buffer and re-renders; any
|
// holds (requiredPages > pageHeights.size) the caller grows the buffer and re-renders;
|
||||||
// genuinely undrawable tiles (past MAX_ATLAS_PAGES / the vertex budget) are dropped
|
// frames too dense for the paged atlas flatten into the RGBA composite (see AssPack.c).
|
||||||
// and counted in truncated.
|
|
||||||
//
|
//
|
||||||
// Returns 0 for missing buffers/handles (the caller maps that to a null frame); 1 when
|
// 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,
|
// 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,
|
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, hasOutput=%d)", (long long)time,
|
||||||
tAss - t0, changed, hasOutput);
|
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) {
|
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,
|
ANDROID_LOG_WARN, LOG_TAG, "slow render t=%lldms: ass=%lldms (changed=%d, no output)", (long long)time,
|
||||||
tAss - t0, changed);
|
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);
|
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);
|
(long long)atlasCap);
|
||||||
return 0;
|
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.
|
AssPackResult pack;
|
||||||
// tiles[] stays in list order (= blend/painter order for emission); keys[] is
|
if (!ass_pack_frame(image, atlasPixels, (size_t)atlasCap, atlasMaxW, atlasMaxH, vertices, (size_t)vertexCap, &pack)) {
|
||||||
// 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);
|
|
||||||
return 0;
|
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
|
// Warn only for genuinely-unrecoverable loss. A frame that needs more capacity than
|
||||||
// (byte-identical to the prior single-page packer when the frame fits one page).
|
// the buffer currently holds is recoverable: the caller grows the buffer and
|
||||||
qsort(keys, (size_t)n, sizeof(TileSortKey), compareTileKeysByHeightDesc);
|
// re-renders, so the first (discarded) render's truncated > 0 is a false alarm, not
|
||||||
int cursorX = 0, cursorY = 0, rowH = 0, packedH = 0, accepted = 0;
|
// data loss. With the composite fallback, unrecoverable truncation should be
|
||||||
for (int i = 0; i < n; i++) {
|
// unreachable for real content.
|
||||||
PackTile* t = &tiles[keys[i].idx];
|
const int maxQuads = (int)(vertexCap / 192);
|
||||||
if (accepted >= maxQuads) break;
|
const int recoverableGrow =
|
||||||
int cx = cursorX, cy = cursorY, rh = rowH;
|
pack.requiredPages <= ASS_PACK_MAX_PAGES && (pack.mode == ASS_PACK_MODE_COMPOSITE || pack.totalTiles <= maxQuads);
|
||||||
if (cx + t->tw > atlasMaxW) {
|
if (pack.truncated > 0 && !recoverableGrow && (truncationLogCounter++ & 63) == 0) {
|
||||||
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) {
|
|
||||||
__android_log_print(
|
__android_log_print(
|
||||||
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %d)",
|
ANDROID_LOG_WARN, LOG_TAG, "atlas truncation: %d of %d tiles dropped (atlas %dx%d, need %d pages have %lld)",
|
||||||
truncated, n, atlasMaxW, atlasMaxH, requiredPages, providedPages);
|
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)
|
// 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();
|
const long long tEnd = nowMs();
|
||||||
if (tEnd - t0 > 40) {
|
if (tEnd - t0 > 40) {
|
||||||
__android_log_print(
|
__android_log_print(
|
||||||
ANDROID_LOG_WARN, LOG_TAG,
|
ANDROID_LOG_WARN, LOG_TAG,
|
||||||
"slow render t=%lldms: total=%lldms ass=%lldms pack+copy=%lldms images=%d srcPx=%lldk "
|
"slow render t=%lldms: total=%lldms ass=%lldms pack+copy=%lldms images=%d srcPx=%lldk "
|
||||||
"atlas=%dx%d pages=%d quads=%d",
|
"atlas=%dx%d pages=%d quads=%d mode=%d",
|
||||||
(long long)time, tEnd - t0, tAss - t0, tEnd - tAss, n, srcPixels / 1000, atlasMaxW, atlasMaxH, pageCount, qi);
|
(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);
|
// ATLAS: atlasWidth is the full row stride (GLES2 can't upload with stride ≠ width)
|
||||||
// pageHeights/pageQuadCounts describe the per-page upload + draw ranges.
|
// 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(
|
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) ---
|
// --- 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}")
|
IMPORTED_LOCATION "${LIBASS_ARCHIVE}")
|
||||||
target_include_directories(ass INTERFACE "${LIBASS_ROOT}/include")
|
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})
|
add_dependencies(${CMAKE_PROJECT_NAME} libass_prebuilt_${LIBASS_ABI_TARGET})
|
||||||
# HarfBuzz brings C++; link the shared STL that the app already packages.
|
# 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.)
|
# (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
|
* 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.
|
* 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
|
* In the common [MODE_ATLAS] the atlas may span more than one ALPHA_8 *page* — a heavy
|
||||||
* sub-pixels than a single GL-max texture holds. Pages are vertically stacked in the
|
* full-screen sign can produce more sub-pixels than a single GL-max texture holds. Pages
|
||||||
* atlas ByteBuffer (page `p` at byte offset `p * atlasWidth * atlasMaxHeight`), each its
|
* are vertically stacked in the atlas ByteBuffer (page `p` at byte offset
|
||||||
* own texture, and are drawn in turn. Quads are emitted in libass painter order and page
|
* `p * atlasWidth * atlasMaxHeight`), each its own texture, and are drawn in turn. Quads
|
||||||
* assignment is monotonic in that order, so each page's quads form one contiguous run in
|
* are emitted in libass painter order and page assignment is monotonic in that order, so
|
||||||
* the vertex stream ([pageQuadCounts]); the runner uploads page `p`, then draws its run.
|
* 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
|
* Built in Kotlin by [AssRender.renderFrameAtlas] from the int[] header the native
|
||||||
* renderer fills (see `writeAtlasHeader` in AssKt.c) — never constructed from JNI, so
|
* renderer fills (see `writeAtlasHeader` in AssKt.c) — never constructed from JNI, so
|
||||||
* the minifier may obfuscate it freely without breaking the native boundary.
|
* the minifier may obfuscate it freely without breaking the native boundary.
|
||||||
*
|
*
|
||||||
* @param atlasWidth atlas row stride in pixels (= the allocated width; same for every
|
* @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 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;
|
* @param pageQuadCounts quads on each page, contiguous in the vertex stream in this order;
|
||||||
* `size` = page count, `sum` = [quadCount]
|
* `size` = page count, `sum` = [quadCount]
|
||||||
* @param quadCount total quads; the vertex buffer holds [quadCount] * 6 vertices
|
* @param quadCount total quads; the vertex buffer holds [quadCount] * 6 vertices
|
||||||
* @param changed libass change flag (0 = no change, 1 = positions, 2 = content)
|
* @param changed libass change flag (0 = no change, 1 = positions, 2 = content)
|
||||||
* @param truncated images dropped because the frame needed more than [requiredPages]
|
* @param truncated images dropped because the frame needed more capacity than the
|
||||||
* pages of capacity or exceeded the vertex budget; the frame is
|
* buffer holds; the frame is incomplete but never stale. Recoverable
|
||||||
* incomplete but never stale (should be unreachable for real content)
|
* by growing to [requiredPages]; with the composite fallback,
|
||||||
* @param requiredPages pages this frame needs to render completely. When it exceeds
|
* unrecoverable truncation should be unreachable for real content
|
||||||
* [pageHeights].size the caller must grow the atlas buffer and
|
* @param requiredPages pages of buffer capacity this frame needs to render completely.
|
||||||
* re-render; the rendered pages are still valid in the meantime.
|
* 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
|
* @param hasOutput true when libass reported at least one visible image for this
|
||||||
* timestamp, even when [changed] is 0 and the buffers were not
|
* timestamp, even when [changed] is 0 and the buffers were not
|
||||||
* rewritten. false means this timestamp should be blank.
|
* 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(
|
class AssAtlasFrame(
|
||||||
val atlasWidth: Int,
|
val atlasWidth: Int,
|
||||||
@@ -40,8 +51,17 @@ class AssAtlasFrame(
|
|||||||
val changed: Int,
|
val changed: Int,
|
||||||
val truncated: Int,
|
val truncated: Int,
|
||||||
val requiredPages: 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. */
|
/** Number of atlas pages this frame occupies. */
|
||||||
val pageCount: Int get() = pageHeights.size
|
val pageCount: Int get() = pageHeights.size
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
|
|||||||
|
|
||||||
companion object {
|
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 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
|
@JvmStatic
|
||||||
external fun nativeAssRenderInit(ass: Long): Long
|
external fun nativeAssRenderInit(ass: Long): Long
|
||||||
@@ -175,7 +175,8 @@ class AssRender(nativeAss: Long, private val lock: ReentrantLock) {
|
|||||||
changed = header[2],
|
changed = header[2],
|
||||||
truncated = header[3],
|
truncated = header[3],
|
||||||
requiredPages = header[4],
|
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
|
* 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
|
* 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
|
* 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
|
* buffer grows on demand toward this cap. 4 covers the worst frame measured for
|
||||||
* letter needs 3); past it tiles are dropped and counted in `truncated`. Must match
|
* ordinary typesetting (a 4K letter needs 3); frames too dense even for that
|
||||||
* MAX_ATLAS_PAGES in AssKt.c.
|
* (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
|
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)
|
var frame = render.renderFrameAtlas(timeMs, payload.atlasBuf, slots.atlasW, slots.atlasH, payload.vertexBuf)
|
||||||
?: return null
|
?: return null
|
||||||
// A frame overflows one atlas page only on dense full-screen typesetting. When it
|
// 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
|
// does — multi-page atlas or an RGBA composite rect needing more than one page —
|
||||||
// — libass's caches make the re-render cheap, and the slot keeps the larger buffer
|
// 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.
|
// 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) {
|
if (frame.requiredPages > payload.pageCapacity && payload.pageCapacity < AssAtlasPipelineConfig.MAX_ATLAS_PAGES) {
|
||||||
payload.growAtlas(
|
payload.growAtlas(
|
||||||
@@ -794,7 +797,8 @@ private class AtlasLibassThread(
|
|||||||
"releaseLeadMs=${request.releaseLeadNs / 1_000_000} seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " +
|
"releaseLeadMs=${request.releaseLeadNs / 1_000_000} seq=${payload.contentSeq} waitMs=$waitMs budgetMs=$budgetMs " +
|
||||||
"libassMs=$lastLibassMs lockWaitMs=${assHandler.render?.lastLockWaitMs} " +
|
"libassMs=$lastLibassMs lockWaitMs=${assHandler.render?.lastLockWaitMs} " +
|
||||||
"specHit=${outcome.specHit} changed=${payload.frame.changed} output=${payload.frame.hasOutput} quads=${payload.frame.quadCount} " +
|
"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()
|
""".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 = """
|
private val fragmentShaderCode = """
|
||||||
precision mediump float;
|
precision mediump float;
|
||||||
varying vec2 v_TexCoord;
|
varying vec2 v_TexCoord;
|
||||||
varying vec4 v_Color;
|
varying vec4 v_Color;
|
||||||
uniform sampler2D u_Texture;
|
uniform sampler2D u_Texture;
|
||||||
|
uniform float u_Rgba;
|
||||||
void main() {
|
void main() {
|
||||||
float mask = texture2D(u_Texture, v_TexCoord).a;
|
vec4 texel = texture2D(u_Texture, v_TexCoord);
|
||||||
float alpha = v_Color.a * mask;
|
float alpha = v_Color.a * texel.a;
|
||||||
gl_FragColor = vec4(v_Color.rgb * alpha, alpha);
|
gl_FragColor = mix(vec4(v_Color.rgb * alpha, alpha), texel, u_Rgba);
|
||||||
}
|
}
|
||||||
""".trimIndent()
|
""".trimIndent()
|
||||||
|
|
||||||
@@ -1423,11 +1431,18 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
|||||||
private var allocatedPages = 0
|
private var allocatedPages = 0
|
||||||
private var vertexBufferId = 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 aPosition = 0
|
||||||
private var aTexCoord = 0
|
private var aTexCoord = 0
|
||||||
private var aColor = 0
|
private var aColor = 0
|
||||||
private var uTexture = 0
|
private var uTexture = 0
|
||||||
private var uSurfaceSize = 0
|
private var uSurfaceSize = 0
|
||||||
|
private var uRgba = 0
|
||||||
|
|
||||||
private var atlasAllocatedW = 0
|
private var atlasAllocatedW = 0
|
||||||
private var atlasAllocatedH = 0
|
private var atlasAllocatedH = 0
|
||||||
@@ -1477,6 +1492,7 @@ private class AtlasRenderer(private val assHandler: AssHandler) {
|
|||||||
aColor = glProgram.getAttributeArrayLocationAndEnable("a_Color")
|
aColor = glProgram.getAttributeArrayLocationAndEnable("a_Color")
|
||||||
uTexture = glProgram.getUniformLocation("u_Texture")
|
uTexture = glProgram.getUniformLocation("u_Texture")
|
||||||
uSurfaceSize = glProgram.getUniformLocation("u_SurfaceSize")
|
uSurfaceSize = glProgram.getUniformLocation("u_SurfaceSize")
|
||||||
|
uRgba = glProgram.getUniformLocation("u_Rgba")
|
||||||
|
|
||||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
||||||
GLES20.glUniform1i(uTexture, 0)
|
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(aPosition, 2, GLES20.GL_FLOAT, false, stride, 0)
|
||||||
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, stride, 8)
|
GLES20.glVertexAttribPointer(aTexCoord, 2, GLES20.GL_FLOAT, false, stride, 8)
|
||||||
GLES20.glVertexAttribPointer(aColor, 4, GLES20.GL_FLOAT, false, stride, 16)
|
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
|
// 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
|
// stream (page assignment is monotonic in painter order). Upload + draw each in
|
||||||
// turn, which reproduces the libass blend order across pages.
|
// turn, which reproduces the libass blend order across pages.
|
||||||
GLES20.glActiveTexture(GLES20.GL_TEXTURE0)
|
|
||||||
var quadOffset = 0
|
var quadOffset = 0
|
||||||
for (p in 0 until frame.pageCount) {
|
for (p in 0 until frame.pageCount) {
|
||||||
val pageQuads = frame.pageQuadCounts[p]
|
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
|
/** Uploads page [page]'s packed rows from the stacked atlas buffer into the
|
||||||
* currently-bound page texture. */
|
* currently-bound page texture. */
|
||||||
private fun uploadPage(atlasBuf: ByteBuffer, page: Int, atlasW: Int, pageH: Int) {
|
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)
|
atlasTexIds.fill(0)
|
||||||
allocatedPages = 0
|
allocatedPages = 0
|
||||||
}
|
}
|
||||||
|
if (rgbaTexId != 0) {
|
||||||
|
val tex = intArrayOf(rgbaTexId)
|
||||||
|
GLES20.glDeleteTextures(1, tex, 0)
|
||||||
|
rgbaTexId = 0
|
||||||
|
rgbaAllocW = 0
|
||||||
|
rgbaAllocH = 0
|
||||||
|
}
|
||||||
if (vertexBufferId != 0) {
|
if (vertexBufferId != 0) {
|
||||||
val buf = intArrayOf(vertexBufferId)
|
val buf = intArrayOf(vertexBufferId)
|
||||||
GLES20.glDeleteBuffers(1, buf, 0)
|
GLES20.glDeleteBuffers(1, buf, 0)
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
<svg id="svg3390" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="141.25" viewBox="0 0 138.75 141.25" width="138.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
<metadata id="metadata3396">
|
<metadata id="metadata3396">
|
||||||
<rdf:RDF>
|
<rdf:RDF>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.3 KiB |
@@ -1,5 +1,4 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||||
<!-- Created with Inkscape (http://www.inkscape.org/) -->
|
|
||||||
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
<svg id="svg3880" xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" xmlns="http://www.w3.org/2000/svg" height="108.75" viewBox="0 0 143.75 108.75" width="143.75" version="1.1" xmlns:cc="http://creativecommons.org/ns#" xmlns:dc="http://purl.org/dc/elements/1.1/">
|
||||||
<metadata id="metadata3886">
|
<metadata id="metadata3886">
|
||||||
<rdf:RDF>
|
<rdf:RDF>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 4.6 KiB After Width: | Height: | Size: 4.5 KiB |
@@ -1,4 +1,3 @@
|
|||||||
# Uncomment this line to define a global platform for your project
|
|
||||||
platform :ios, '15.5'
|
platform :ios, '15.5'
|
||||||
|
|
||||||
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
# CocoaPods analytics sends network stats synchronously affecting flutter build latency.
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ import MediaPlayer
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> Bool {
|
||||||
// Configure audio session for media playback. Not activated here: the
|
// Configure the non-mixing session; activate it only when playback starts.
|
||||||
// session is non-mixing, so activation stops other apps' audio — it is
|
|
||||||
// claimed when playback actually starts.
|
|
||||||
do {
|
do {
|
||||||
let session = AVAudioSession.sharedInstance()
|
let session = AVAudioSession.sharedInstance()
|
||||||
try session.setCategory(.playback, mode: .default)
|
try session.setCategory(.playback, mode: .default)
|
||||||
@@ -32,12 +30,10 @@ import MediaPlayer
|
|||||||
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
|
||||||
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
|
||||||
|
|
||||||
// Register MPV player plugin
|
|
||||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") {
|
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvPlayerPlugin") {
|
||||||
MpvPlayerPlugin.register(with: registrar)
|
MpvPlayerPlugin.register(with: registrar)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Register the audio-only MPV player plugin (music playback)
|
|
||||||
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") {
|
if let registrar = engineBridge.pluginRegistry.registrar(forPlugin: "MpvAudioPlayerPlugin") {
|
||||||
MpvAudioPlayerPlugin.register(with: registrar)
|
MpvAudioPlayerPlugin.register(with: registrar)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,19 +39,13 @@ import UIKit
|
|||||||
|
|
||||||
/// Delegate to notify the plugin of PiP lifecycle events
|
/// Delegate to notify the plugin of PiP lifecycle events
|
||||||
protocol MpvPipDelegate: AnyObject {
|
protocol MpvPipDelegate: AnyObject {
|
||||||
/// Called when PiP is about to start (system or app-initiated)
|
|
||||||
func pipWillStart()
|
func pipWillStart()
|
||||||
func pipDidStart()
|
func pipDidStart()
|
||||||
/// Called when PiP stops. `restored` is true if the user pressed maximize (restore UI).
|
|
||||||
func pipDidStop(restored: Bool)
|
func pipDidStop(restored: Bool)
|
||||||
func pipDidFailToStart(error: Error?)
|
func pipDidFailToStart(error: Error?)
|
||||||
/// Forward play/pause commands from PiP overlay to mpv
|
|
||||||
func pipSetPlaying(_ playing: Bool)
|
func pipSetPlaying(_ playing: Bool)
|
||||||
/// Forward skip forward/backward commands from PiP overlay to mpv
|
|
||||||
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
|
func pipSkip(byInterval seconds: Double, completion: @escaping () -> Void)
|
||||||
/// Query whether mpv is currently playing
|
|
||||||
var isPipPlaying: Bool { get }
|
var isPipPlaying: Bool { get }
|
||||||
/// Get total duration in seconds
|
|
||||||
var pipDuration: Double { get }
|
var pipDuration: Double { get }
|
||||||
}
|
}
|
||||||
protocol MpvPictureInPictureControlling: AnyObject {
|
protocol MpvPictureInPictureControlling: AnyObject {
|
||||||
@@ -151,7 +145,6 @@ import UIKit
|
|||||||
createPipController()
|
createPipController()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Helper that conforms to the iOS 15+ delegate protocols
|
|
||||||
private var delegateHelper: AnyObject?
|
private var delegateHelper: AnyObject?
|
||||||
|
|
||||||
private func createPipController() {
|
private func createPipController() {
|
||||||
|
|||||||
@@ -12,12 +12,10 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
|||||||
private weak var registrar: FlutterPluginRegistrar?
|
private weak var registrar: FlutterPluginRegistrar?
|
||||||
var nameToId: [String: Int] = [:]
|
var nameToId: [String: Int] = [:]
|
||||||
|
|
||||||
// MpvPluginShared conformance
|
|
||||||
var coreBase: MpvPlayerCoreBase? { playerCore }
|
var coreBase: MpvPlayerCoreBase? { playerCore }
|
||||||
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
|
func setPlayerVisible(_ visible: Bool, restoreOnWindowVisible _: Bool) { playerCore?.setVisible(visible) }
|
||||||
func updatePlayerFrame() { playerCore?.updateFrame() }
|
func updatePlayerFrame() { playerCore?.updateFrame() }
|
||||||
|
|
||||||
// PiP
|
|
||||||
private var pipController: MpvPipController?
|
private var pipController: MpvPipController?
|
||||||
private var pipChannel: FlutterMethodChannel?
|
private var pipChannel: FlutterMethodChannel?
|
||||||
private var autoPipEnabled = false
|
private var autoPipEnabled = false
|
||||||
@@ -207,7 +205,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
pip.setAutoStart(true)
|
pip.setAutoStart(true)
|
||||||
// Warm the layer so the system considers PiP possible
|
|
||||||
if let pc = self.playerCore {
|
if let pc = self.playerCore {
|
||||||
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
pip.warmLayer(currentTime: pc.timePos, isPlaying: !pc.isPaused)
|
||||||
}
|
}
|
||||||
@@ -287,7 +284,6 @@ class MpvPlayerPlugin: NSObject, FlutterPlugin, FlutterStreamHandler, MpvPluginS
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Unified cleanup for all PiP exit paths
|
|
||||||
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
private func cleanupPip(notify: Bool, pause: Bool = false) {
|
||||||
playerCore?.setPipSubtitleCompositing(false)
|
playerCore?.setPipSubtitleCompositing(false)
|
||||||
playerCore?.isPipStarting = false
|
playerCore?.isPipStarting = false
|
||||||
|
|||||||
@@ -13,13 +13,10 @@ platform :ios do
|
|||||||
lane :deploy_appstore do
|
lane :deploy_appstore do
|
||||||
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
git_commit = `git -C #{PROJECT_ROOT_ARG} rev-parse --short HEAD`.strip
|
||||||
|
|
||||||
# Build the Flutter app
|
|
||||||
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
|
sh("cd #{PROJECT_ROOT_ARG} && flutter build ipa --dart-define=ENABLE_SENTRY=true --dart-define=GIT_COMMIT=#{git_commit} --dart-define=SENTRY_ENVIRONMENT=app-store --dart-define=SENTRY_DIST=app-store --split-debug-info=debug-info/ios")
|
||||||
|
|
||||||
# Upload debug symbols
|
|
||||||
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
|
sh("cd #{PROJECT_ROOT_ARG} && SENTRY_DIST=app-store ./scripts/upload-symbols.sh ios")
|
||||||
|
|
||||||
# Upload to App Store
|
|
||||||
upload_to_app_store(
|
upload_to_app_store(
|
||||||
ipa: "../build/ios/ipa/Plezy.ipa",
|
ipa: "../build/ios/ipa/Plezy.ipa",
|
||||||
force: true,
|
force: true,
|
||||||
|
|||||||
@@ -119,7 +119,6 @@ class AppDatabase extends _$AppDatabase {
|
|||||||
// migrations while failures are still covered by this close/rethrow
|
// migrations while failures are still covered by this close/rethrow
|
||||||
// boundary and the caller's startup download-recovery decision.
|
// boundary and the caller's startup download-recovery decision.
|
||||||
await database.customSelect('SELECT 1').get();
|
await database.customSelect('SELECT 1').get();
|
||||||
// It deliberately does not claim capacity for a later write.
|
|
||||||
}
|
}
|
||||||
final outcome = await _tvosRecoveryQueue.run(
|
final outcome = await _tvosRecoveryQueue.run(
|
||||||
() => store.reconcile(
|
() => store.reconcile(
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
|||||||
final double viewportHeight = scrollController.position.viewportDimension;
|
final double viewportHeight = scrollController.position.viewportDimension;
|
||||||
final double viewportBottom = viewportTop + viewportHeight;
|
final double viewportBottom = viewportTop + viewportHeight;
|
||||||
|
|
||||||
// Already fully visible — skip
|
|
||||||
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
if (targetTop >= viewportTop && targetBottom <= viewportBottom) return;
|
||||||
|
|
||||||
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
final double destination = (targetTop - viewportHeight * 0.25).clamp(
|
||||||
@@ -104,7 +103,6 @@ mixin DpadReorderListMixin<E, W extends StatefulWidget> on State<W> {
|
|||||||
|
|
||||||
final backResult = handleBackKeyAction(event, () {
|
final backResult = handleBackKeyAction(event, () {
|
||||||
if (movingIndex != null) {
|
if (movingIndex != null) {
|
||||||
// Cancel move - restore original position
|
|
||||||
setState(() {
|
setState(() {
|
||||||
final originalOrder = _originalOrder;
|
final originalOrder = _originalOrder;
|
||||||
if (originalOrder != null) {
|
if (originalOrder != null) {
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class FocusMemoryTracker {
|
|||||||
/// Restore focus to the last focused item, or fallback if provided
|
/// Restore focus to the last focused item, or fallback if provided
|
||||||
/// Returns true if focus was successfully restored
|
/// Returns true if focus was successfully restored
|
||||||
bool restoreFocus({String? fallbackKey}) {
|
bool restoreFocus({String? fallbackKey}) {
|
||||||
// Try to restore last focused item
|
|
||||||
if (_lastFocusedKey != null) {
|
if (_lastFocusedKey != null) {
|
||||||
final node = _nodes[_lastFocusedKey];
|
final node = _nodes[_lastFocusedKey];
|
||||||
if (node != null) {
|
if (node != null) {
|
||||||
@@ -50,7 +49,6 @@ class FocusMemoryTracker {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Fallback: focus the provided key if available
|
|
||||||
if (fallbackKey != null) {
|
if (fallbackKey != null) {
|
||||||
final node = _nodes[fallbackKey];
|
final node = _nodes[fallbackKey];
|
||||||
if (node != null) {
|
if (node != null) {
|
||||||
@@ -69,7 +67,6 @@ class FocusMemoryTracker {
|
|||||||
_nodes.remove(key);
|
_nodes.remove(key);
|
||||||
_focused.remove(key);
|
_focused.remove(key);
|
||||||
}
|
}
|
||||||
// Clear last focused if it was pruned
|
|
||||||
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
|
if (_lastFocusedKey != null && !validKeys.contains(_lastFocusedKey)) {
|
||||||
_lastFocusedKey = null;
|
_lastFocusedKey = null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ class _FocusableButtonState extends State<FocusableButton> {
|
|||||||
final showFocus = _isFocused && isKeyboard;
|
final showFocus = _isFocused && isKeyboard;
|
||||||
final duration = FocusTheme.getAnimationDuration(context);
|
final duration = FocusTheme.getAnimationDuration(context);
|
||||||
final enabled = widget.onPressed != null;
|
final enabled = widget.onPressed != null;
|
||||||
// In dpad mode: focused = full opacity, unfocused = dimmed
|
|
||||||
final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0;
|
final opacity = isKeyboard && !_isFocused ? 0.6 : 1.0;
|
||||||
|
|
||||||
return FocusableWrapper(
|
return FocusableWrapper(
|
||||||
|
|||||||
@@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox {
|
|||||||
|
|
||||||
/// A wrapper widget that makes its child focusable with D-pad navigation support.
|
/// A wrapper widget that makes its child focusable with D-pad navigation support.
|
||||||
///
|
///
|
||||||
/// Provides:
|
|
||||||
/// - Visual focus indicator (border + scale animation)
|
|
||||||
/// - Keyboard/D-pad event handling (Enter/Select to activate)
|
|
||||||
/// - Optional auto-scroll to keep focused item visible
|
|
||||||
/// - Long-press detection for SELECT key
|
|
||||||
/// - Navigation callbacks (UP, BACK)
|
|
||||||
class FocusableWrapper extends StatefulWidget {
|
class FocusableWrapper extends StatefulWidget {
|
||||||
/// The child widget to wrap.
|
/// The child widget to wrap.
|
||||||
final Widget child;
|
final Widget child;
|
||||||
@@ -287,12 +281,10 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
|||||||
void didUpdateWidget(FocusableWrapper oldWidget) {
|
void didUpdateWidget(FocusableWrapper oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
|
|
||||||
// Handle focusNode changes
|
|
||||||
if (widget.focusNode != oldWidget.focusNode) {
|
if (widget.focusNode != oldWidget.focusNode) {
|
||||||
_bindFocusNode();
|
_bindFocusNode();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update canRequestFocus
|
|
||||||
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
|
if (widget.canRequestFocus != oldWidget.canRequestFocus) {
|
||||||
_focusNode.canRequestFocus = widget.canRequestFocus;
|
_focusNode.canRequestFocus = widget.canRequestFocus;
|
||||||
}
|
}
|
||||||
@@ -368,7 +360,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
|||||||
final viewport = scrollable.context.findRenderObject() as RenderBox?;
|
final viewport = scrollable.context.findRenderObject() as RenderBox?;
|
||||||
if (viewport == null) return;
|
if (viewport == null) return;
|
||||||
|
|
||||||
// Get item's position relative to viewport
|
|
||||||
final itemBox = renderObject as RenderBox;
|
final itemBox = renderObject as RenderBox;
|
||||||
final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport);
|
final itemPosition = itemBox.localToGlobal(Offset.zero, ancestor: viewport);
|
||||||
|
|
||||||
@@ -376,17 +367,14 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
|||||||
final itemHeight = itemBox.size.height;
|
final itemHeight = itemBox.size.height;
|
||||||
final itemVerticalCenter = itemPosition.dy + itemHeight / 2;
|
final itemVerticalCenter = itemPosition.dy + itemHeight / 2;
|
||||||
|
|
||||||
// Account for focus decoration when checking item visibility
|
|
||||||
final itemTop = itemPosition.dy - _focusDecorationPadding;
|
final itemTop = itemPosition.dy - _focusDecorationPadding;
|
||||||
final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding;
|
final itemBottom = itemPosition.dy + itemHeight + _focusDecorationPadding;
|
||||||
|
|
||||||
if (widget.useComfortableZone) {
|
if (widget.useComfortableZone) {
|
||||||
// Define comfortable zone - if item (including focus decoration) is within middle 60% of viewport, don't scroll
|
|
||||||
final comfortZoneTop = viewportHeight * 0.2;
|
final comfortZoneTop = viewportHeight * 0.2;
|
||||||
final comfortZoneBottom = viewportHeight * 0.8;
|
final comfortZoneBottom = viewportHeight * 0.8;
|
||||||
|
|
||||||
if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) {
|
if (itemTop >= comfortZoneTop && itemBottom <= comfortZoneBottom) {
|
||||||
// Item is in comfortable zone, no need to scroll
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -394,22 +382,17 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
|||||||
// close to target position (prevents jitter when navigating horizontally)
|
// close to target position (prevents jitter when navigating horizontally)
|
||||||
final targetY = viewportHeight * widget.scrollAlignment;
|
final targetY = viewportHeight * widget.scrollAlignment;
|
||||||
final distance = (itemVerticalCenter - targetY).abs();
|
final distance = (itemVerticalCenter - targetY).abs();
|
||||||
// Skip scroll if within half the item height of target
|
|
||||||
if (distance < itemHeight / 2) {
|
if (distance < itemHeight / 2) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate target scroll offset for the immediate scrollable only.
|
// Avoid Scrollable.ensureVisible, which scrolls all ancestor scrollables and
|
||||||
// This avoids Scrollable.ensureVisible which scrolls ALL ancestor scrollables,
|
// can move nested views (e.g. the chips bar) out of view when focusing grid items.
|
||||||
// which can cause issues with nested scroll views (e.g., chips bar scrolling
|
|
||||||
// out of view when focusing grid items in library browse tab).
|
|
||||||
final position = scrollable.position;
|
final position = scrollable.position;
|
||||||
final currentOffset = position.pixels;
|
final currentOffset = position.pixels;
|
||||||
|
|
||||||
// Target: item center should be at scrollAlignment of viewport
|
|
||||||
// Add padding to ensure focus decoration is fully visible
|
|
||||||
final targetViewportY = viewportHeight * widget.scrollAlignment;
|
final targetViewportY = viewportHeight * widget.scrollAlignment;
|
||||||
|
|
||||||
var scrollDelta = itemVerticalCenter - targetViewportY;
|
var scrollDelta = itemVerticalCenter - targetViewportY;
|
||||||
|
|
||||||
// If item would be near the top edge, add extra scroll to show focus decoration
|
// If item would be near the top edge, add extra scroll to show focus decoration
|
||||||
@@ -471,7 +454,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle SELECT key with optional long-press detection
|
|
||||||
if (key.isSelectKey) {
|
if (key.isSelectKey) {
|
||||||
if (widget.enableLongPress) {
|
if (widget.enableLongPress) {
|
||||||
final result = _selectLongPress.handleKeyEvent(
|
final result = _selectLongPress.handleKeyEvent(
|
||||||
@@ -547,7 +529,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
|
|||||||
} else {
|
} else {
|
||||||
final duration = FocusTheme.getAnimationDuration(context);
|
final duration = FocusTheme.getAnimationDuration(context);
|
||||||
final controller = _ensureAnimationController();
|
final controller = _ensureAnimationController();
|
||||||
// Update animation duration if theme changes
|
|
||||||
if (controller.duration != duration) {
|
if (controller.duration != duration) {
|
||||||
controller.duration = duration;
|
controller.duration = duration;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,9 +85,7 @@ class _InputModeTrackerState extends State<InputModeTracker> {
|
|||||||
// is identity-guarded — otherwise startup's bootstrap→app swap would leave
|
// is identity-guarded — otherwise startup's bootstrap→app swap would leave
|
||||||
// the live registration cleared.
|
// the live registration cleared.
|
||||||
InputModeTracker._instance = this;
|
InputModeTracker._instance = this;
|
||||||
// Initialize focus highlight strategy based on starting mode
|
|
||||||
_updateFocusHighlightStrategy(_mode);
|
_updateFocusHighlightStrategy(_mode);
|
||||||
// Listen to hardware keyboard events globally
|
|
||||||
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
HardwareKeyboard.instance.addHandler(_handleKeyEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -114,13 +114,10 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
DownloadProvider({required this._downloadManager, required this._database})
|
DownloadProvider({required this._downloadManager, required this._database})
|
||||||
: _syncRuleExecutor = SyncRuleExecutor(database: _database) {
|
: _syncRuleExecutor = SyncRuleExecutor(database: _database) {
|
||||||
_metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged);
|
_metadataStore = _DownloadMetadataStore(_downloadManager, _database)..addListener(_onMetadataStoreChanged);
|
||||||
// Listen to progress updates from the download manager
|
|
||||||
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
_progressSubscription = _downloadManager.progressStream.listen(_onProgressUpdate);
|
||||||
|
|
||||||
// Listen to deletion progress updates
|
|
||||||
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
_deletionProgressSubscription = _downloadManager.deletionProgressStream.listen(_onDeletionProgressUpdate);
|
||||||
|
|
||||||
// Load persisted downloads from database
|
|
||||||
_initFuture = _loadPersistedDownloads();
|
_initFuture = _loadPersistedDownloads();
|
||||||
|
|
||||||
// Lets the diagnostics service score whether downloads actually advance
|
// Lets the diagnostics service score whether downloads actually advance
|
||||||
@@ -400,7 +397,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
// Initialize artwork directory path for synchronous access
|
// Initialize artwork directory path for synchronous access
|
||||||
await storageService.getArtworkDirectory();
|
await storageService.getArtworkDirectory();
|
||||||
|
|
||||||
// Load all downloads from database
|
|
||||||
final downloads = await _downloadManager.getAllDownloads();
|
final downloads = await _downloadManager.getAllDownloads();
|
||||||
|
|
||||||
// Bulk-load all pinned metadata across every backend in a single pass
|
// Bulk-load all pinned metadata across every backend in a single pass
|
||||||
@@ -427,7 +423,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load sync rules from database
|
|
||||||
await _loadSyncRules();
|
await _loadSyncRules();
|
||||||
|
|
||||||
// Apply queued offline watch actions on top of the server-time metadata
|
// Apply queued offline watch actions on top of the server-time metadata
|
||||||
@@ -844,7 +839,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate aggregate statistics
|
|
||||||
int completedCount = 0;
|
int completedCount = 0;
|
||||||
int downloadingCount = 0;
|
int downloadingCount = 0;
|
||||||
int queuedCount = 0;
|
int queuedCount = 0;
|
||||||
@@ -867,7 +861,6 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Determine overall status
|
|
||||||
final DownloadStatus overallStatus;
|
final DownloadStatus overallStatus;
|
||||||
if (completedCount == totalEpisodes) {
|
if (completedCount == totalEpisodes) {
|
||||||
overallStatus = DownloadStatus.completed;
|
overallStatus = DownloadStatus.completed;
|
||||||
@@ -912,22 +905,18 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
/// For shows/seasons, returns aggregate progress of all child episodes
|
/// For shows/seasons, returns aggregate progress of all child episodes
|
||||||
/// For episodes/movies, returns direct progress
|
/// For episodes/movies, returns direct progress
|
||||||
DownloadProgress? getProgress(String globalKey) {
|
DownloadProgress? getProgress(String globalKey) {
|
||||||
// First check if we have direct progress (for episodes/movies)
|
|
||||||
final directProgress = _downloads[globalKey];
|
final directProgress = _downloads[globalKey];
|
||||||
if (directProgress != null) {
|
if (directProgress != null) {
|
||||||
if (!_ownsDownloadKey(globalKey)) return null;
|
if (!_ownsDownloadKey(globalKey)) return null;
|
||||||
return directProgress;
|
return directProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If no direct progress, check if this is a show or season
|
|
||||||
// and calculate aggregate progress from episodes
|
|
||||||
final parsed = parseGlobalKey(globalKey);
|
final parsed = parseGlobalKey(globalKey);
|
||||||
if (parsed == null) return null;
|
if (parsed == null) return null;
|
||||||
|
|
||||||
final serverId = parsed.serverId;
|
final serverId = parsed.serverId;
|
||||||
final ratingKey = parsed.ratingKey;
|
final ratingKey = parsed.ratingKey;
|
||||||
|
|
||||||
// Try to get metadata to determine type
|
|
||||||
final meta = _metadata[globalKey];
|
final meta = _metadata[globalKey];
|
||||||
if (meta == null) {
|
if (meta == null) {
|
||||||
// No metadata stored yet, might be a container (show/season/artist/
|
// No metadata stored yet, might be a container (show/season/artist/
|
||||||
@@ -1294,11 +1283,9 @@ class DownloadProvider extends ChangeNotifier with DisposableChangeNotifierMixin
|
|||||||
await _claimDownloadForProfile(globalKey, ownership, client);
|
await _claimDownloadForProfile(globalKey, ownership, client);
|
||||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||||
|
|
||||||
// Update local state immediately for UI feedback
|
|
||||||
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
_downloads[globalKey] = DownloadProgress(globalKey: globalKey, status: DownloadStatus.queued);
|
||||||
safeNotifyListeners();
|
safeNotifyListeners();
|
||||||
|
|
||||||
// Actually trigger download via DownloadManagerService
|
|
||||||
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
if (!_isQueueOwnershipCurrent(ownership)) return false;
|
||||||
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
|
await _downloadManager.queueDownload(metadata: metadataToStore, client: client, mediaIndex: resolvedIndex);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -303,10 +303,8 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
|||||||
return libraries;
|
return libraries;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create a map for quick lookup
|
|
||||||
final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
|
final libraryMap = {for (final lib in libraries) lib.globalKey: lib};
|
||||||
|
|
||||||
// Build ordered list based on saved order
|
|
||||||
final orderedLibraries = <MediaLibrary>[];
|
final orderedLibraries = <MediaLibrary>[];
|
||||||
for (final key in savedOrder) {
|
for (final key in savedOrder) {
|
||||||
final lib = libraryMap.remove(key);
|
final lib = libraryMap.remove(key);
|
||||||
@@ -315,7 +313,6 @@ class LibrariesProvider extends ChangeNotifier with DisposableChangeNotifierMixi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add any new libraries that weren't in the saved order
|
|
||||||
orderedLibraries.addAll(libraryMap.values);
|
orderedLibraries.addAll(libraryMap.values);
|
||||||
|
|
||||||
return orderedLibraries;
|
return orderedLibraries;
|
||||||
|
|||||||
@@ -175,7 +175,6 @@ class OfflineModeProvider extends ChangeNotifier with DisposableChangeNotifierMi
|
|||||||
if (_isInitialized) return;
|
if (_isInitialized) return;
|
||||||
_isInitialized = true;
|
_isInitialized = true;
|
||||||
|
|
||||||
// Check initial connectivity
|
|
||||||
await _updateConnectionFlags();
|
await _updateConnectionFlags();
|
||||||
|
|
||||||
// Monitor connectivity changes — runZonedGuarded catches async errors from
|
// Monitor connectivity changes — runZonedGuarded catches async errors from
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
|||||||
final DownloadProvider _downloadProvider;
|
final DownloadProvider _downloadProvider;
|
||||||
|
|
||||||
OfflineWatchProvider({required this._syncService, required this._downloadProvider}) {
|
OfflineWatchProvider({required this._syncService, required this._downloadProvider}) {
|
||||||
// Listen to sync service changes to update UI
|
|
||||||
_syncService.addListener(_onSyncServiceChanged);
|
_syncService.addListener(_onSyncServiceChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,13 +48,11 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
|||||||
///
|
///
|
||||||
/// Returns true if watched, false otherwise.
|
/// Returns true if watched, false otherwise.
|
||||||
Future<bool> isWatched(String globalKey) async {
|
Future<bool> isWatched(String globalKey) async {
|
||||||
// First check local offline action
|
|
||||||
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
||||||
if (localStatus != null) {
|
if (localStatus != null) {
|
||||||
return localStatus;
|
return localStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to cached metadata
|
|
||||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||||
if (metadata != null) {
|
if (metadata != null) {
|
||||||
return metadata.isWatched;
|
return metadata.isWatched;
|
||||||
@@ -73,7 +70,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
|||||||
/// Returns null if no position is available.
|
/// Returns null if no position is available.
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
Future<int?> getViewOffset(String globalKey) async {
|
Future<int?> getViewOffset(String globalKey) async {
|
||||||
// First check local offline progress
|
|
||||||
final localOffset = await _syncService.getLocalViewOffset(globalKey);
|
final localOffset = await _syncService.getLocalViewOffset(globalKey);
|
||||||
if (localOffset != null) {
|
if (localOffset != null) {
|
||||||
return localOffset;
|
return localOffset;
|
||||||
@@ -82,7 +78,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
|||||||
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
final localStatus = await _syncService.getLocalWatchStatus(globalKey);
|
||||||
if (localStatus == true) return null;
|
if (localStatus == true) return null;
|
||||||
|
|
||||||
// Fall back to cached metadata
|
|
||||||
final metadata = _downloadProvider.getMetadata(globalKey);
|
final metadata = _downloadProvider.getMetadata(globalKey);
|
||||||
return metadata?.viewOffsetMs;
|
return metadata?.viewOffsetMs;
|
||||||
}
|
}
|
||||||
@@ -127,7 +122,6 @@ class OfflineWatchProvider extends ChangeNotifier with DisposableChangeNotifierM
|
|||||||
|
|
||||||
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
final watchStatuses = await _resolveEpisodeWatchStatuses(episodes);
|
||||||
|
|
||||||
// Find first unwatched episode
|
|
||||||
for (final episode in episodes) {
|
for (final episode in episodes) {
|
||||||
if (!watchStatuses[episode.globalKey]!) {
|
if (!watchStatuses[episode.globalKey]!) {
|
||||||
return episode;
|
return episode;
|
||||||
|
|||||||
@@ -103,18 +103,15 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
final TvSpotlightController _spotlight = TvSpotlightController();
|
final TvSpotlightController _spotlight = TvSpotlightController();
|
||||||
bool _isTabVisible = true;
|
bool _isTabVisible = true;
|
||||||
|
|
||||||
// Track initial load so we can focus hero when content first appears
|
|
||||||
bool _initialLoadComplete = false;
|
bool _initialLoadComplete = false;
|
||||||
bool _pendingTvBrowseRailFocus = false;
|
bool _pendingTvBrowseRailFocus = false;
|
||||||
|
|
||||||
// Hub navigation keys
|
|
||||||
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
GlobalKey<HubSectionState>? _continueWatchingHubKey;
|
||||||
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
|
final Map<String, GlobalKey<HubSectionState>> _hubKeysByIdentity = {};
|
||||||
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
List<GlobalKey<HubSectionState>> _orderedHubKeys = const [];
|
||||||
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
final _tvBrowseRailKey = GlobalKey<TvBrowseRailState>();
|
||||||
final _hubFocusMemory = HubFocusMemory();
|
final _hubFocusMemory = HubFocusMemory();
|
||||||
|
|
||||||
// Hero and app bar focus
|
|
||||||
late FocusNode _heroFocusNode;
|
late FocusNode _heroFocusNode;
|
||||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||||
final _serverActivitiesButtonKey = GlobalKey<ServerActivitiesButtonState>();
|
final _serverActivitiesButtonKey = GlobalKey<ServerActivitiesButtonState>();
|
||||||
@@ -155,7 +152,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
_continueWatchingHubKey ??= GlobalKey<HubSectionState>();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get all hub states (continue watching + other hubs)
|
|
||||||
List<GlobalKey<HubSectionState>> get _allHubKeys {
|
List<GlobalKey<HubSectionState>> get _allHubKeys {
|
||||||
final keys = <GlobalKey<HubSectionState>>[];
|
final keys = <GlobalKey<HubSectionState>>[];
|
||||||
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
|
if (_continueWatchingHubKey != null && _onDeck.isNotEmpty) {
|
||||||
@@ -935,7 +931,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
if (_isLoading) LoadingIndicatorBox.sliver,
|
if (_isLoading) LoadingIndicatorBox.sliver,
|
||||||
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
if (_errorMessage != null) SliverErrorState(message: _errorMessage!, onRetry: _discover.load),
|
||||||
if (!_isLoading && _errorMessage == null) ...[
|
if (!_isLoading && _errorMessage == null) ...[
|
||||||
// On Deck / Continue Watching
|
|
||||||
if (continueWatchingHub != null)
|
if (continueWatchingHub != null)
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: HubSection(
|
child: HubSection(
|
||||||
@@ -1259,14 +1254,11 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
|
shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Determine content type label for chip
|
|
||||||
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
|
final contentTypeLabel = heroItem.isMovie ? t.discover.movie : t.discover.tvShow;
|
||||||
|
|
||||||
// Spoiler protection
|
|
||||||
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
|
final hideSpoilers = SettingsService.instance.read(SettingsService.hideSpoilers);
|
||||||
final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler;
|
final shouldHideSpoiler = hideSpoilers && heroItem.shouldHideSpoiler;
|
||||||
|
|
||||||
// Build semantic label for hero item
|
|
||||||
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
|
final heroLabel = isEpisode ? "${heroItem.grandparentTitle}, ${heroItem.title}" : heroItem.title;
|
||||||
|
|
||||||
return Semantics(
|
return Semantics(
|
||||||
@@ -1420,10 +1412,8 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// On small screens: show button before summary
|
|
||||||
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)],
|
||||||
|
|
||||||
// Summary with episode info (Apple TV style)
|
|
||||||
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
if (heroItem.summary != null && !shouldHideSpoiler) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
RichText(
|
RichText(
|
||||||
@@ -1468,7 +1458,6 @@ class _DiscoverScreenState extends State<DiscoverScreen>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
// On large screens: show button after summary
|
|
||||||
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
|
if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ class DownloadsScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class DownloadsScreenState extends State<DownloadsScreen>
|
class DownloadsScreenState extends State<DownloadsScreen>
|
||||||
with TickerProviderStateMixin, TabNavigationMixin, FocusableTab {
|
with TickerProviderStateMixin, TabNavigationMixin, FocusableTab {
|
||||||
// Focus nodes for tab chips
|
|
||||||
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
|
final _queueTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_queue');
|
||||||
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
|
final _tvShowsTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_tv_shows');
|
||||||
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
|
final _moviesTabChipFocusNode = FocusNode(debugLabel: 'tab_chip_movies');
|
||||||
@@ -60,7 +59,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
suppressAutoFocus = true; // Start suppressed
|
|
||||||
initTabNavigation();
|
initTabNavigation();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +90,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
|||||||
|
|
||||||
/// Focus the first item in the currently active tab
|
/// Focus the first item in the currently active tab
|
||||||
void _focusCurrentTab() {
|
void _focusCurrentTab() {
|
||||||
// Re-enable auto-focus since user is navigating into tab content
|
|
||||||
setState(() {
|
setState(() {
|
||||||
suppressAutoFocus = false;
|
suppressAutoFocus = false;
|
||||||
});
|
});
|
||||||
@@ -115,7 +112,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
|||||||
|
|
||||||
/// Build the app bar title - either tabs on desktop or simple title on mobile
|
/// Build the app bar title - either tabs on desktop or simple title on mobile
|
||||||
Widget _buildAppBarTitle() {
|
Widget _buildAppBarTitle() {
|
||||||
// On desktop/TV with side nav, show tabs in app bar
|
|
||||||
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
if (PlatformDetector.shouldUseSideNavigation(context)) {
|
||||||
return TabChipStrip(
|
return TabChipStrip(
|
||||||
children: [
|
children: [
|
||||||
@@ -130,7 +126,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// On mobile, show simple title
|
|
||||||
return Text(t.downloads.title);
|
return Text(t.downloads.title);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +172,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
|||||||
builder: (context, hasPendingDownloads, _) =>
|
builder: (context, hasPendingDownloads, _) =>
|
||||||
BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads),
|
BackgroundDownloadWarningBanner(hasPendingDownloads: hasPendingDownloads),
|
||||||
),
|
),
|
||||||
// Tab selector chips (only on mobile - desktop has them in app bar)
|
|
||||||
if (!PlatformDetector.shouldUseSideNavigation(context))
|
if (!PlatformDetector.shouldUseSideNavigation(context))
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
@@ -197,7 +191,6 @@ class DownloadsScreenState extends State<DownloadsScreen>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Tab content
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: tabController,
|
controller: tabController,
|
||||||
|
|||||||
@@ -202,7 +202,6 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
setState(() {
|
setState(() {
|
||||||
_filteredItems = List.from(_items);
|
_filteredItems = List.from(_items);
|
||||||
|
|
||||||
// Apply sorting
|
|
||||||
if (_selectedSort != null) {
|
if (_selectedSort != null) {
|
||||||
final sortKey = _selectedSort!.key;
|
final sortKey = _selectedSort!.key;
|
||||||
_filteredItems.sort((a, b) {
|
_filteredItems.sort((a, b) {
|
||||||
@@ -540,21 +539,16 @@ class _HubDetailScreenState extends State<HubDetailScreen>
|
|||||||
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
final libraryDensity = svc.read(SettingsService.libraryDensity);
|
||||||
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
final fullCardLayout = PlatformDetector.isTV() && svc.read(SettingsService.tvFullCardLayout);
|
||||||
|
|
||||||
// Determine hub content type for layout decisions
|
|
||||||
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
|
final hasEpisodes = _filteredItems.any((item) => item.usesWideAspectRatio(episodePosterMode));
|
||||||
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
|
final hasNonEpisodes = _filteredItems.any((item) => !item.usesWideAspectRatio(episodePosterMode));
|
||||||
|
|
||||||
// Mixed hub = has both episodes AND non-episodes
|
|
||||||
final isMixedHub = hasEpisodes && hasNonEpisodes;
|
final isMixedHub = hasEpisodes && hasNonEpisodes;
|
||||||
|
|
||||||
// Episode-only = all items are episodes with thumbnails
|
|
||||||
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
|
final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes;
|
||||||
|
|
||||||
// Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode)
|
|
||||||
final useWideLayout =
|
final useWideLayout =
|
||||||
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
|
episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub);
|
||||||
|
|
||||||
// Music hubs render square album/artist artwork
|
|
||||||
final isSquareHub =
|
final isSquareHub =
|
||||||
_filteredItems.isNotEmpty &&
|
_filteredItems.isNotEmpty &&
|
||||||
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
|
_filteredItems.every((item) => item.cardShape(episodePosterMode) == CardShape.square);
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class AlphaJumpHelper {
|
|||||||
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
|
AlphaJumpHelper._(this.letters, this.letterToIndex, this.letterSizes, this.totalItemCount);
|
||||||
|
|
||||||
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
|
factory AlphaJumpHelper(List<LibraryFirstCharacter> firstCharacters, {bool descending = false}) {
|
||||||
// Collect characters with their sizes.
|
|
||||||
final entries = <({String letter, int size})>[];
|
final entries = <({String letter, int size})>[];
|
||||||
final letterSizes = <String, int>{};
|
final letterSizes = <String, int>{};
|
||||||
|
|
||||||
@@ -41,13 +40,11 @@ class AlphaJumpHelper {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-sort by DUCET collation to match the content endpoint's ICU sort order.
|
|
||||||
entries.sort((a, b) => ducetCompare(a.letter, b.letter));
|
entries.sort((a, b) => ducetCompare(a.letter, b.letter));
|
||||||
if (descending) {
|
if (descending) {
|
||||||
entries.setAll(0, entries.reversed.toList());
|
entries.setAll(0, entries.reversed.toList());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build cumulative index map in the corrected order.
|
|
||||||
final letters = <String>[];
|
final letters = <String>[];
|
||||||
final letterToIndex = <String, int>{};
|
final letterToIndex = <String, int>{};
|
||||||
int cumulative = 0;
|
int cumulative = 0;
|
||||||
|
|||||||
@@ -176,12 +176,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Loading state (only show loading indicator if items list is empty)
|
|
||||||
if (isLoading && items.isEmpty) {
|
if (isLoading && items.isEmpty) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error state (only show error if items list is empty)
|
|
||||||
if (errorMessage != null && items.isEmpty) {
|
if (errorMessage != null && items.isEmpty) {
|
||||||
return ErrorStateWidget(
|
return ErrorStateWidget(
|
||||||
message: errorMessage!,
|
message: errorMessage!,
|
||||||
@@ -191,12 +189,10 @@ class ContentStateBuilder<T> extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Empty state
|
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
|
return EmptyStateWidget(message: emptyMessage, icon: emptyIcon);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Content state - delegate to builder
|
|
||||||
return builder(items);
|
return builder(items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,7 +62,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
ItemUpdatable,
|
ItemUpdatable,
|
||||||
TickerProviderStateMixin,
|
TickerProviderStateMixin,
|
||||||
TabNavigationMixin {
|
TabNavigationMixin {
|
||||||
// GlobalKeys for tabs to enable refresh
|
|
||||||
final _recommendedTabKey = GlobalKey();
|
final _recommendedTabKey = GlobalKey();
|
||||||
final _browseTabKey = GlobalKey();
|
final _browseTabKey = GlobalKey();
|
||||||
final _collectionsTabKey = GlobalKey();
|
final _collectionsTabKey = GlobalKey();
|
||||||
@@ -84,7 +83,6 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
/// Key for the library dropdown menu button.
|
/// Key for the library dropdown menu button.
|
||||||
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
|
final _libraryDropdownKey = GlobalKey<AppMenuButtonState<String>>();
|
||||||
|
|
||||||
// Dynamic visible tabs and their focus nodes
|
|
||||||
List<LibraryTabType> _visibleTabs = LibraryTabType.values;
|
List<LibraryTabType> _visibleTabs = LibraryTabType.values;
|
||||||
List<FocusNode> _tabFocusNodes = List.generate(
|
List<FocusNode> _tabFocusNodes = List.generate(
|
||||||
LibraryTabType.values.length,
|
LibraryTabType.values.length,
|
||||||
@@ -94,10 +92,8 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
@override
|
@override
|
||||||
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
|
List<FocusNode> get tabChipFocusNodes => _tabFocusNodes;
|
||||||
|
|
||||||
// App bar action bar
|
|
||||||
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
final _actionBarKey = GlobalKey<FocusableActionBarState>();
|
||||||
|
|
||||||
// Scroll controller for the outer CustomScrollView
|
|
||||||
final ScrollController _outerScrollController = ScrollController();
|
final ScrollController _outerScrollController = ScrollController();
|
||||||
|
|
||||||
/// Reveal the floating header by jumping the outer NestedScrollView back
|
/// Reveal the floating header by jumping the outer NestedScrollView back
|
||||||
@@ -153,25 +149,20 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Compute visible libraries for initial load
|
|
||||||
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
final hiddenKeys = hiddenLibrariesProvider.hiddenLibraryKeys;
|
||||||
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
final visibleLibraries = allLibraries.where((lib) => !hiddenKeys.contains(lib.globalKey)).toList();
|
||||||
|
|
||||||
// Load saved preferences
|
|
||||||
final storage = await StorageService.getInstance();
|
final storage = await StorageService.getInstance();
|
||||||
final savedLibraryKey = storage.getSelectedLibraryKey();
|
final savedLibraryKey = storage.getSelectedLibraryKey();
|
||||||
|
|
||||||
// Find the library by key in visible libraries
|
|
||||||
String? libraryGlobalKeyToLoad;
|
String? libraryGlobalKeyToLoad;
|
||||||
if (savedLibraryKey != null) {
|
if (savedLibraryKey != null) {
|
||||||
// Check if saved library exists and is visible
|
|
||||||
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
|
final libraryExists = visibleLibraries.any((lib) => lib.globalKey == savedLibraryKey);
|
||||||
if (libraryExists) {
|
if (libraryExists) {
|
||||||
libraryGlobalKeyToLoad = savedLibraryKey;
|
libraryGlobalKeyToLoad = savedLibraryKey;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to first visible library if saved key not found
|
|
||||||
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
|
if (libraryGlobalKeyToLoad == null && visibleLibraries.isNotEmpty) {
|
||||||
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
|
libraryGlobalKeyToLoad = visibleLibraries.first.globalKey;
|
||||||
}
|
}
|
||||||
@@ -183,16 +174,12 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void onTabChanged() {
|
void onTabChanged() {
|
||||||
// Save tab name when changed (but not when restoring from storage)
|
|
||||||
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
|
if (_selectedLibraryGlobalKey != null && !tabController.indexIsChanging) {
|
||||||
// Only save if this was a user-initiated tab change, not a restore
|
|
||||||
if (!_isRestoringTab) {
|
if (!_isRestoringTab) {
|
||||||
StorageService.getInstance().then((storage) {
|
StorageService.getInstance().then((storage) {
|
||||||
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name);
|
storage.saveLibraryTab(_selectedLibraryGlobalKey!, _visibleTabs[tabController.index].name);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Focus first item in the current tab (only for user-initiated changes)
|
|
||||||
// But not when navigating via tab bar (suppressAutoFocus is true)
|
|
||||||
if (!suppressAutoFocus) {
|
if (!suppressAutoFocus) {
|
||||||
_focusCurrentTab();
|
_focusCurrentTab();
|
||||||
}
|
}
|
||||||
@@ -306,15 +293,11 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
|
|
||||||
/// Handle when a tab's data has finished loading
|
/// Handle when a tab's data has finished loading
|
||||||
void _handleTabDataLoaded(int tabIndex) {
|
void _handleTabDataLoaded(int tabIndex) {
|
||||||
// Track that this tab has loaded
|
|
||||||
_loadedTabs.add(tabIndex);
|
_loadedTabs.add(tabIndex);
|
||||||
|
|
||||||
// Don't auto-focus if suppressed (e.g., when navigating via tab bar)
|
|
||||||
if (suppressAutoFocus) return;
|
if (suppressAutoFocus) return;
|
||||||
|
|
||||||
// Only focus if this is the currently active tab
|
|
||||||
if (tabController.index == tabIndex && mounted) {
|
if (tabController.index == tabIndex && mounted) {
|
||||||
// Use post-frame callback to ensure the widget tree is fully built
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
|
if (mounted && tabController.index == tabIndex && !suppressAutoFocus) {
|
||||||
_focusCurrentTab();
|
_focusCurrentTab();
|
||||||
@@ -352,21 +335,17 @@ class _LibrariesScreenState extends State<LibrariesScreen>
|
|||||||
void _updateVisibleTabs(List<LibraryTabType> newTabs) {
|
void _updateVisibleTabs(List<LibraryTabType> newTabs) {
|
||||||
if (listEquals(_visibleTabs, newTabs)) return;
|
if (listEquals(_visibleTabs, newTabs)) return;
|
||||||
|
|
||||||
// Save current tab type before changing
|
|
||||||
final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
|
final currentTabType = _visibleTabs.length > tabController.index ? _visibleTabs[tabController.index] : null;
|
||||||
|
|
||||||
// Dispose old focus nodes and controller
|
|
||||||
for (final node in _tabFocusNodes) {
|
for (final node in _tabFocusNodes) {
|
||||||
node.dispose();
|
node.dispose();
|
||||||
}
|
}
|
||||||
disposeTabNavigation();
|
disposeTabNavigation();
|
||||||
|
|
||||||
// Build new
|
|
||||||
_visibleTabs = newTabs;
|
_visibleTabs = newTabs;
|
||||||
_tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
|
_tabFocusNodes = List.generate(newTabs.length, (i) => FocusNode(debugLabel: 'tab_chip_${newTabs[i].name}'));
|
||||||
initTabNavigation();
|
initTabNavigation();
|
||||||
|
|
||||||
// Restore tab position: find current tab type in new set, default to first
|
|
||||||
final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1;
|
final newIndex = currentTabType != null ? newTabs.indexOf(currentTabType) : -1;
|
||||||
if (newIndex > 0) {
|
if (newIndex > 0) {
|
||||||
tabController.index = newIndex;
|
tabController.index = newIndex;
|
||||||
|
|||||||
@@ -49,7 +49,6 @@ class _SortBottomSheetState extends State<SortBottomSheet> {
|
|||||||
_currentDescending = widget.isSortDescending;
|
_currentDescending = widget.isSortDescending;
|
||||||
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
|
_initialFocusNode = FocusNode(debugLabel: 'SortBottomSheetInitialFocus');
|
||||||
|
|
||||||
// Scroll to selected item, then handle focus
|
|
||||||
final selectedIndex = widget.selectedSort != null
|
final selectedIndex = widget.selectedSort != null
|
||||||
? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key)
|
? widget.sortOptions.indexWhere((s) => s.key == widget.selectedSort!.key)
|
||||||
: -1;
|
: -1;
|
||||||
|
|||||||
@@ -78,14 +78,14 @@ import '../watch_together/watch_together.dart';
|
|||||||
// browse rail can import the scope without an import cycle through this file.
|
// browse rail can import the scope without an import cycle through this file.
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
bool shouldHandleMacOsRootEscape({
|
bool shouldHandleDesktopRootEscape({
|
||||||
required bool isMacOS,
|
required bool isDesktop,
|
||||||
required bool isPhysicalKeyboardEvent,
|
required bool isPhysicalKeyboardEvent,
|
||||||
required LogicalKeyboardKey logicalKey,
|
required LogicalKeyboardKey logicalKey,
|
||||||
required bool isCurrentRoute,
|
required bool isCurrentRoute,
|
||||||
required bool isHomeTab,
|
required bool isHomeTab,
|
||||||
}) {
|
}) {
|
||||||
return isMacOS && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab;
|
return isDesktop && isPhysicalKeyboardEvent && logicalKey == LogicalKeyboardKey.escape && isCurrentRoute && isHomeTab;
|
||||||
}
|
}
|
||||||
|
|
||||||
@visibleForTesting
|
@visibleForTesting
|
||||||
@@ -1348,13 +1348,16 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
return KeyEventResult.handled;
|
return KeyEventResult.handled;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// On macOS, native fullscreen is window state shared by every route.
|
/// Desktop physical-keyboard Escape at root Home is reserved for leaving
|
||||||
/// Player Escape therefore leaves it alone; only root Home owns the
|
/// window fullscreen; it never arms the press-back-again quit, so an Escape
|
||||||
/// conventional Escape-to-leave-fullscreen behavior.
|
/// aimed at fullscreen can't close the app (#1748). Remotes, gamepad B, and
|
||||||
KeyEventResult _handleMacOsRootEscape(KeyEvent event) {
|
/// 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 tabs = _getVisibleTabs(_isOffline);
|
||||||
final shouldHandle = shouldHandleMacOsRootEscape(
|
final shouldHandle = shouldHandleDesktopRootEscape(
|
||||||
isMacOS: Platform.isMacOS,
|
isDesktop: PlatformDetector.isDesktopOS(),
|
||||||
isPhysicalKeyboardEvent: event.isPhysicalKeyboardEvent,
|
isPhysicalKeyboardEvent: event.isPhysicalKeyboardEvent,
|
||||||
logicalKey: event.logicalKey,
|
logicalKey: event.logicalKey,
|
||||||
isCurrentRoute: ModalRoute.of(context)?.isCurrent == true,
|
isCurrentRoute: ModalRoute.of(context)?.isCurrent == true,
|
||||||
@@ -1750,7 +1753,7 @@ class _MainScreenState extends State<MainScreen>
|
|||||||
canPop: false,
|
canPop: false,
|
||||||
child: Focus(
|
child: Focus(
|
||||||
onKeyEvent: (node, event) {
|
onKeyEvent: (node, event) {
|
||||||
final rootEscapeResult = _handleMacOsRootEscape(event);
|
final rootEscapeResult = _handleDesktopRootEscape(event);
|
||||||
if (rootEscapeResult == KeyEventResult.handled) return rootEscapeResult;
|
if (rootEscapeResult == KeyEventResult.handled) return rootEscapeResult;
|
||||||
final fullscreenResult = _handleFullscreenShortcut(event);
|
final fullscreenResult = _handleFullscreenShortcut(event);
|
||||||
if (fullscreenResult == KeyEventResult.handled) return fullscreenResult;
|
if (fullscreenResult == KeyEventResult.handled) return fullscreenResult;
|
||||||
|
|||||||
@@ -23,12 +23,11 @@ class PlaylistItemCard extends StatefulWidget {
|
|||||||
final VoidCallback? onRemove;
|
final VoidCallback? onRemove;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final void Function(MediaItem source)? onRefresh;
|
final void Function(MediaItem source)? onRefresh;
|
||||||
final bool canReorder; // Whether drag handle should be shown
|
final bool canReorder;
|
||||||
|
|
||||||
// Focus state for keyboard/D-pad navigation
|
|
||||||
final bool isFocused;
|
final bool isFocused;
|
||||||
final int? focusedColumn; // 0=row, 1=drag handle, 2=remove button
|
final int? focusedColumn;
|
||||||
final bool isMoving; // Whether this item is being moved/reordered
|
final bool isMoving;
|
||||||
|
|
||||||
const PlaylistItemCard({
|
const PlaylistItemCard({
|
||||||
super.key,
|
super.key,
|
||||||
@@ -56,20 +55,16 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
|||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
final textMuted = tokens(context).textMuted;
|
final textMuted = tokens(context).textMuted;
|
||||||
|
|
||||||
// Determine if row is focused (main content area)
|
|
||||||
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
|
final isRowFocused = widget.isFocused && widget.focusedColumn == 0;
|
||||||
|
|
||||||
// Focus states for individual elements
|
|
||||||
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
|
final isDragHandleFocused = widget.isFocused && widget.focusedColumn == 1;
|
||||||
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
|
final isRemoveButtonFocused = widget.isFocused && widget.focusedColumn == 2;
|
||||||
|
|
||||||
// Determine card styling based on focus/move state
|
|
||||||
Color? cardColor;
|
Color? cardColor;
|
||||||
ShapeBorder? cardShape;
|
ShapeBorder? cardShape;
|
||||||
if (widget.isMoving) {
|
if (widget.isMoving) {
|
||||||
cardColor = colorScheme.primaryContainer;
|
cardColor = colorScheme.primaryContainer;
|
||||||
} else if (isRowFocused) {
|
} else if (isRowFocused) {
|
||||||
// Row is focused - use visible border like FocusableWrapper
|
|
||||||
cardColor = colorScheme.surfaceContainerHighest;
|
cardColor = colorScheme.surfaceContainerHighest;
|
||||||
cardShape = RoundedRectangleBorder(
|
cardShape = RoundedRectangleBorder(
|
||||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||||
@@ -126,18 +121,15 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
// Poster thumbnail
|
|
||||||
_buildPosterImage(context, item),
|
_buildPosterImage(context, item),
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Title and metadata
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
children: [
|
children: [
|
||||||
// Title
|
|
||||||
Text(
|
Text(
|
||||||
item.displayTitle,
|
item.displayTitle,
|
||||||
style: const TextStyle(fontSize: 15, fontWeight: .w500),
|
style: const TextStyle(fontSize: 15, fontWeight: .w500),
|
||||||
@@ -147,7 +139,6 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
|||||||
|
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
|
|
||||||
// Subtitle (episode info or type)
|
|
||||||
Text(
|
Text(
|
||||||
_buildSubtitle(item),
|
_buildSubtitle(item),
|
||||||
style: TextStyle(fontSize: 13, color: textMuted),
|
style: TextStyle(fontSize: 13, color: textMuted),
|
||||||
@@ -160,13 +151,11 @@ class _PlaylistItemCardState extends State<PlaylistItemCard> with ContextMenuTap
|
|||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Duration
|
|
||||||
if (item.durationMs != null)
|
if (item.durationMs != null)
|
||||||
Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)),
|
Text(formatDurationTextual(item.durationMs!), style: TextStyle(fontSize: 13, color: textMuted)),
|
||||||
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Remove button
|
|
||||||
Container(
|
Container(
|
||||||
decoration: isRemoveButtonFocused
|
decoration: isRemoveButtonFocused
|
||||||
? BoxDecoration(
|
? BoxDecoration(
|
||||||
|
|||||||
@@ -29,7 +29,6 @@ class AboutScreen extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// App Icon and Name
|
|
||||||
Center(
|
Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
@@ -54,7 +53,6 @@ class AboutScreen extends StatelessWidget {
|
|||||||
|
|
||||||
const SizedBox(height: 40),
|
const SizedBox(height: 40),
|
||||||
|
|
||||||
// Open Source Licenses
|
|
||||||
SettingsGroup(
|
SettingsGroup(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -114,7 +114,6 @@ class _LicenseDetailScreen extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
sliver: SliverList(
|
sliver: SliverList(
|
||||||
delegate: SliverChildListDelegate([
|
delegate: SliverChildListDelegate([
|
||||||
// Package info card
|
|
||||||
if (mergedLicense.allPackageNames.length > 1)
|
if (mergedLicense.allPackageNames.length > 1)
|
||||||
Card(
|
Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
@@ -134,7 +133,6 @@ class _LicenseDetailScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16),
|
if (mergedLicense.allPackageNames.length > 1) const SizedBox(height: 16),
|
||||||
|
|
||||||
// License cards
|
|
||||||
...licenseEntries.asMap().entries.map((entry) {
|
...licenseEntries.asMap().entries.map((entry) {
|
||||||
final index = entry.key;
|
final index = entry.key;
|
||||||
final license = entry.value;
|
final license = entry.value;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'dart:math' as math;
|
import 'dart:math' as math;
|
||||||
import '../../providers/playback_state_provider.dart';
|
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
|
/// 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.
|
/// player EOF signal to count as the real end of the media.
|
||||||
@@ -28,6 +29,54 @@ CompletionNavigationAction completionNavigationAction({
|
|||||||
return CompletionNavigationAction.exit;
|
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.
|
/// 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
|
/// 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 (!mounted) return;
|
||||||
if (_nextEpisode == null || _isLoadingNext) 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();
|
_autoPlayTimer?.cancel();
|
||||||
_unfocusPlayNextPrompt();
|
_unfocusPlayNextPrompt();
|
||||||
_dismissStillWatching();
|
_dismissStillWatching();
|
||||||
@@ -64,7 +71,10 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
_showPlayNextDialog = false;
|
_showPlayNextDialog = false;
|
||||||
});
|
});
|
||||||
|
|
||||||
await _navigateToEpisode(_nextEpisode!);
|
final outcome = await _navigateToEpisode(_nextEpisode!);
|
||||||
|
if (outcome == _MediaReloadOutcome.failed) {
|
||||||
|
_presentPlayNextRetryPrompt(wasAtCompletion: wasAtCompletion);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _playPrevious() async {
|
Future<void> _playPrevious() async {
|
||||||
@@ -132,11 +142,17 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Navigates to a new episode by reusing the current player whenever possible.
|
/// 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;
|
final currentPlayer = player;
|
||||||
if (currentPlayer == null) {
|
if (currentPlayer == null) {
|
||||||
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
|
if (mounted) unawaited(_replaceScreenWithPlayer(episodeMetadata));
|
||||||
return;
|
return _MediaReloadOutcome.rejected;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Callers fire this without awaiting (auto-play countdown, PiP, the prompt), so an escaping
|
// 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,
|
nativeTrack: currentPlayer.state.track.secondarySubtitle,
|
||||||
sessionPreference: _sessionSecondarySubtitlePreference,
|
sessionPreference: _sessionSecondarySubtitlePreference,
|
||||||
);
|
);
|
||||||
await _reloadMediaInPlace(
|
return await _reloadMediaInPlace(
|
||||||
metadata: episodeMetadata,
|
metadata: episodeMetadata,
|
||||||
selectedMediaIndex: _effectiveSelectedMediaIndex,
|
selectedMediaIndex: _effectiveSelectedMediaIndex,
|
||||||
selectedMediaSourceId: null,
|
selectedMediaSourceId: null,
|
||||||
@@ -193,6 +209,7 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
appLogger.e('Failed to navigate to the next item', error: e, stackTrace: stackTrace);
|
appLogger.e('Failed to navigate to the next item', error: e, stackTrace: stackTrace);
|
||||||
_clearEpisodeLoadingFlags();
|
_clearEpisodeLoadingFlags();
|
||||||
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
if (mounted) showErrorSnackBar(context, t.messages.errorLoading(error: e.toString()));
|
||||||
|
return _MediaReloadOutcome.failed;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -695,7 +712,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
preferredSubtitleTrack: initializationSubtitleTrack,
|
preferredSubtitleTrack: initializationSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
transcodeSessionId: _playbackTranscodeSessionId,
|
||||||
transcodeOffset: openResumePosition,
|
|
||||||
),
|
),
|
||||||
offlineLibraryMode: _offlineLibraryMode,
|
offlineLibraryMode: _offlineLibraryMode,
|
||||||
);
|
);
|
||||||
@@ -801,12 +817,6 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
externalSubtitles: subtitleSelection.sidecarsAtOpen,
|
externalSubtitles: subtitleSelection.sidecarsAtOpen,
|
||||||
);
|
);
|
||||||
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
|
var effectiveExternalSubtitlePlan = externalSubtitlePlan;
|
||||||
await _awaitTranscodeReadiness(
|
|
||||||
client: mediaClient,
|
|
||||||
isTranscoding: result.isTranscoding,
|
|
||||||
videoUrl: result.videoUrl!,
|
|
||||||
);
|
|
||||||
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
|
||||||
final openResult = await _openMediaOnPlayer(
|
final openResult = await _openMediaOnPlayer(
|
||||||
player: currentPlayer,
|
player: currentPlayer,
|
||||||
settingsService: settingsService,
|
settingsService: settingsService,
|
||||||
@@ -946,6 +956,9 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
_nextEpisode = null;
|
_nextEpisode = null;
|
||||||
_previousEpisode = null;
|
_previousEpisode = null;
|
||||||
_nextEpisodeStatus = QueueNavigationStatus.failed;
|
_nextEpisodeStatus = QueueNavigationStatus.failed;
|
||||||
|
// A successful swap restores the transient-retry budget for the
|
||||||
|
// next transition (#1867).
|
||||||
|
_playNextTransientRetryCount = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -963,6 +976,11 @@ extension _VideoPlayerEpisodeNavigationMethods on VideoPlayerScreenState {
|
|||||||
return _MediaReloadOutcome.opened;
|
return _MediaReloadOutcome.opened;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!isCurrentReload()) return _MediaReloadOutcome.superseded;
|
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();
|
_completionLatch.reset();
|
||||||
if (!didOpenReplacement) {
|
if (!didOpenReplacement) {
|
||||||
// Nothing was opened: the previous session is still committed, so
|
// Nothing was opened: the previous session is still committed, so
|
||||||
|
|||||||
@@ -149,5 +149,34 @@ extension _VideoPlayerEpisodeQueueMethods on VideoPlayerScreenState {
|
|||||||
_previousEpisode = adjacentEpisodes.previous;
|
_previousEpisode = adjacentEpisodes.previous;
|
||||||
_nextEpisodeStatus = adjacentEpisodes.nextStatus;
|
_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
|
/// 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
|
/// 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
|
/// 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
|
/// 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
|
/// open. On Android with ExoPlayer active they are stashed natively and
|
||||||
/// replayed on the exo→mpv fallback, so keep them unconditional.
|
/// replayed on the exo→mpv fallback, so keep them unconditional.
|
||||||
@@ -579,6 +579,25 @@ extension _VideoPlayerOpenMethods on VideoPlayerScreenState {
|
|||||||
await player.setProperty('stream-lavf-o', '');
|
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;
|
int? ringBytes;
|
||||||
if (isNetworkVod && !isTranscoding) {
|
if (isNetworkVod && !isTranscoding) {
|
||||||
// Transcode (HLS) playback only uses the mpv stream layer for the
|
// 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}');
|
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.
|
/// Open [videoUrl] on [player]: stream tuning → open → native subtitle style.
|
||||||
///
|
///
|
||||||
/// [shouldContinue] is re-checked between the awaits so stale generations
|
/// [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() {
|
void _cancelAutoPlay() {
|
||||||
_autoPlayTimer?.cancel();
|
_autoPlayTimer?.cancel();
|
||||||
_unfocusPlayNextPrompt();
|
_unfocusPlayNextPrompt();
|
||||||
|
|||||||
@@ -261,11 +261,6 @@ extension _VideoPlayerPlaybackStartMethods on VideoPlayerScreenState {
|
|||||||
resumePosition: resumePosition,
|
resumePosition: resumePosition,
|
||||||
durationMs: _currentMetadata.durationMs,
|
durationMs: _currentMetadata.durationMs,
|
||||||
);
|
);
|
||||||
await _awaitTranscodeReadiness(
|
|
||||||
client: playbackContext.reportingClient,
|
|
||||||
isTranscoding: result.isTranscoding,
|
|
||||||
videoUrl: result.videoUrl!,
|
|
||||||
);
|
|
||||||
if (!attempt.isCurrent) return;
|
if (!attempt.isCurrent) return;
|
||||||
final openResult = await _openMediaOnPlayer(
|
final openResult = await _openMediaOnPlayer(
|
||||||
player: currentPlayer,
|
player: currentPlayer,
|
||||||
|
|||||||
@@ -75,7 +75,6 @@ import '../providers/shader_provider.dart';
|
|||||||
import '../providers/user_profile_provider.dart';
|
import '../providers/user_profile_provider.dart';
|
||||||
import '../utils/app_logger.dart';
|
import '../utils/app_logger.dart';
|
||||||
import '../utils/dialogs.dart';
|
import '../utils/dialogs.dart';
|
||||||
import '../utils/media_server_http_client.dart' show AbortController;
|
|
||||||
import '../utils/log_redaction_manager.dart';
|
import '../utils/log_redaction_manager.dart';
|
||||||
import '../utils/live_tv_player_navigation.dart';
|
import '../utils/live_tv_player_navigation.dart';
|
||||||
import '../utils/player_utils.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
|
// Retryable sentinel until the fire-and-forget initial adjacency load
|
||||||
// commits found, boundary, or unavailable.
|
// commits found, boundary, or unavailable.
|
||||||
QueueNavigationStatus _nextEpisodeStatus = QueueNavigationStatus.failed;
|
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 _isResolvingCompletionAdjacency = false;
|
||||||
bool _isLoadingNext = false;
|
bool _isLoadingNext = false;
|
||||||
bool _isLoadingPrevious = false;
|
bool _isLoadingPrevious = false;
|
||||||
@@ -473,9 +475,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
Completer<void>? _playbackTransitionIdleCompleter;
|
Completer<void>? _playbackTransitionIdleCompleter;
|
||||||
bool _playbackIntentShouldPlay = true;
|
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;
|
int _pendingSubtitleCycleCount = 0;
|
||||||
bool _subtitleCycleDrainActive = false;
|
bool _subtitleCycleDrainActive = false;
|
||||||
|
|
||||||
@@ -572,6 +571,15 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
Timer? _autoPlayTimer;
|
Timer? _autoPlayTimer;
|
||||||
int _autoPlayCountdown = 5;
|
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;
|
// 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.
|
// position ticks only re-arm once playback is more than 2s from the end.
|
||||||
final CompletionLatch _completionLatch = CompletionLatch(rearmWindowMs: 2000);
|
final CompletionLatch _completionLatch = CompletionLatch(rearmWindowMs: 2000);
|
||||||
@@ -1307,13 +1315,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
preferredSubtitleTrack: _preferredSubtitleTrack,
|
preferredSubtitleTrack: _preferredSubtitleTrack,
|
||||||
sessionIdentifier: _playbackSessionIdentifier,
|
sessionIdentifier: _playbackSessionIdentifier,
|
||||||
transcodeSessionId: _playbackTranscodeSessionId,
|
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,
|
offlineLibraryMode: false,
|
||||||
);
|
);
|
||||||
@@ -1828,7 +1829,6 @@ class VideoPlayerScreenState extends State<VideoPlayerScreen> with WidgetsBindin
|
|||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
|
unawaited(AndroidExitDiagnostics.markUiState(AndroidUiState.mainScreen));
|
||||||
_transcodeReadinessAbort?.abort();
|
|
||||||
_playerInitializationGeneration++;
|
_playerInitializationGeneration++;
|
||||||
_frameRate.dispose();
|
_frameRate.dispose();
|
||||||
WidgetsBinding.instance.removeObserver(this);
|
WidgetsBinding.instance.removeObserver(this);
|
||||||
|
|||||||
@@ -43,10 +43,8 @@ class AmbientLightingService {
|
|||||||
|
|
||||||
appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
|
appLogger.d('AmbientLightingService: Shader path: $_shaderPath');
|
||||||
|
|
||||||
// Set video-aspect-override to fill the entire output area
|
|
||||||
await _player.setProperty('video-aspect-override', outputAspect.toString());
|
await _player.setProperty('video-aspect-override', outputAspect.toString());
|
||||||
|
|
||||||
// Append ambient lighting shader
|
|
||||||
await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]);
|
await _player.command(['change-list', 'glsl-shaders', 'append', _shaderPath!]);
|
||||||
|
|
||||||
_enabled = true;
|
_enabled = true;
|
||||||
|
|||||||
@@ -139,7 +139,6 @@ class DiscordRPCService {
|
|||||||
_playbackSpeed = 1.0;
|
_playbackSpeed = 1.0;
|
||||||
|
|
||||||
if (_isEnabled && _isConnected) {
|
if (_isEnabled && _isConnected) {
|
||||||
// Upload thumbnail in background, don't block playback
|
|
||||||
unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client));
|
unawaited(_uploadThumbnailAndUpdatePresence(revision, metadata, client));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,9 +147,7 @@ class DiscordRPCService {
|
|||||||
void updatePosition(Duration position) {
|
void updatePosition(Duration position) {
|
||||||
final isSeek = _timeline.updatePosition(position);
|
final isSeek = _timeline.updatePosition(position);
|
||||||
|
|
||||||
// Update presence if position jumped significantly (seek detected)
|
|
||||||
if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) {
|
if (_isEnabled && _isConnected && _playbackStartTime != null && isSeek) {
|
||||||
// Throttle updates to max once per second
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) {
|
if (_lastPresenceUpdate == null || now.difference(_lastPresenceUpdate!) > const Duration(seconds: 1)) {
|
||||||
_lastPresenceUpdate = now;
|
_lastPresenceUpdate = now;
|
||||||
@@ -172,7 +169,6 @@ class DiscordRPCService {
|
|||||||
Future<void> resumePlayback() async {
|
Future<void> resumePlayback() async {
|
||||||
if (_currentMetadata == null) return;
|
if (_currentMetadata == null) return;
|
||||||
|
|
||||||
// Reset start time for elapsed time display
|
|
||||||
_playbackStartTime = DateTime.now();
|
_playbackStartTime = DateTime.now();
|
||||||
|
|
||||||
if (_isEnabled && _isConnected) {
|
if (_isEnabled && _isConnected) {
|
||||||
@@ -182,7 +178,6 @@ class DiscordRPCService {
|
|||||||
|
|
||||||
/// Pause - clear timestamp but keep showing what's playing
|
/// Pause - clear timestamp but keep showing what's playing
|
||||||
Future<void> pausePlayback() async {
|
Future<void> pausePlayback() async {
|
||||||
// Clear start time so Discord stops counting
|
|
||||||
_playbackStartTime = null;
|
_playbackStartTime = null;
|
||||||
|
|
||||||
if (_isEnabled && _isConnected) {
|
if (_isEnabled && _isConnected) {
|
||||||
@@ -309,12 +304,9 @@ class DiscordRPCService {
|
|||||||
|
|
||||||
Future<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async {
|
Future<String?> _uploadThumbnail(MediaItem metadata, MediaServerClient client) async {
|
||||||
try {
|
try {
|
||||||
// Get the thumbnail path (prefer show poster for episodes)
|
|
||||||
final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath;
|
final thumbPath = metadata.grandparentThumbPath ?? metadata.thumbPath;
|
||||||
if (thumbPath == null || thumbPath.isEmpty) return null;
|
if (thumbPath == null || thumbPath.isEmpty) return null;
|
||||||
|
|
||||||
// Check cache first (with expiry check). Key by backend so the same
|
|
||||||
// path on Plex and Jellyfin doesn't collide.
|
|
||||||
final cacheKey = '${client.backend.id}:$thumbPath';
|
final cacheKey = '${client.backend.id}:$thumbPath';
|
||||||
final cached = _posterUrlCache[cacheKey];
|
final cached = _posterUrlCache[cacheKey];
|
||||||
if (cached != null && !cached.isExpired) {
|
if (cached != null && !cached.isExpired) {
|
||||||
|
|||||||
@@ -192,25 +192,20 @@ class GamepadService with WindowListener {
|
|||||||
_tabNavigationHandlers.clear();
|
_tabNavigationHandlers.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deadzone for analog sticks (0.0 to 1.0)
|
|
||||||
static const double _stickDeadzone = 0.5;
|
static const double _stickDeadzone = 0.5;
|
||||||
|
|
||||||
// Auto-repeat timing for held directional inputs (D-pad / stick)
|
|
||||||
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
|
static const Duration _repeatInitialDelay = Duration(milliseconds: 400);
|
||||||
static const Duration _repeatInterval = Duration(milliseconds: 80);
|
static const Duration _repeatInterval = Duration(milliseconds: 80);
|
||||||
|
|
||||||
key_sim.KeyEventSimulatorController? _keyEventSimulator;
|
key_sim.KeyEventSimulatorController? _keyEventSimulator;
|
||||||
|
|
||||||
// Track stick state to detect deadzone crossings
|
|
||||||
bool _leftStickUp = false;
|
bool _leftStickUp = false;
|
||||||
bool _leftStickDown = false;
|
bool _leftStickDown = false;
|
||||||
bool _leftStickLeft = false;
|
bool _leftStickLeft = false;
|
||||||
bool _leftStickRight = false;
|
bool _leftStickRight = false;
|
||||||
|
|
||||||
// Track button states to prevent repeated events from button holds
|
|
||||||
final Set<GamepadButton> _pressedButtons = {};
|
final Set<GamepadButton> _pressedButtons = {};
|
||||||
final Set<GamepadButton> _suppressedButtons = {};
|
final Set<GamepadButton> _suppressedButtons = {};
|
||||||
// Whether the app window is currently focused — ignore gamepad input when false
|
|
||||||
bool _windowFocused = true;
|
bool _windowFocused = true;
|
||||||
bool _nativeKeyHandlerRegistered = false;
|
bool _nativeKeyHandlerRegistered = false;
|
||||||
bool _nativeTextInputFocused = false;
|
bool _nativeTextInputFocused = false;
|
||||||
|
|||||||
@@ -758,20 +758,39 @@ mixin _JellyfinBrowseMethods on _JellyfinClientInternals {
|
|||||||
return _mapItem(data);
|
return _mapItem(data);
|
||||||
} on MediaServerHttpException catch (e) {
|
} on MediaServerHttpException catch (e) {
|
||||||
if (e.statusCode == 404) return null;
|
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;
|
rethrow;
|
||||||
} catch (e) {
|
} 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);
|
appLogger.w('JellyfinClient.fetchItem network call failed', error: e);
|
||||||
try {
|
final cached = await _cachedItemFallback(endpoint);
|
||||||
final cached = await cache.get(ServerId(cacheServerId), endpoint);
|
if (cached != null) return cached;
|
||||||
if (cached is Map<String, dynamic>) return _mapItem(cached);
|
|
||||||
} catch (cacheError, st) {
|
|
||||||
appLogger.w('JellyfinClient.fetchItem cache fallback failed', error: cacheError, stackTrace: st);
|
|
||||||
}
|
|
||||||
rethrow;
|
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
|
@override
|
||||||
Future<List<MediaItem>> fetchChildren(String parentId) => _fetchChildrenInternal(parentId);
|
Future<List<MediaItem>> fetchChildren(String parentId) => _fetchChildrenInternal(parentId);
|
||||||
|
|
||||||
|
|||||||
@@ -500,6 +500,13 @@ mixin _JellyfinPlaybackMethods on _JellyfinClientInternals {
|
|||||||
externalSubtitles.add(
|
externalSubtitles.add(
|
||||||
PlaybackSubtitleSidecar(
|
PlaybackSubtitleSidecar(
|
||||||
sourceStreamId: track.id,
|
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(
|
track: SubtitleTrack.uri(
|
||||||
url,
|
url,
|
||||||
title:
|
title:
|
||||||
|
|||||||
@@ -250,16 +250,13 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
if (!isMetaPressed) modifiersMatch = false;
|
if (!isMetaPressed) modifiersMatch = false;
|
||||||
break;
|
break;
|
||||||
case HotKeyModifier.capsLock:
|
case HotKeyModifier.capsLock:
|
||||||
// CapsLock is typically not used for shortcuts, ignore for now
|
|
||||||
break;
|
break;
|
||||||
case HotKeyModifier.fn:
|
case HotKeyModifier.fn:
|
||||||
// Fn key is typically not used for shortcuts, ignore for now
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (!modifiersMatch) break;
|
if (!modifiersMatch) break;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check that no extra modifiers are pressed
|
|
||||||
if (modifiersMatch) {
|
if (modifiersMatch) {
|
||||||
final hasShift = requiredModifiers.contains(HotKeyModifier.shift);
|
final hasShift = requiredModifiers.contains(HotKeyModifier.shift);
|
||||||
final hasControl = requiredModifiers.contains(HotKeyModifier.control);
|
final hasControl = requiredModifiers.contains(HotKeyModifier.control);
|
||||||
@@ -372,7 +369,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge);
|
return shortcut.label(seekTimeSmall: _seekTimeSmall, seekTimeLarge: _seekTimeLarge);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if a hotkey is already assigned to another action
|
|
||||||
String? getActionForHotkey(HotKey hotkey) {
|
String? getActionForHotkey(HotKey hotkey) {
|
||||||
for (final entry in _hotkeys.entries) {
|
for (final entry in _hotkeys.entries) {
|
||||||
final assignedHotkey = entry.value;
|
final assignedHotkey = entry.value;
|
||||||
@@ -383,7 +379,6 @@ class KeyboardShortcutsService extends ChangeNotifier {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper method to compare two HotKey objects
|
|
||||||
bool _hotkeyEquals(HotKey a, HotKey b) {
|
bool _hotkeyEquals(HotKey a, HotKey b) {
|
||||||
if (a.key != b.key) return false;
|
if (a.key != b.key) return false;
|
||||||
|
|
||||||
|
|||||||
@@ -246,6 +246,9 @@ class PlaybackInitializationService {
|
|||||||
subtitles.add(
|
subtitles.add(
|
||||||
PlaybackSubtitleSidecar(
|
PlaybackSubtitleSidecar(
|
||||||
sourceStreamId: trackId,
|
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(
|
track: SubtitleTrack.uri(
|
||||||
Uri.file(entity.path).toString(),
|
Uri.file(entity.path).toString(),
|
||||||
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? t.videoControls.subtitleFile(name: fileName),
|
title: cachedTrack?.displayTitle ?? cachedTrack?.language ?? t.videoControls.subtitleFile(name: fileName),
|
||||||
|
|||||||
@@ -61,13 +61,6 @@ class PlaybackInitializationOptions {
|
|||||||
/// for Plex transcode.
|
/// for Plex transcode.
|
||||||
final String? transcodeSessionId;
|
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({
|
const PlaybackInitializationOptions({
|
||||||
required this.metadata,
|
required this.metadata,
|
||||||
required this.selectedMediaIndex,
|
required this.selectedMediaIndex,
|
||||||
@@ -80,7 +73,6 @@ class PlaybackInitializationOptions {
|
|||||||
this.preferredSubtitleTrack,
|
this.preferredSubtitleTrack,
|
||||||
this.sessionIdentifier,
|
this.sessionIdentifier,
|
||||||
this.transcodeSessionId,
|
this.transcodeSessionId,
|
||||||
this.transcodeOffset,
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+129
-218
@@ -1,7 +1,6 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import '../utils/isolate_helper.dart';
|
import '../utils/isolate_helper.dart';
|
||||||
import '../utils/json_utils.dart';
|
import '../utils/json_utils.dart';
|
||||||
import 'package:clock/clock.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:uuid/uuid.dart';
|
import 'package:uuid/uuid.dart';
|
||||||
@@ -86,15 +85,51 @@ part 'plex_client/parts/metadata_edit.dart';
|
|||||||
const _plexVideoTranscodeBaseEndpoint = '/video/:/transcode/universal';
|
const _plexVideoTranscodeBaseEndpoint = '/video/:/transcode/universal';
|
||||||
const _plexVideoHlsStartEndpoint = '$_plexVideoTranscodeBaseEndpoint/start.m3u8';
|
const _plexVideoHlsStartEndpoint = '$_plexVideoTranscodeBaseEndpoint/start.m3u8';
|
||||||
const _plexVideoHlsProtocol = 'hls';
|
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'
|
'add-transcode-target(type=videoProfile&context=streaming'
|
||||||
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
|
'&protocol=hls&container=mpegts&videoCodec=h264%2Chevc%2Cmpeg2video'
|
||||||
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
|
'&audioCodec=aac%2Cac3%2Ceac3%2Cmp3)';
|
||||||
|
|
||||||
const _plexHlsSubtitleTranscodeTarget =
|
const _plexHlsSubtitleTranscodeTarget =
|
||||||
'add-transcode-target(type=subtitleProfile&context=streaming'
|
'add-transcode-target(type=subtitleProfile&context=streaming'
|
||||||
'&protocol=hls&container=webvtt&subtitleCodec=webvtt)';
|
'&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)'];
|
final clauses = <String>['add-settings(DirectPlayStreamSelection=true)'];
|
||||||
if (maxVideoBitrateKbps != null) {
|
if (maxVideoBitrateKbps != null) {
|
||||||
clauses.add(
|
clauses.add(
|
||||||
@@ -103,7 +138,7 @@ String _buildPlexHlsClientProfileExtra({int? maxVideoBitrateKbps}) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
clauses
|
clauses
|
||||||
..add(_plexHlsVideoTranscodeTarget)
|
..add(videoTranscodeTarget)
|
||||||
..add(_plexHlsSubtitleTranscodeTarget);
|
..add(_plexHlsSubtitleTranscodeTarget);
|
||||||
return clauses.join('+');
|
return clauses.join('+');
|
||||||
}
|
}
|
||||||
@@ -2612,6 +2647,16 @@ class PlexClient
|
|||||||
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
/// [transcodeSessionId] and [sessionIdentifier] should be reused across
|
||||||
/// seeks + quality/version/audio switches within one playback so the
|
/// seeks + quality/version/audio switches within one playback so the
|
||||||
/// server-side transcode session is preserved.
|
/// 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({
|
Future<({String? startPath, TranscodeDecisionOutcome outcome})> buildTranscodeStartPath({
|
||||||
required String ratingKey,
|
required String ratingKey,
|
||||||
required int mediaIndex,
|
required int mediaIndex,
|
||||||
@@ -2620,13 +2665,12 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
int? partId,
|
int? partId,
|
||||||
}) async {
|
}) async {
|
||||||
try {
|
try {
|
||||||
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
|
await selectSubtitleStreamForBurn(partId: partId, track: selectedSubtitleTrack);
|
||||||
final allParams = _buildTranscodeParams(
|
Map<String, String> paramsFor({required bool useTsFallbackTarget}) => _buildTranscodeParams(
|
||||||
ratingKey: ratingKey,
|
ratingKey: ratingKey,
|
||||||
mediaIndex: mediaIndex,
|
mediaIndex: mediaIndex,
|
||||||
partIndex: partIndex,
|
partIndex: partIndex,
|
||||||
@@ -2634,219 +2678,42 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
|
useTsFallbackTarget: useTsFallbackTarget,
|
||||||
);
|
);
|
||||||
return await _runTranscodeDecision(
|
|
||||||
|
final primary = await _runTranscodeDecision(
|
||||||
startEndpoint: _plexVideoHlsStartEndpoint,
|
startEndpoint: _plexVideoHlsStartEndpoint,
|
||||||
allParams: allParams,
|
allParams: paramsFor(useTsFallbackTarget: false),
|
||||||
isOriginal: preset.isOriginal,
|
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) {
|
} catch (e, st) {
|
||||||
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
appLogger.e('Failed to build transcode start path', error: e, stackTrace: st);
|
||||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
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
|
/// Point the part's server-side subtitle selection at [track] so an imminent
|
||||||
/// `subtitles=burn` transcode burns *that* stream.
|
/// `subtitles=burn` transcode burns *that* stream.
|
||||||
///
|
///
|
||||||
@@ -2899,11 +2766,12 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
);
|
);
|
||||||
return await _runTranscodeDecision(
|
final result = await _runTranscodeDecision(
|
||||||
startEndpoint: _musicTranscodeStartEndpoint,
|
startEndpoint: _musicTranscodeStartEndpoint,
|
||||||
allParams: allParams,
|
allParams: allParams,
|
||||||
isOriginal: preset.isOriginal,
|
isOriginal: preset.isOriginal,
|
||||||
);
|
);
|
||||||
|
return (startPath: result.startPath, outcome: result.outcome);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st);
|
appLogger.e('Failed to build music transcode start path', error: e, stackTrace: st);
|
||||||
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
return (startPath: null, outcome: TranscodeDecisionOutcome.failed);
|
||||||
@@ -2917,10 +2785,17 @@ class PlexClient
|
|||||||
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
|
/// outcome via [_parseTranscodeDecisionOutcome], and hand back the start
|
||||||
/// path (token stripped) on success. [startEndpoint] includes the container
|
/// path (token stripped) on success. [startEndpoint] includes the container
|
||||||
/// extension (`start.m3u8` / `start.mp3`).
|
/// 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 String startEndpoint,
|
||||||
required Map<String, String> allParams,
|
required Map<String, String> allParams,
|
||||||
required bool isOriginal,
|
required bool isOriginal,
|
||||||
|
String? requiredContainer,
|
||||||
}) async {
|
}) async {
|
||||||
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
final decisionEndpoint = '${startEndpoint.substring(0, startEndpoint.lastIndexOf('/'))}/decision';
|
||||||
|
|
||||||
@@ -2938,15 +2813,41 @@ class PlexClient
|
|||||||
|
|
||||||
if (decisionResponse.statusCode != 200) {
|
if (decisionResponse.statusCode != 200) {
|
||||||
appLogger.w('Transcode decision returned ${decisionResponse.statusCode}');
|
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);
|
final outcome = _parseTranscodeDecisionOutcome(decisionResponse.data, isOriginal: isOriginal);
|
||||||
if (outcome == TranscodeDecisionOutcome.failed) {
|
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(
|
String _buildTranscodeStartPathFromParams(
|
||||||
@@ -2974,12 +2875,13 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
|
bool useTsFallbackTarget = false,
|
||||||
}) {
|
}) {
|
||||||
final isOriginal = preset.isOriginal;
|
final isOriginal = preset.isOriginal;
|
||||||
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
|
final selectedInternalSubtitle = _selectedInternalSubtitleForHls(selectedSubtitleTrack);
|
||||||
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
final clientProfileExtra = _buildPlexHlsClientProfileExtra(
|
||||||
|
videoTranscodeTarget: useTsFallbackTarget ? _plexHlsVodTsVideoTranscodeTarget : _plexHlsVodVideoTranscodeTarget,
|
||||||
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
maxVideoBitrateKbps: !isOriginal ? preset.videoBitrateKbps : null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -3003,10 +2905,16 @@ class PlexClient
|
|||||||
'location': 'lan',
|
'location': 'lan',
|
||||||
'addDebugOverlay': '0',
|
'addDebugOverlay': '0',
|
||||||
'autoAdjustQuality': '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',
|
'directStreamAudio': '1',
|
||||||
'mediaBufferSize': '102400',
|
'mediaBufferSize': '102400',
|
||||||
'session': transcodeSessionId,
|
'session': transcodeSessionId,
|
||||||
if (offset != null && offset > Duration.zero) 'offset': (offset.inMilliseconds / 1000).toStringAsFixed(6),
|
|
||||||
// `subtitles` is the only subtitle knob this endpoint honours. Which
|
// `subtitles` is the only subtitle knob this endpoint honours. Which
|
||||||
// stream gets burned comes from the part's server-side selection, not
|
// stream gets burned comes from the part's server-side selection, not
|
||||||
// from here: measured against a real PMS, passing `subtitleStreamID` for
|
// from here: measured against a real PMS, passing `subtitleStreamID` for
|
||||||
@@ -3043,8 +2951,8 @@ class PlexClient
|
|||||||
required String sessionIdentifier,
|
required String sessionIdentifier,
|
||||||
required String transcodeSessionId,
|
required String transcodeSessionId,
|
||||||
int? audioStreamId,
|
int? audioStreamId,
|
||||||
Duration? offset,
|
|
||||||
MediaSubtitleTrack? selectedSubtitleTrack,
|
MediaSubtitleTrack? selectedSubtitleTrack,
|
||||||
|
bool useTsFallbackTarget = false,
|
||||||
}) {
|
}) {
|
||||||
return _buildTranscodeParams(
|
return _buildTranscodeParams(
|
||||||
ratingKey: ratingKey,
|
ratingKey: ratingKey,
|
||||||
@@ -3054,8 +2962,8 @@ class PlexClient
|
|||||||
sessionIdentifier: sessionIdentifier,
|
sessionIdentifier: sessionIdentifier,
|
||||||
transcodeSessionId: transcodeSessionId,
|
transcodeSessionId: transcodeSessionId,
|
||||||
audioStreamId: audioStreamId,
|
audioStreamId: audioStreamId,
|
||||||
offset: offset,
|
|
||||||
selectedSubtitleTrack: selectedSubtitleTrack,
|
selectedSubtitleTrack: selectedSubtitleTrack,
|
||||||
|
useTsFallbackTarget: useTsFallbackTarget,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3592,7 +3500,6 @@ class PlexClient
|
|||||||
sessionIdentifier: options.sessionIdentifier!,
|
sessionIdentifier: options.sessionIdentifier!,
|
||||||
transcodeSessionId: options.transcodeSessionId!,
|
transcodeSessionId: options.transcodeSessionId!,
|
||||||
audioStreamId: resolvedAudioId,
|
audioStreamId: resolvedAudioId,
|
||||||
offset: options.transcodeOffset,
|
|
||||||
selectedSubtitleTrack: requestedSubtitleTrack,
|
selectedSubtitleTrack: requestedSubtitleTrack,
|
||||||
partId: data.mediaInfo?.getPartId(),
|
partId: data.mediaInfo?.getPartId(),
|
||||||
);
|
);
|
||||||
@@ -3842,6 +3749,10 @@ class PlexClient
|
|||||||
externalSubtitles.add(
|
externalSubtitles.add(
|
||||||
PlaybackSubtitleSidecar(
|
PlaybackSubtitleSidecar(
|
||||||
sourceStreamId: plexTrack.id,
|
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(
|
track: SubtitleTrack.uri(
|
||||||
url,
|
url,
|
||||||
title:
|
title:
|
||||||
|
|||||||
@@ -696,7 +696,9 @@ mixin _PlexLiveTvClientMethods on _PlexClientInternals implements LiveTvSupport,
|
|||||||
'copyts': '0',
|
'copyts': '0',
|
||||||
'Accept-Language': 'en',
|
'Accept-Language': 'en',
|
||||||
'X-Plex-Session-Identifier': sessionIdentifier,
|
'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-Incomplete-Segments': '1',
|
||||||
'X-Plex-Product': config.product,
|
'X-Plex-Product': config.product,
|
||||||
'X-Plex-Version': config.version,
|
'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 '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove common country prefixes like "gb/", "us/", "de/", etc.
|
|
||||||
// The pattern matches: lowercase letters followed by a forward slash
|
|
||||||
final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false);
|
final regex = RegExp(r'^[a-z]{2,3}/(.+)$', caseSensitive: false);
|
||||||
final match = regex.firstMatch(contentRating);
|
final match = regex.firstMatch(contentRating);
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
/// Stream-download a URL directly into a file.
|
||||||
Future<void> downloadFile(
|
Future<void> downloadFile(
|
||||||
String url,
|
String url,
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ class BottomSheetHeader extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final usesBackButton = leading == null && onBack != null;
|
final usesBackButton = leading == null && onBack != null;
|
||||||
|
|
||||||
// Determine the leading widget based on priority: leading > onBack > icon
|
|
||||||
Widget? resolvedLeading;
|
Widget? resolvedLeading;
|
||||||
if (leading != null) {
|
if (leading != null) {
|
||||||
resolvedLeading = leading;
|
resolvedLeading = leading;
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
@override
|
@override
|
||||||
void didUpdateWidget(DownloadTreeView oldWidget) {
|
void didUpdateWidget(DownloadTreeView oldWidget) {
|
||||||
super.didUpdateWidget(oldWidget);
|
super.didUpdateWidget(oldWidget);
|
||||||
// When suppressAutoFocus changes from true to false, focus the first item
|
|
||||||
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
|
if (oldWidget.suppressAutoFocus && !widget.suppressAutoFocus) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted && _firstItemFocusNode.canRequestFocus) {
|
if (mounted && _firstItemFocusNode.canRequestFocus) {
|
||||||
@@ -121,13 +120,11 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the download tree from flat download list
|
|
||||||
List<DownloadTreeNode> _buildTree() {
|
List<DownloadTreeNode> _buildTree() {
|
||||||
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
|
final Map<String, List<MapEntry<String, DownloadProgress>>> showGroups = {};
|
||||||
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
|
final Map<String, List<MapEntry<String, DownloadProgress>>> albumGroups = {};
|
||||||
final List<DownloadTreeNode> movies = [];
|
final List<DownloadTreeNode> movies = [];
|
||||||
|
|
||||||
// Group downloads
|
|
||||||
for (final entry in widget.downloads.entries) {
|
for (final entry in widget.downloads.entries) {
|
||||||
final globalKey = entry.key;
|
final globalKey = entry.key;
|
||||||
final download = entry.value;
|
final download = entry.value;
|
||||||
@@ -136,17 +133,14 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
if (meta == null) continue;
|
if (meta == null) continue;
|
||||||
|
|
||||||
if (meta.isEpisode) {
|
if (meta.isEpisode) {
|
||||||
// Group episodes by show
|
|
||||||
final showKey = meta.grandparentId ?? 'unknown';
|
final showKey = meta.grandparentId ?? 'unknown';
|
||||||
showGroups.putIfAbsent(showKey, () => []);
|
showGroups.putIfAbsent(showKey, () => []);
|
||||||
showGroups[showKey]!.add(entry);
|
showGroups[showKey]!.add(entry);
|
||||||
} else if (meta.kind == MediaKind.track) {
|
} else if (meta.kind == MediaKind.track) {
|
||||||
// Group tracks by album (single level — no per-disc tier)
|
|
||||||
final albumKey = meta.parentId ?? 'unknown';
|
final albumKey = meta.parentId ?? 'unknown';
|
||||||
albumGroups.putIfAbsent(albumKey, () => []);
|
albumGroups.putIfAbsent(albumKey, () => []);
|
||||||
albumGroups[albumKey]!.add(entry);
|
albumGroups[albumKey]!.add(entry);
|
||||||
} else if (meta.isMovie) {
|
} else if (meta.isMovie) {
|
||||||
// Movies go at top level
|
|
||||||
movies.add(
|
movies.add(
|
||||||
DownloadTreeNode(
|
DownloadTreeNode(
|
||||||
key: globalKey,
|
key: globalKey,
|
||||||
@@ -161,7 +155,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build show nodes
|
|
||||||
final List<DownloadTreeNode> shows = [];
|
final List<DownloadTreeNode> shows = [];
|
||||||
for (final showEntry in showGroups.entries) {
|
for (final showEntry in showGroups.entries) {
|
||||||
final showKey = showEntry.key;
|
final showKey = showEntry.key;
|
||||||
@@ -169,11 +162,9 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
|
|
||||||
if (episodes.isEmpty) continue;
|
if (episodes.isEmpty) continue;
|
||||||
|
|
||||||
// Get show metadata from first episode
|
|
||||||
final firstEpisode = widget.metadata[episodes.first.key];
|
final firstEpisode = widget.metadata[episodes.first.key];
|
||||||
final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow;
|
final showTitle = firstEpisode?.grandparentTitle ?? t.downloads.unknownShow;
|
||||||
|
|
||||||
// Group episodes by season
|
|
||||||
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
|
final Map<String, List<MapEntry<String, DownloadProgress>>> seasonGroups = {};
|
||||||
for (final episode in episodes) {
|
for (final episode in episodes) {
|
||||||
final meta = widget.metadata[episode.key];
|
final meta = widget.metadata[episode.key];
|
||||||
@@ -184,7 +175,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
seasonGroups[seasonKey]!.add(episode);
|
seasonGroups[seasonKey]!.add(episode);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build season nodes
|
|
||||||
final List<DownloadTreeNode> seasons = [];
|
final List<DownloadTreeNode> seasons = [];
|
||||||
for (final seasonEntry in seasonGroups.entries) {
|
for (final seasonEntry in seasonGroups.entries) {
|
||||||
final seasonKey = seasonEntry.key;
|
final seasonKey = seasonEntry.key;
|
||||||
@@ -192,7 +182,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
|
|
||||||
if (seasonEpisodes.isEmpty) continue;
|
if (seasonEpisodes.isEmpty) continue;
|
||||||
|
|
||||||
// Get season metadata from first episode
|
|
||||||
final firstEpisode = widget.metadata[seasonEpisodes.first.key];
|
final firstEpisode = widget.metadata[seasonEpisodes.first.key];
|
||||||
final seasonNumber = firstEpisode?.parentIndex;
|
final seasonNumber = firstEpisode?.parentIndex;
|
||||||
final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true
|
final seasonTitle = firstEpisode?.parentTitle?.isNotEmpty == true
|
||||||
@@ -201,7 +190,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
? t.common.seasonNumber(number: seasonNumber)
|
? t.common.seasonNumber(number: seasonNumber)
|
||||||
: t.downloads.unknownSeason;
|
: t.downloads.unknownSeason;
|
||||||
|
|
||||||
// Build episode nodes
|
|
||||||
final List<DownloadTreeNode> episodeNodes = [];
|
final List<DownloadTreeNode> episodeNodes = [];
|
||||||
for (final episodeEntry in seasonEpisodes) {
|
for (final episodeEntry in seasonEpisodes) {
|
||||||
final globalKey = episodeEntry.key;
|
final globalKey = episodeEntry.key;
|
||||||
@@ -228,14 +216,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort episodes by episode number only (not by status)
|
|
||||||
episodeNodes.sort((a, b) {
|
episodeNodes.sort((a, b) {
|
||||||
final aIndex = a.metadata?.index ?? 0;
|
final aIndex = a.metadata?.index ?? 0;
|
||||||
final bIndex = b.metadata?.index ?? 0;
|
final bIndex = b.metadata?.index ?? 0;
|
||||||
return aIndex.compareTo(bIndex);
|
return aIndex.compareTo(bIndex);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate aggregate season progress
|
|
||||||
final seasonProgress = episodeNodes.isEmpty
|
final seasonProgress = episodeNodes.isEmpty
|
||||||
? 0.0
|
? 0.0
|
||||||
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length;
|
: episodeNodes.map((e) => e.progress).reduce((a, b) => a + b) / episodeNodes.length;
|
||||||
@@ -255,14 +241,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
|
|
||||||
seasons.removeWhere((s) => s.children.isEmpty);
|
seasons.removeWhere((s) => s.children.isEmpty);
|
||||||
|
|
||||||
// Sort seasons by season number
|
|
||||||
seasons.sort((a, b) {
|
seasons.sort((a, b) {
|
||||||
final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0;
|
final aSeasonNum = widget.metadata[a.children.first.key]?.parentIndex ?? 0;
|
||||||
final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0;
|
final bSeasonNum = widget.metadata[b.children.first.key]?.parentIndex ?? 0;
|
||||||
return aSeasonNum.compareTo(bSeasonNum);
|
return aSeasonNum.compareTo(bSeasonNum);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Calculate aggregate show progress
|
|
||||||
final showProgress = seasons.isEmpty
|
final showProgress = seasons.isEmpty
|
||||||
? 0.0
|
? 0.0
|
||||||
: seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length;
|
: seasons.map((s) => s.progress).reduce((a, b) => a + b) / seasons.length;
|
||||||
@@ -280,14 +264,12 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build album nodes (album -> tracks)
|
|
||||||
final List<DownloadTreeNode> albums = [];
|
final List<DownloadTreeNode> albums = [];
|
||||||
for (final albumEntry in albumGroups.entries) {
|
for (final albumEntry in albumGroups.entries) {
|
||||||
final albumKey = albumEntry.key;
|
final albumKey = albumEntry.key;
|
||||||
final tracks = albumEntry.value;
|
final tracks = albumEntry.value;
|
||||||
if (tracks.isEmpty) continue;
|
if (tracks.isEmpty) continue;
|
||||||
|
|
||||||
// Album/artist names from any track's parent fields
|
|
||||||
final firstTrack = widget.metadata[tracks.first.key];
|
final firstTrack = widget.metadata[tracks.first.key];
|
||||||
final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum;
|
final albumTitle = firstTrack?.albumTitle ?? t.downloads.unknownAlbum;
|
||||||
final artistTitle = firstTrack?.albumArtistTitle;
|
final artistTitle = firstTrack?.albumArtistTitle;
|
||||||
@@ -314,7 +296,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
}
|
}
|
||||||
if (trackNodes.isEmpty) continue;
|
if (trackNodes.isEmpty) continue;
|
||||||
|
|
||||||
// Sort tracks by disc then track number
|
|
||||||
trackNodes.sort((a, b) {
|
trackNodes.sort((a, b) {
|
||||||
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
|
final byDisc = (a.metadata?.discNumber ?? 1).compareTo(b.metadata?.discNumber ?? 1);
|
||||||
if (byDisc != 0) return byDisc;
|
if (byDisc != 0) return byDisc;
|
||||||
@@ -336,17 +317,13 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sort shows, albums, and movies by status and title
|
|
||||||
_sortNodesByStatusAndTitle(shows);
|
_sortNodesByStatusAndTitle(shows);
|
||||||
_sortNodesByStatusAndTitle(albums);
|
_sortNodesByStatusAndTitle(albums);
|
||||||
_sortNodesByStatusAndTitle(movies);
|
_sortNodesByStatusAndTitle(movies);
|
||||||
|
|
||||||
// Combine movies, shows, and albums
|
|
||||||
return [...movies, ...shows, ...albums];
|
return [...movies, ...shows, ...albums];
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Determine aggregate status from child statuses
|
|
||||||
/// Priority: downloading > queued > paused > completed > failed
|
|
||||||
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
|
DownloadStatus _determineAggregateStatus(List<DownloadStatus> statuses) {
|
||||||
if (statuses.isEmpty) return DownloadStatus.queued;
|
if (statuses.isEmpty) return DownloadStatus.queued;
|
||||||
|
|
||||||
@@ -365,7 +342,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
return DownloadStatus.completed;
|
return DownloadStatus.completed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Compare statuses for sorting (downloading first, then queued, etc.)
|
|
||||||
int _compareByStatus(DownloadStatus a, DownloadStatus b) {
|
int _compareByStatus(DownloadStatus a, DownloadStatus b) {
|
||||||
const statusOrder = {
|
const statusOrder = {
|
||||||
DownloadStatus.downloading: 0,
|
DownloadStatus.downloading: 0,
|
||||||
@@ -378,7 +354,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
|
return (statusOrder[a] ?? 99).compareTo(statusOrder[b] ?? 99);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Sort nodes by status (downloading first) then by title
|
|
||||||
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
|
void _sortNodesByStatusAndTitle(List<DownloadTreeNode> nodes) {
|
||||||
nodes.sort((a, b) {
|
nodes.sort((a, b) {
|
||||||
final statusCompare = _compareByStatus(a.status, b.status);
|
final statusCompare = _compareByStatus(a.status, b.status);
|
||||||
@@ -414,7 +389,6 @@ class _DownloadTreeViewState extends State<DownloadTreeView> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a tree item widget
|
|
||||||
Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) {
|
Widget _buildTreeItem(DownloadTreeNode node, int depth, {bool isFirst = false}) {
|
||||||
return _DownloadTreeItem(
|
return _DownloadTreeItem(
|
||||||
node: node,
|
node: node,
|
||||||
@@ -590,9 +564,7 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
|||||||
return widget.node.status;
|
return widget.node.status;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Focus node for row content (only created if not provided externally)
|
|
||||||
FocusNode? _ownedRowFocusNode;
|
FocusNode? _ownedRowFocusNode;
|
||||||
// Focus nodes for action buttons (up to 3 buttons max)
|
|
||||||
final List<FocusNode> _buttonFocusNodes = [];
|
final List<FocusNode> _buttonFocusNodes = [];
|
||||||
|
|
||||||
FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!;
|
FocusNode get _rowFocusNode => widget.rowFocusNode ?? _ownedRowFocusNode!;
|
||||||
@@ -676,10 +648,8 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
// Row content
|
|
||||||
Expanded(child: _buildRowContent(theme, canExpand)),
|
Expanded(child: _buildRowContent(theme, canExpand)),
|
||||||
|
|
||||||
// Action buttons
|
|
||||||
if (actions.isNotEmpty)
|
if (actions.isNotEmpty)
|
||||||
Row(
|
Row(
|
||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
@@ -696,7 +666,6 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
|||||||
Widget _buildRowContent(ThemeData theme, bool canExpand) {
|
Widget _buildRowContent(ThemeData theme, bool canExpand) {
|
||||||
return Row(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
// Expand/collapse icon
|
|
||||||
if (canExpand)
|
if (canExpand)
|
||||||
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
|
AppIcon(widget.isExpanded ? Symbols.expand_more_rounded : Symbols.chevron_right_rounded, fill: 1, size: 20)
|
||||||
else
|
else
|
||||||
@@ -704,12 +673,10 @@ class _DownloadTreeItemState extends State<_DownloadTreeItem> {
|
|||||||
|
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
|
|
||||||
// Status icon
|
|
||||||
DownloadStatusIcon(status: _effectiveStatus, size: 20),
|
DownloadStatusIcon(status: _effectiveStatus, size: 20),
|
||||||
|
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
|
|
||||||
// Title and info
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
|
|||||||
@@ -275,7 +275,6 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin, Skele
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle key down and repeat events
|
|
||||||
if (!event.isActionable) {
|
if (!event.isActionable) {
|
||||||
return KeyEventResult.ignored;
|
return KeyEventResult.ignored;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -289,7 +289,6 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
|||||||
final ScrollController _dialogScrollController = ScrollController();
|
final ScrollController _dialogScrollController = ScrollController();
|
||||||
final ScrollController _sheetScrollController = ScrollController();
|
final ScrollController _sheetScrollController = ScrollController();
|
||||||
|
|
||||||
// Keyboard navigation: column 0 = row, 1 = visibility button, 2 = options button.
|
|
||||||
@override
|
@override
|
||||||
List<MediaLibrary> get reorderItems => _tempLibraries;
|
List<MediaLibrary> get reorderItems => _tempLibraries;
|
||||||
|
|
||||||
@@ -468,16 +467,13 @@ class _LibraryManagementSheetState extends State<_LibraryManagementSheet>
|
|||||||
final isHidden = hiddenLibraryKeys.contains(library.globalKey);
|
final isHidden = hiddenLibraryKeys.contains(library.globalKey);
|
||||||
final colorScheme = Theme.of(context).colorScheme;
|
final colorScheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
// Determine background color based on state
|
|
||||||
Color? tileColor;
|
Color? tileColor;
|
||||||
if (isMoving) {
|
if (isMoving) {
|
||||||
tileColor = colorScheme.primaryContainer;
|
tileColor = colorScheme.primaryContainer;
|
||||||
} else if (isFocused && focusedColumn == 0) {
|
} else if (isFocused && focusedColumn == 0) {
|
||||||
// Only highlight row when row itself is focused (column 0)
|
|
||||||
tileColor = colorScheme.surfaceContainerHighest;
|
tileColor = colorScheme.surfaceContainerHighest;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Button focus states
|
|
||||||
final isVisibilityButtonFocused = isFocused && focusedColumn == 1;
|
final isVisibilityButtonFocused = isFocused && focusedColumn == 1;
|
||||||
final isOptionsButtonFocused = isFocused && focusedColumn == 2;
|
final isOptionsButtonFocused = isFocused && focusedColumn == 2;
|
||||||
|
|
||||||
|
|||||||
@@ -615,13 +615,11 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
|||||||
mainAxisSize: .min,
|
mainAxisSize: .min,
|
||||||
crossAxisAlignment: .start,
|
crossAxisAlignment: .start,
|
||||||
children: [
|
children: [
|
||||||
// Poster with overlay
|
|
||||||
if (posterHeight != null)
|
if (posterHeight != null)
|
||||||
SizedBox(width: double.infinity, height: posterHeight, child: poster)
|
SizedBox(width: double.infinity, height: posterHeight, child: poster)
|
||||||
else
|
else
|
||||||
Expanded(child: poster),
|
Expanded(child: poster),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
// Title (flattened — no inner Column)
|
|
||||||
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
|
if (widget.onTap == null && item is MediaItem && _hasClickableTitle(item))
|
||||||
_ClickableText(
|
_ClickableText(
|
||||||
text: item.displayTitle,
|
text: item.displayTitle,
|
||||||
@@ -637,7 +635,6 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
|||||||
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
|
style: const TextStyle(fontWeight: .w600, fontSize: 13, height: 1.1),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Subtitle
|
|
||||||
if (item is MediaPlaylist)
|
if (item is MediaPlaylist)
|
||||||
_MediaCardHelpers.buildPlaylistMeta(context, item)
|
_MediaCardHelpers.buildPlaylistMeta(context, item)
|
||||||
else if (item is MediaItem)
|
else if (item is MediaItem)
|
||||||
@@ -1131,7 +1128,6 @@ class _MediaCardHelpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For collections, show item count
|
|
||||||
if (mi.kind == MediaKind.collection) {
|
if (mi.kind == MediaKind.collection) {
|
||||||
final count = mi.childCount ?? mi.leafCount;
|
final count = mi.childCount ?? mi.leafCount;
|
||||||
if (count != null && count > 0) {
|
if (count != null && count > 0) {
|
||||||
@@ -1146,14 +1142,12 @@ class _MediaCardHelpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For albums, show the album artist
|
|
||||||
if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) {
|
if (mi.kind == MediaKind.album && mi.albumArtistTitle != null) {
|
||||||
return ExcludeSemantics(
|
return ExcludeSemantics(
|
||||||
child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
child: Text(mi.albumArtistTitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For tracks, show "Artist • duration"
|
|
||||||
if (mi.kind == MediaKind.track) {
|
if (mi.kind == MediaKind.track) {
|
||||||
final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)];
|
final parts = [?mi.trackArtistTitle, if (mi.durationMs case final durationMs?) formatDurationTextual(durationMs)];
|
||||||
if (parts.isNotEmpty) {
|
if (parts.isNotEmpty) {
|
||||||
@@ -1163,7 +1157,6 @@ class _MediaCardHelpers {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For episodes, show "S# · Episode Title" with clickable season link
|
|
||||||
if (mi.isEpisode && mi.parentIndex != null) {
|
if (mi.isEpisode && mi.parentIndex != null) {
|
||||||
if (enableDetailLinks && mi.parentId != null) {
|
if (enableDetailLinks && mi.parentId != null) {
|
||||||
return _buildEpisodeSubtitleRow(
|
return _buildEpisodeSubtitleRow(
|
||||||
@@ -1185,7 +1178,6 @@ class _MediaCardHelpers {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// For other media types, show subtitle/parent/year
|
|
||||||
if (mi.displaySubtitle != null) {
|
if (mi.displaySubtitle != null) {
|
||||||
return ExcludeSemantics(
|
return ExcludeSemantics(
|
||||||
child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
child: Text(mi.displaySubtitle!, maxLines: 1, overflow: .ellipsis, style: subtitleStyle),
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user