Plezy declares appCategory="video", so on Android Automotive OS it is a
parked app bound by car app quality DD-2/DD-3: audio must stop when the
vehicle starts driving and must not be resumable while driving. Two paths
kept audio alive. Music playback ran under a mediaPlayback foreground
service whose lifecycle observer was registered for Apple TV only, so it
never paused when Android backgrounded the app. Video pausing hung off
AppLifecycleState.hidden, which Flutter only synthesizes once Android
delivers onStop; a car without the Automotive compatibility mode delivers
onPause alone, which maps to AppLifecycleState.inactive and the player
ignored.
Gate every path that can start audio on a new lifecycle predicate,
automotivePlaybackAllowed, which permits playback on a car only while the
app is resumed and fails closed on an unknown lifecycle state. That covers
explicit play, gapless arming and track transitions, live retry and
channel switch, frame-rate-match resume, VOD/live startup, and the queue
navigation commands of the OS media session, plus a last-resort pause for
when the platform player resumes itself on native audio-focus regain.
Playback authority on the media-session router is deliberately left alone:
the router consumes a denied event, so gating it would swallow PauseEvent
and leave the OS unable to stop audio. Reacting to lifecycle callbacks is
the mechanism the platform documents as sufficient, so no android.car
dependency is added.
The music queue no longer requests POST_NOTIFICATIONS on a car, where the
foreground service and its notification never start: there is nothing to
authorize, and the prompt would take focus and make the gate discard the
first play intent.
Detect the form factor too: FEATURE_AUTOMOTIVE now vetoes the Android TV
verdict, so a rotary-only head unit no longer inherits the leanback
experience. Picture-in-picture is gated on FEATURE_PICTURE_IN_PICTURE,
which cars lack, so the app's UI cannot stay on screen while driving, and
nothing forces a preferred orientation on a fixed-orientation display.
Cycling backdrops reach a fallback path only once every rotating path
has failed to load, but every hero passed the rotation-agnostic backdrop
list as the rotation set and the aspect-ordered candidates as the
fallback. One servable wide backdrop was therefore enough to hide the
square background for good, so phone detail and Discover heroes
cover-fitted a 16:9 backdrop into a portrait box instead of showing the
square image Plex supplies.
Give the rotation set the same aspect-aware preference the candidate
list already has: near-square containers rotate the square background
alone and keep the backdrops behind it as fallbacks.
close#1700
Distinguish an unresolvable URL from a failed load at the error-widget
boundary. A transiently null media client during a profile switch or
reconnect marked the primary poster dead in a process-global set, pinning the
item to fallback artwork for the rest of the session.
Back left the Manage Libraries sheet open on Android with no way to
dismiss it. The host answered the platform pop with
`BackKeyCoordinator.consumeIfHandled()`, which dedups the focused key
path against the platform pop. Only TV routes one Back through both;
touch platforms never deliver Back to the sheet's key handler, verified
on device — a physical Back produced only `popRoute` and no key event.
So there was nothing to dedup against, and the global one-shot marker,
once set by any other handler, silently swallowed the only signal that
closes the sheet.
Scopes the dedup to TV. The TV regression that guarded this never set
the TV override, so it asserted the swallow on every platform and hid
the defect; it now enables the override and a touch counterpart pins the
dismissal.
Also blocks semantics behind the barrier. The barrier takes every
pointer event but left the screen underneath in the semantics tree, so
assistive tech and UI automation still saw rows that could not be
activated — Maestro read an occluded settings row as visible and tapped
its stale coordinates into the sheet. Flutter's own ModalBarrier blocks
semantics for the same reason.
Verified by replaying the failing Maestro sequence
(.maestro/subflows/settings_deep_checks.yaml lines 42-60) on a device:
back dismisses the sheet, Services opens, and back returns to settings.
On phone layouts main_screen pushes SettingsScreen as its own route, and
that route carried no OverlaySheetHost. showAdaptive could not find one
from the tile's context, so Manage Libraries fell back to
showModalBottomSheet. The sheet also owns a focused Back handler, so a
single Android Back arrived twice — once as a key event, once as
popRoute — and the two route-based paths raced, tearing down Settings
along with the sheet.
Installs one route-local host when no enclosing host exists, and opens
the sheet from a context below it. OverlaySheetHost then holds the route
while a sheet is open and deduplicates the key path, so one Back closes
only the sheet.
`dart format --set-exit-if-changed` over lib and test rewrites these.
The analysis job never reached its formatting step, so the drift went
unnoticed. No behaviour changes.
`clean_translations.py --strict` reported seven unused keys.
Three are genuinely dead: TrackSelectionHelper.getEmptyMessage was
removed as unreachable in 4307c49c, and the sheets that need an empty
state carry their own strings. Removes them from every locale.
The four accessibility keys are false positives. tv_number_spinner
aliased the subtree as `final a11y = ...accessibility`, which the static
scanner cannot follow — its docstring says as much. Binds the documented
`final t = Translations.of(context)` instead and hoists the two labels,
so the semantics stay identical and the scanner sees the chains.
Settings rows carried their own platform-conditional typography and
density, so on desktop and TV they rendered a 16px title, 14px subtitle
and 80px row while every other row in the app — the Focusable*ListTile
defaults plus ThemeData.listTileTheme's `dense: true` — renders 13/12
in 61px.
Drop the overrides instead of re-tuning them: the tile defaults already
encode the app's row style, and the explicit title styles were redundant
under a dense ListTile (they also masked the disabled/selected title
color). settingsOptionTitleStyle now only serves group children that are
not ListTiles, and matches the dense title unconditionally.
SettingsGroup hands its children that same compact density, so the plain
ListTiles used as non-interactive info rows stop standing 11px taller
than their interactive siblings.
Groundwork for #1300. Establishes the session, buffering and route
handling Dolby's application guide prescribes, and adds the diagnostic
arm needed to find out whether Apple's sample-buffer renderer can carry
Atmos objects at all.
Audio session, per the guide's sequence:
- Adopt the long-form playback profile in one atomic call at app launch
and activate the session there. The SDK only accepts that policy with
category Playback, a Default/MoviePlayback/SpokenAudio mode and no
options, so it cannot be assembled from separate calls.
- Report the resolved rendering mode in the player, hidden unless the
system resolves it. Apple only resolves it for CarPlay and AirPlay, so
an unresolved value means unknown, never "not Dolby".
Diagnostics (Apple TV only, Settings > Video Playback > Atmos Output Test):
- Add a sample-buffer arm. It reads the asset with AVAssetReader at
outputSettings nil and hands the untouched compressed buffers and the
untouched format description straight to the renderer, with a variant
that rebuilds the description the way playback builds it. Every
existing mode went through AVPlayer, so nothing exercised the path
playback actually uses; this is what tells us whether the renderer or
our construction is at fault.
- Add an AirPlay route picker. AirPlay is the only route where the system
resolves the rendering mode and the supported channel layouts, so it is
what makes those observations reachable at all, and the AVPlayer arms
now allow external playback so every arm can be compared on the same
destination.
- Add a session-mode toggle for the one profile difference between the
guide and previous playback behaviour.
- Report the session profile, supported layouts, both format
descriptions, the magic cookie and the renderer status, and release the
session on stop so a failed run cannot contaminate the next one.
Also bumps MPVKit to 1.0.14, which carries the matching audio output
work: the channel layout AVFoundation itself uses for Dolby content, a
renderer-failure observer so the fallback to PCM can actually run, the
prescribed feed ordering and preroll, flush recovery that re-supplies the
discarded audio instead of shifting later audio into its place, and
capability-driven fallback on route and capability changes.
This does not yet fix#1300. Whether the sample-buffer renderer can carry
JOC is still unknown; it removes every difference from the documented
setup that could explain the failure, and gives us the arm to answer it
on real hardware.
Consolidates duplicated logic behind shared implementations — paginated
grid tabs, focus chrome, cached remote stores, sheet selection columns,
the server artifact store and a test fixture layer — and removes code
that had become unreachable. Net reduction of about 5,500 lines with no
behaviour change.
Where a fix had landed separately in code that moved into a shared
helper, the fix was re-applied inside the helper rather than left behind
in the copy that went away.
Deduplicates the hand-rolled coalescing/caching maps, the Plex client cast,
the missing-serverId event guard and the progress-failure backoff, and drops
the MusicPlaybackService availability gate, which could never fail in
production.
The search screens, the out-of-band auth dialogs, the live TV guide and the
list-download paths each carried their own copy of the same shell. Extracts
SearchInputField and PendingAuthDialog and routes the duplicated download
and guide helpers through one implementation.
Focus chrome was implemented twice, once in the focusable wrapper and once
in the focus builders; both now go through FocusChrome. TvColorPicker's
channel row was a copy of TvNumberSpinner and is now that widget in compact
density.
Also trims unused helpers and fields and simplifies the Jellyfin browse
paths.
Extracts the repeated toolbar fade into a single ToolbarScrim widget, folds
duplicated request/retry handling in the media server HTTP client, and
collapses the parallel playback-source, download-manager and live TV helper
paths into shared implementations.
Collapses indirection layers and one-caller abstractions across the video
player, shortcut dispatch, shader loading and context-menu code, including
the VideoPIPManager pass-through over PipService.
- PaginatedCardGridTabState: the collections and playlists tabs were 95%
identical; they now supply only pageSize/fetchPage/idOf instead of each
duplicating the grid, memo, inflation budget and focus wiring.
- EtagCachedRemoteStore: the anime-lists and fribb mapping stores now share
one download/cache/isolate-parse/conditional-GET lifecycle.
- FocusableTileStateMixin manages its own initState/didUpdateWidget/dispose
instead of requiring every caller to forward three lifecycle hooks.
Also drops unused ServerCapabilities entries and dead code in
focusable_list_tile and music/track_row.
Introduces shared seams for paginated views, D-pad reorder, media control
routing, async singletons and the device method channel, then points the
open-coded copies at them.
Also removes unused models and duplicated provider/server plumbing, folds
the twice-implemented artifact store in the server, and factors the
repeated Flutter toolchain prologue in CI into a composite action.
Drops dead code across services, models, utils and widgets, including the
connection auth service, which had no implementer, and the Live TV DVR
provisioning models, which had no caller.
Tests that only covered deleted behaviour are removed or trimmed. No
behaviour change.
New TV-only Appearance toggle that pins the spotlight artwork to the
top-right corner (68% width, 72% height) with the left and bottom edges
feathered into the scaffold background, so the logo, metadata, and
shelves sit on a calm surface instead of the image. Default is off —
the full-bleed spotlight stays exactly as it is.
The artwork keeps its full-screen request size: the corner box only
crops the layout, so transcode URLs and image caches are unchanged and
toggling the setting stays instant.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Use Flutter's supported desktop application-exit API so the second root Back press closes Plezy on Windows. Preserve fullscreen when video player navigation is enabled while keeping fullscreen-first Escape behavior for normal desktop use.
close#1582