Compare commits

...
10 Commits
Author SHA1 Message Date
edde746 26e98e898d fix(desktop): reserve root Escape for leaving fullscreen instead of quitting
CI - Sanity Checks / Code Analysis (push) Successful in 5m36s
CI - Sanity Checks / Unit Tests (push) Failing after 13m36s
CI - Sanity Checks / Android JVM and Native Tests (push) Failing after 1m13s
CI - Sanity Checks / Native Formatting (push) Failing after 22s
CI - Sanity Checks / Linux native reliability (thread) (push) Failing after 2m59s
CI - Sanity Checks / Linux native reliability (address) (push) Failing after 2m57s
CI - Sanity Checks / Apple native reliability (iOS) (push) Canceled after 0s
CI - Sanity Checks / Apple native reliability (macOS) (push) Canceled after 0s
CI - Sanity Checks / Apple native reliability (tvOS) (push) Canceled after 0s
CI - Sanity Checks / Windows native reliability (arm64) (push) Canceled after 0s
CI - Sanity Checks / Windows native reliability (x64) (push) Canceled after 0s
CI - Sanity Checks / Dependency Validation (push) Successful in 1m9s
CI - Sanity Checks / Server checks (push) Successful in 28s
CI - Sanity Checks / Website checks (push) Successful in 1m22s
CI - Sanity Checks / Linux package smoke build (push) Failing after 6m41s
Physical-keyboard Escape at root Home now exits window fullscreen on
Windows and Linux the way it already did on macOS, and never arms the
press-back-again quit — so Escape aimed at fullscreen can't close the
app. Remotes, gamepad B, and system back keep the double-press exit.

close #1748
2026-08-11 11:15:51 +02:00
edde746 6663353895 fix(player): retry episode advances that fail on a transient server blip
An EOF-driven advance does one cold metadata fetch with a single endpoint
failover and no transient retry. When connectivity to the server drops for
the ~20s that fetch needs (issue log: both plex.direct endpoints connect
timed out, then the running stream's own TLS socket died), the reload
rolled back to the finished episode's last frame: black screen, progress
bar parked at the end, no way forward but the transport controls - while
pressing Next by hand seconds later succeeded. The per-item metadata cache
row could not absorb the blip either, because adjacency comes from queue
containers, so the next episode's row is cold at the exact moment the
transition needs it.

Three changes:

- A failed in-place reload now records its classified failure reason, and
  an advance that ran with the completion latch set re-presents the Play
  Next prompt when that reason is serverUnavailable. With auto-play
  enabled the countdown re-fires the advance up to two times before the
  prompt goes manual-only; Watch Together sessions and mid-episode Next
  presses (whose rolled-back stream is still valid) keep the existing
  handling. playNextRetryPresentation owns the decision and is unit-tested.

- Committing adjacency now best-effort prefetches the next episode's full
  metadata row through fetchItem, which writes the exact row playback
  initialization falls back to on both backends (Plex: same cache key and
  full playback query shape; Jellyfin: the /Users/{uid}/Items/{id} row the
  playback bundle reads). A warm row turns a blip at the transition into a
  normal start.

- JellyfinClient.fetchItem's documented "pure transport error -> cached
  row" fallback was dead code: the HTTP layer wraps transport errors into
  MediaServerHttpException, which the first catch rethrew unconditionally.
  Status-less, non-cancelled failures now take the fallback; answered
  requests (401/403/5xx) and cancellations surface unchanged.

Verified with new contract tests (Plex: cold row fails transiently ->
fetchItem primes -> the same failing fetch serves playback from cache;
Jellyfin: primed row survives a transport failure into fetchPlaybackBundle)
plus the full test/screens/video_player and test/services suites and
analyzer parity.

close #1867
2026-08-11 09:08:07 +02:00
edde746 83c50d93a2 fix(subtitles): flatten atlas-overflow ASS frames into an RGBA composite
Signs built from hundreds of overlapping paint-stroke drawings (masked
smartphone screens and similar typesetting) sum to far more bitmap area
than the paged ALPHA_8 atlas can hold: the issue sample needs 5 pages of
16M px at 1080p and 19 at 4K against the 4-page cap, so the packer
dropped the painter-order tail - the sign's text and late mask strokes.

Move the packer out of the JNI file into AssPack.c (pure C, compilable
against a desktop libass for verification) and add a composite fallback:
when a frame can never fit MAX_ATLAS_PAGES pages or the vertex budget,
blend the image list CPU-side into one premultiplied RGBA rect over the
union bounding box - O(frame area) instead of O(sum of image areas) -
and draw it as a single quad through a new MODE_COMPOSITE path in the
GL renderer. Oversized composites reuse the existing grow-and-re-render
contract; the atlas fast path is byte-identical for every frame that fits.

Verified with a desktop harness compiling the shipped AssPack.c against
fork libass 0.18.3 and the issue sample: all atlas-mode frames byte-match
the previous packer, the sign's frames composite with zero truncation and
byte-match a reference full-frame blend at 1080p and 4K, and the
multi-page composite grow path round-trips.

close #1868
2026-08-11 08:41:56 +02:00
edde746 3a704a2b9b fix(player): seek Plex transcodes in-band instead of pre-warming at the resume offset
A quality switch or resumed open at a nonzero position sent offset=T on the
HLS start URL, waited for the readiness probe to touch the segment at T, and
then had mpv seek to T anyway. mpv's stream probing always reads segment zero
first, and a Plex segment request is a seek, so the transcoder was dragged
through seek(T) -> seek(0) -> seek(T) within seconds of the open. Measured
against PMS 1.43, a segment response that races such a restart can be left
open with headers sent and no data or error, and ffmpeg's HLS segment reads
have no default timeout, so playback buffered forever after the first frame
(issue #1859). Starting the session plain and letting the player's start=T
request the resume segment performs the one unavoidable transcoder seek.

The offset request parameter, the readiness probe, and the probe-only
getStatus HTTP helper are removed; live TV time-shift keeps its own offset
path. Transcode opens now also set an explicit network-timeout with
demuxer-level reconnect options: mpv's stream-layer reconnect settings never
reach ffmpeg's HLS segment fetches, so a silently hung segment response now
times out after 20s and is re-requested on a fresh connection instead of
buffering indefinitely. Verified against a live PMS (resume plays from the
requested position) and a stall harness (hung segment re-requested at 20s
with no content skip).
2026-08-10 23:04:35 +02:00
edde746 aff6b6576f fix(player): offer the TrueHD MAT carrier on API 29-32 routes
Carrier-or-decode gated the carrier on getDirectPlaybackSupport, which only
exists on API 33, so every older route force-decoded TrueHD - including
routes that bitstreamed it before the carrier existed. The #1863 Fire TV
Stick 4K Max is Fire OS 8 (API 30): its HDMI route advertises raw TrueHD
and IEC 61937 at 8 channels, 2.12.1 passed TrueHD through, and 2.13.0 hands
the same stream to the FFmpeg decoder. The Shield is API 30 as well.

API 29-32 now asks AudioTrack.isDirectPlaybackSupported about the exact
192kHz/7.1 IEC tuple before offering the carrier. It is coarser than the
API 33 probe - it cannot tell bitstream from offload - but an IEC 61937
track is PCM-shaped by definition, so direct support means the route
carries the frames. getMinBufferSize stays as the precondition on every
tier, and a route that still lies fails AudioTrack initialisation, which
the audio recovery path already answers by blocking direct output and
force-decoding in place. Below API 29 nothing can vouch for the tuple, so
the carrier is still not offered and TrueHD decodes as before.

The tier decision is split from the platform probes so it is unit-testable;
each probe is consulted only on the tiers where its API exists.
2026-08-10 22:54:17 +02:00
edde746 d19ec625cd fix(tv): remove the background Watch Next refresh
2.13.0's ShelfRefreshWorker boots a second headless FlutterEngine in
the app process to refresh the launcher row every six hours. Its
foreground guard is checked only once at worker start, so launching the
app during a run leaves two engines sharing a low-RAM TV for up to 90
seconds, and a failed run retries with backoff. Suspected of
destabilizing the compositor on the 32-bit TCL panel in #1862. The tvOS
Top Shelf live fetch is unaffected and stays.

The foreground sync pipeline keeps the row fresh while the app runs, as
before 2.13.0. Updated devices still carry the persisted periodic job,
which would wake the process once more only to fail instantiating the
deleted class; the package-replaced receiver now cancels it.
2026-08-10 22:30:51 +02:00
edde746 c2bd1d28fd fix(subtitles): load external subtitle files with the media whether or not selected
Since a1b6a8971 only the selected sidecar attached at open, so mpv's
track-list carried one external subtitle and the track sheet could only
offer the rest as primary source switches - tap-and-hold on a
non-selected external track selected it as primary instead of secondary.

Real external files are cheap static fetches, so Jellyfin, Plex direct
play, and offline discovery now mark them preload and they ride along in
sub-files at open, keeping every external track selectable as a
secondary subtitle without a reopen. Embedded rows extracted on a
transcode stay lazy: extraction can stall behind the transcoder, which
is exactly what used to trip the sidecar open guard.

close #1860
2026-08-10 21:23:55 +02:00
edde746 ea356a6112 fix(plex): use fMP4 HLS for video transcodes so HEVC presets stop corrupting
Non-Original presets advertised hevc inside the mpegts HLS target; a Plex
Pass server with HEVC encoding enabled obliges, and its HEVC encode -> TS
segmenter path emits parameter sets mpv rejects ("PPS changed between
slices"). The VOD target now requests fragmented MP4 (verified against
PMS 1.22-1.43), retrying once with an H.264-only TS profile when a
server's decision does not echo the mp4 container back, and falling back
to direct play when neither is honoured. Live TV keeps its own TS target:
live sessions copy broadcast hevc/mpeg2video streams, a path the encoder
bug does not touch.

Presets also now send the videoResolution/videoQuality caps their labels
promise; previously only the bitrate limitation went out, so a "1080p
8 Mbps" preset delivered 2160p at a starved 8 Mbps.

close #1859
2026-08-10 20:38:45 +02:00
edde746 69fadc220d chore: clean up code comments 2026-08-10 20:28:41 +02:00
edde746 5611c6785a chore: bump version to 2.13.0 (129) 2026-08-10 19:03:03 +02:00
211 changed files with 1884 additions and 3730 deletions
-2
View File
@@ -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
View File
@@ -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:
+4 -14
View File
@@ -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
+2 -4
View File
@@ -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
+2 -4
View File
@@ -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
+2 -6
View File
@@ -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
+4 -5
View File
@@ -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
+4 -4
View File
@@ -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
@@ -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
} }
-13
View File
@@ -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 2932: `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 2932.
.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()
}
}
}
@@ -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()
}
-3
View File
@@ -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")
+46 -283
View File
@@ -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) ---
+300
View File
@@ -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;
}
+57
View File
@@ -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
+1 -1
View File
@@ -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]
) )
} }
} }
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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.
+1 -5
View File
@@ -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
-3
View File
@@ -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,
-1
View File
@@ -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(
-2
View File
@@ -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) {
-3
View File
@@ -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;
} }
-1
View File
@@ -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(
+3 -22
View File
@@ -79,12 +79,6 @@ class _RenderPaintScale extends RenderProxyBox {
/// A wrapper widget that makes its child focusable with D-pad navigation support. /// 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;
} }
-2
View File
@@ -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);
} }
-13
View File
@@ -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;
-3
View File
@@ -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;
-1
View File
@@ -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;
-11
View File
@@ -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,
-6
View File
@@ -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;
+13 -10
View File
@@ -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;
+3 -14
View File
@@ -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(
-2
View File
@@ -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,
+12 -12
View File
@@ -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;
-8
View File
@@ -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) {
-5
View File
@@ -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;
+26 -7
View File
@@ -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
View File
@@ -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:
+3 -1
View File
@@ -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,
-248
View File
@@ -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);
}
}
-2
View File
@@ -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);
-39
View File
@@ -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,
-1
View File
@@ -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;
-33
View File
@@ -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,
-1
View File
@@ -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;
-8
View File
@@ -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