On a phone in portrait the video controls header is too narrow: the clock crowds the back button, title, and track/chapter controls. Hide it there; landscape, tablets, desktop, and TV keep it.
Phones were locked to portrait outside the video player, and the mobile
shell had no answer for a wide, short viewport: car head units (which use
the mobile layout) and tablets in landscape spent their scarce height on
a bottom navigation bar and a 500dp hero.
In landscape the mobile shell now puts the bottom bar's destinations on a
leading Material NavigationRail (MobileNavigationRail) and keeps every
other mobile layout decision as it is. The rail keeps the bottom bar's
extras: the offline reconnect affordance as its leading action and the
long-press library quick picker on the Libraries destination. Labels
follow the existing nav-bar-labels setting but give way automatically
when the destinations cannot fit (a landscape phone is ~410dp tall), and
the rail scrolls as a last resort. The content beside it drops the leading
system inset the rail already absorbs.
Phones now allow every orientation; OrientationHelper.restoreDefaultOrientations
is context-free and the player exit path uses it instead of its own copy
of the phone lock. The Discover hero fills the viewport in mobile landscape
and compacts its logo and bottom offset when short, so its content stays
below the top bar.
The music mini-player learns a start inset (MiniPlayerInsetController.setNavInsets)
so it floats beside the rail in landscape and above the bar in portrait;
route suspension zeroes both.
Known follow-up: the media detail header is not yet laid out for a
landscape phone (logo runs under the status bar and back button).
Downloaded episodes never showed the partially-watched progress bar, so a
user resuming offline could not tell which episode they had started.
Offline playback already records progress: the tracker queues a progress
action and emits a WatchStateNotifier event, and DownloadProvider hydrates
those rows back into WatchStateStore on load. EpisodeCard consumed that
state via withFreshWatchState but then passed progressAvailable: false to
WatchedIndicator for offline cards, a guard left over from before offline
progress tracking existed. Downloaded movie cards (MediaCard) never had the
guard and already showed the bar.
Drop the guard and the now-unused WatchedIndicator.progressAvailable flag.
close#2236
Requesting an anime series with REQUEST_ADVANCED routed it to the standard
Sonarr profile and root folder. The sheet seeded its pickers from
activeProfileId/activeDirectory only, then posted them as explicit overrides,
which beat the anime defaults Seerr would otherwise apply at approval. A user
without the permission sent no overrides and was routed correctly.
The service DTOs now carry activeAnimeProfileId, activeAnimeDirectory,
activeAnimeLanguageProfileId, activeTags, activeAnimeTags and the instance's
tag list. The sheet detects the TMDB anime keyword on the TV details it
already fetches and seeds each picker the way Seerr's web requester does:
the anime value when the series is an anime and the instance configures one,
else the standard value. Options carry the "(Default)" marker and an anime
series shows the same note as the web UI.
Tags are editable: an inline checklist under the advanced pickers, seeded from
the instance's (anime) default tags and posted as `tags`. It is inline rather
than a nested sheet page because the host builds only the top page, so a push
would dispose the sheet and drop the season and picker selections on the way
back.
close#2215
The detail screen gave no hint of what pressing Play would do with the
file: which audio track and which subtitles the player would start with,
or the picture format. Changing tracks meant starting playback first.
The action row now ends with a status line — picture labels, then the
audio and subtitle rows the player will select — computed by the player's
own selection ladder (TrackSelectionService) over the server's stream
rows, so it agrees with what the player then does. Select opens a
pre-play chooser with the track sheet's two columns; Play and rail
activation pass the pick to the player as its navigation-tier preference,
and a pick carries across episodes by semantics, forced-ness included.
Plex listings carry no stream rows, so a focused episode is probed once,
debounced, and cached for the screen.
Along the way: MediaStream keeps the server's `selected` apart from the
container `default` flag (they were OR'd, so the account's pick was
indistinguishable from the default track); the overlay sheet host no
longer overrides a row that claimed focus itself.
The TV show detail page spent its hero rows on information that never
changes while browsing episodes (the show's genres, the file's stream
quality) while the rail cards repeated the show name on every card and
pushed the episode title into a truncated subtitle. The Play button also
kept naming the on-deck episode while the hero described the focused one.
Rail cards inside the show now headline the episode title with
`S1 E3 · 23min` as subtitle; genres and quality labels move from the hero
to the details sheet (which now also names the episode and takes the show's
genres explicitly); Play follows the hero's episode and falls back to
on-deck when nothing is focused. The freed rows go back to the logo and the
third description line.
close#2217
The memo-lookup, scroll-budget, skeleton-scheduling sequence was copied
in the paginated grid, the browse tab, and hub rows; SkeletonUpgradeScheduler
now owns it as realizeBudgeted with per-site skeleton and card builders,
keeping the hub's focus salt and padded skeleton.
The video player screen held fourteen nullable subscription fields and
listed them twice when cancelling; they are two lists (player re-wire,
media controls) snapshotted and cleared before cancellation, with the
screen-lifetime Apple TV and sleep-timer subscriptions left as named
fields. Live seek and subtitle switches open through one helper, and the
initial live open applies the shared stream options instead of an
inline property write.
Grid Spacing (Settings > Appearance) only changed the library grid. The
home, explore, detail and library-recommended rows were byte-identical
across Tight/Normal/Spacious because #2083 deliberately opted hub-row
cell packing out of the setting and hard-coded a 2px pad per card, and
the TV home rail never read the setting at all.
Treat a hub row as one row of the grid. MediaGridDelegate.cellWidth
packs row cells with the same gutter MediaGridGeometry uses, so a row
and the "see all" grid behind it render the same card at every setting.
HubSection renders max(4, gap) between cards - Tight keeps its historical
4px so nothing moves on update, Normal/Spacious render the grid's 6/12px
gutter - and feeds the same gap into its D-pad item extent and card memo
epoch. TvBrowseRail threads GridSpacing through metricsForHub,
maxActiveRailHeight and estimateHeight; non-full-card rails use the
setting scaled with the rest of the rail metrics, full-card rails keep
their own scale-derived gutter like full-bleed grids. The spotlight and
detail rail height reservations pass the setting too so reserved
heights track the narrower cards. Both surfaces watch the pref, so rows
re-lay out live.
close#2226
Every D-pad step on the TV home ends with a spotlight swap, and on a
low-end box that frame ran over budget: 12 % of UI-thread CPU during
navigation was paragraph layout, most of it shaping strings a second time
to measure them before the render tree shaped them for real.
- FittingTitleText no longer lays the title out to learn whether it fits:
Text wraps and ellipsizes at maxLines, so the box can only overflow
vertically, and one cached one-glyph line height settles that. Only a
box shorter than maxLines lines still searches, now bracketed by the
proportional shrink of the base layout and stopped at a 0.25 px
tolerance instead of a fixed 12 bisections.
- FittedMetadataLine and inlineRatingBadgeWidth measure through a small
bounded cache keyed by text, style, scaler and direction, so separators,
type labels, certifications, runtimes and years measure once per
session.
- MediaHub.isContinueWatchingHub/usesContinueWatchingAction memoize their
regex tokenisation per key; every card build and step re-asked them.
Cold navigation on the Android 14 box (30 steps over three hubs): build
312-316 ms -> 284-285 ms, layout 530-540 ms -> 515 ms, UI frame p90
7.5-7.9 ms -> 6.8-7.2 ms.
D-pad navigation on Android TV felt sluggish next to Projectivy and the
Fire TV launcher: every step of the home rail and hub rows trailed the
focus border in a 500ms ease-out, and hub moves deferred the vertical
glide by a frame. The 500ms was measured from the tvOS focus engine
(f357be407) and applied to every platform; Leanback prices a one-card
step at roughly 100-150ms, so the same glide reads as input lag on a
D-pad.
FocusTheme.navigationScrollDuration now vends the per-platform value:
Apple TV keeps the measured 500ms, everything else glides in 150ms.
TvBrowseRail and HubSection use it for row and hub-list scrolls, and a
hub move starts the vertical animation in the same frame when the
build-time section offsets are already known.
Measured with screen recordings on a Shield TV (2019) and a Fire TV
Stick 4K Max: row motion per press 470-510ms before, 135-150ms after;
hub move 480ms before, 120-150ms after.
CI's formatting jobs were red on main: dart format wanted
media_card_grid_spacing_test.dart collapsed, and ktlint wanted three
Kotlin expression bodies on their signature line plus one import moved
into lexicographic order.
Pure formatter output from `dart format` and `scripts/format_native.sh
--fix`; no behavior change.
The brightness set with the left-edge swipe was lost the moment playback
ended, so every session started back at the system level. A new
"Remember Brightness Level" toggle under Settings > Playback > Gestures
persists the level a swipe settles on and reapplies it when the next
playback starts (and when the app resumes mid-session). Leaving the
player still restores the pre-playback brightness, so the rest of the
app is unaffected. Default off, preserving current behavior.
close#2178
The mini player drew a thin scrubbable progress bar along its top edge on
top of the ambient background fill. Drop the scrub lane: the fill is the
card's only progress display, and precise seeking stays on the
now-playing screen's seek bar. Horizontal drags across the card's top
edge swipe-dismiss the card again.
On TV the show detail screen exposed season actions nowhere: marking a
season watched meant toggling every episode one by one, and the action
row's menu only covered the whole show.
Each season row on the TV detail rail now carries a full-size Options
card that opens the same season context menu the mobile season tabs
offer (mark watched/unwatched, download, delete, add to). The card
rests off-screen left of the first episode — the row is a
center-anchored scroll view and the card lives in the negative-offset
region before the anchor — so the resting layout and default focus are
unchanged and normal playback gains no extra steps; pressing LEFT from
the first episode slides it in. The trailing View All / retry / loading
slots become matching full-size cards instead of compact pills.
close#2156
While music plays, the persistent mini player showed progress only as a
subtle background tint with no way to seek; the full seek bar sat one
navigation hop away on the now-playing screen (#2141).
The card's top edge now hosts a thin visible progress bar inside a taller
touch lane: tap seeks proportionally, dragging scrubs and commits one seek
on release, and hovering or dragging grows the bar and shows a thumb. Card
taps outside the lane still open the now-playing screen. Pointer-only by
design: the overlay never renders on TV and the keyboard focus chain leads
to the now-playing screen's focusable seek bar.
close#2141
Users could not adjust the five-second Play Next timer, switch off the
edge-swipe/pinch gestures they trigger by accident, deinterlace DVD-era
content, or keep always-on-top across episodes.
Play Next Countdown (0-30 s) joins Auto-Play & Skip; zero skips the
prompt and starts the next episode immediately, while the transient-
retry prompt keeps its fixed five-second spacing. Brightness swipe,
volume swipe, and pinch-to-zoom each get a switch in a mobile-only
Gestures group; a disabled edge also stops stealing the content-strip
drag. A Deinterlacing toggle (mpv-only — ExoPlayer has no filter chain)
sets mpv's deinterlace=auto at player init. The player's always-on-top
toggle now persists via a pref that is re-applied when the next player
opens, including autoplay episode transitions; the window flag is still
dropped when the player closes.
Also resolves the reorg TODO markers per triage: hold-to-speed lock
(#1046) will be gesture behavior rather than a setting, metadata
language (#1668) and delete re-authentication (#1924) are rejected, and
always-on-top is player-persisted state, not a Window setting (#1386).
close#1827close#1810close#2149close#931
On mobile data the single default streaming quality either burns
through the user's data plan or permanently caps quality at home.
Playback start now applies a dedicated cellular default when the
device is on a cellular-only connection, read from the app's existing
single connectivity subscription. The new setting defaults to
following the general default, ships in Settings > Playback for
phones/tablets only, and explicit per-play quality picks still win.
'Metered connection' had been spelled out independently in the offline
provider, the download WiFi-only gate and the sync-rule cooldown.
Rather than add a fourth copy, all four now share
ConnectivityLinkType. Network *presence* is deliberately left alone:
its callers disagree on whether an empty snapshot counts as
connected, so folding that in would change behavior.
showSelectionDialog now returns the picked DialogOption instead of its
raw value so the tile's null-valued 'Same as Default Quality' option
stays distinguishable from dismissing the dialog.
close#2147
The library grid packs posters edge to edge. The grid delegate's cross-
and main-axis spacing were hardcoded to zero, so the only visible gap
came from each card's own 3px padding, and Library Density changed
poster size rather than the space between posters. The result reads
denser than Plex, Jellyfin, or any commercial client, both horizontally
between posters and vertically between a poster and its title.
Add a Grid Spacing setting - Tight, Normal, Spacious (0/6/12px) - below
Library Density. Tight is the default and reproduces the current layout
exactly, so no existing grid moves on update.
The gutter lands in MediaGridDelegate.spacingFor(), already the single
funnel for non-full-bleed grid spacing, so every grid surface picks it
up: library browse, collections, playlists, downloads, and the detail
screens. Square music grids keep their 8px floor. The pref is watched
once in MediaCardSliverLayout - the only MediaGridGeometry.resolve()
call site - so grids re-layout live instead of on restart.
Grid cells grow the poster-to-title gap to 2/4/6px as well; the
Expanded poster absorbs the delta, so the cell keeps its aspect ratio.
Fixed-height hub-row cards keep the historical 2px because their text
band cannot absorb more, and hub-row cell packing opts out of the
setting entirely so shelves stay byte-identical across it.
Full-bleed TV grids are untouched - they already carry an 8-18px
scale-derived gutter - and are opt-in behind tvFullCardLayout, so the
default TV library grid follows the setting like every other surface.
Verified on macOS across all three steps: gutters and title gap grow,
grids re-pack live, and the segmented control reflects and writes the
value.
close#1597close#2083
The in-app FFmpeg container demuxer classified every DTS variant as
plain DTS (profile-blind MIME mapping at the demux boundary), so
DTS-HD MA lost its lossless identity downstream, and it ignored
container display-aspect-ratio overrides. media3's extractors get both
right.
Delete the FFmpeg demuxer (JNI, extractor, and its setting); ExoPlayer
direct play demuxes with media3's DefaultExtractorsFactory again, and
mpv - the Android default - reads container display dimensions itself.
close#2124close#2115
In TV mode the sidebar menu floated unreadably on top of the home rows,
the artwork and the settings list — two overlapping sets of text with no
panel behind either.
The rail's surface is transparent on TV so the full-bleed backdrop
continues behind a docked rail, which is safe because the shell displaces
content around it. 2.16.0 turned hover/touch expansion into an M3E modal
panel that overlays content at the collapsed offset instead of pushing
it, so there the rail painted nothing and content showed straight
through the menu.
Keep the transparency rule for every docked shape and paint the surface
whenever the rail floats, reusing the predicate that already gates the
overlay corner radius and edge shadow. The fade now shares the width
morph's duration and curve, so a shorter fade cannot strand a fully
grown, fully transparent panel over the content mid-collapse.
Reproducing needs Force TV mode plus a pointer, which is why this
surfaced on a SteamOS box driving a TV and never on Android TV's D-pad
path.
close#2079
Tapping a discovered desktop in the phone's companion remote did nothing:
the device list emptied to "No devices found on your network" and no
connection was ever made. Manual IP:port entry silently did nothing too.
A host advertises every local IPv4 address, so a PC with virtual adapters
(WSL, Hyper-V) broadcasts addresses the phone cannot route to. The client
races them all, and cleanup of the losing candidates awaited
`sink.close()` on channels that never connected. That future never
completes in web_socket_channel: the close future waits on the local
stream's listener, which is only attached on the connect-success path. So
the race never finished, the winner was never joined, and the flow just
stopped with no error.
Cleanup now tracks whether a candidate ever connected: unconnected probes
get a deferred close armed on `ready` settling instead of a blocking
await, and they skip the terminal drain since they hold no host admission
slot. The managed join gets a real connect timeout that surfaces as a
typed timeout error, and disconnect during a pending connect closes the
socket once the connect settles rather than blocking on it.
Two paper cuts around the same flow: a failed attempt now restarts
discovery instead of stranding the cleared list on "No devices found",
and the desktop's server card shows its listen addresses so manual entry
is not guesswork.
close#2077
On a 32-bit Amlogic TV box, 57% of UI frames while moving focus along a rail
missed the 16.68 ms budget, and the median frame was already over it at 20.7 ms.
The GPU was idle throughout (raster p90 9.1 ms, zero frames over budget) -- all
of it was main-isolate work, split roughly evenly between layout and semantics.
Five separate causes, all measured:
- The horizontal rail used `itemExtentBuilder`, so `RenderSliverVariedExtentList`
walked every preceding index on each realized child, in every layout pass, and
re-resolved the trailing slot inside the closure. Pinning the trailing cell to
the card extent makes the list uniform, which restores O(1) offset, index and
max-extent math.
- The sidebar-expand tween wrapped the whole shell with a `LayoutBuilder` inside
its builder, so ~15 ticks per focus flip rebuilt `SideNavigationRail` -- and its
non-virtualized child list -- during the layout phase. The tween now wraps only
the content `Positioned`, matching `SideNavigationBleedBuilder`.
- Each nav item crossfaded through two `Opacity` subtrees, i.e. 14-18 offscreen
save layers per frame for the whole 250 ms morph. It now fades colour alpha on
the leaf, the same substitution `AnimatedDimScrim` already documents.
- `HorizontalScrollWithArrows` kept a scroll listener on platforms where its arrow
chrome is compiled out, so crossing either scroll boundary `setState`d a whole
rail row of cards. Two full-row rebuild storms per traversal, for arrows that
cannot appear.
- The rail's semantic proxy rebuilt its label and six closures on every
`_RailFocusModel` notification, including vertical-scroll flips.
Also: one merged listener per `SettingsBuilder` instead of six, and the media card
semantic label is cached rather than rebuilt and then discarded by the rail's
`ExcludeSemantics`.
Measured on the device, median of three runs: frames over budget 120/212 -> 32/246,
total UI-thread work 4067 ms -> 1845 ms, layout p90 18.2 -> 8.1 ms, build p90
7.95 -> 2.42 ms. The sidebar scenario went from 121/148 frames over budget to
32/184.
Nothing sat between the full-screen `RasterizedGradient` scrim and the
position-driven leaves, so `markNeedsPaint` from the timeline slider walked past
the gradient and re-rasterized the entire chrome -- top bar, button row,
timestamps, timeline -- on every 250 ms position tick. That also partly defeated
`RasterizedGradient`, which exists because Skia gradients cost ~10 ms per
full-screen pass on this GPU class.
Around that, four things drove root `setState` calls that only needed to change a
leaf:
- Held D-pad seek called a root `setState` per key repeat, even when the readout
was already visible in the same direction and only the accumulated seconds had
changed. The seek amount now flows through a `ValueNotifier` that
`DoubleTapFeedback` reads, so a burst updates one `Text`.
- `DoubleTapFeedback`'s drift chevron ran `..repeat()` with no stop condition, and
kept ticking at 60 Hz for the ~300 ms the readout spends at zero opacity after
every burst. The controller is now gated on visibility.
- The auto-skip countdown ran a 5 Hz timer that root-`setState`d the controls tree
for the whole intro/credits window, to animate one progress ring.
- The play-next and still-watching countdowns root-`setState`d the player screen
once a second, which re-created `PlexVideoControls` with its ~50 props and
rebuilt the entire chrome to change one digit. Prompt open/close deliberately
stays on the state path, because `playNextFocusNode` is threaded through those
props and D-pad focus depends on it.
Two of the four position subscribers existed only to recompute pointer-hover
tooltips that a D-pad viewer never sees, rebuilding a button subtree 4x/s and
rescanning the chapter list each time; the "ends at" readout reformatted a string
4x/s for a value that changes once a minute. All three now dedupe.
Finally, the buffer path did two sequential `copyWith` calls on a 15-field
`PlayerState` and handed `BufferRangePainter` a freshly allocated list every tick.
`List` has no value equality, so `shouldRepaint` was unconditionally true.
Measured on device during playback with the chrome raised: 6 of 344 UI frames over
budget, p50 4.39 ms, layout p90 1.09 ms. During a held D-pad seek -- the worst
frame-time window on the TV path, because the readout and the decoder flush land in
the same frames -- 1 of 354 frames over budget, p50 4.28 ms, zero raster frames over
budget.
The image pipeline funnels through `MediaImageHelper.serverArtworkProvider`, which
sizes the request, bounds the decode and namespaces the disk key. Five places
escaped that funnel, and on the reduced tier they compete for a 64 MiB image cache.
- Jellyfin trickplay prefetch built a bare `CachedNetworkImageProvider` and called
`resolve()` on it, fully decoding a sprite sheet -- roughly 22 MiB of RGBA for a
10x10 grid of 320-wide tiles. None of it was ever painted: the render path wraps
the same provider in `ResizeImage`, so it decodes again under a different cache
key. The comment already said the intent was only to warm the disk cache, so it
now does exactly that through the cache manager and resolves no image stream.
- `CyclingMediaBackdrop` re-fetched and re-decoded a full-screen backdrop every
10 s forever, with no `DevicePerformance` term in `_canRotate` even though its
own fade duration is tier-gated. At the reduced tier's 1280x720 art cap that is
3.52 MiB per rotation, ~7 MiB live across the crossfade, churning while the
viewer sits still and evicting the rail posters behind it. The fade is already
zero-duration there, so the rotation bought variety and nothing else.
- Catalog detail passed `item.posterUrl`/`backdropUrl` with no client, which
`getOptimizedImageUrl` deliberately returns unmodified -- so a 40x60 slot pulled
a 600x900 TMDB asset and a 320-tall backdrop pulled 1920x800. `posterFor`/
`backdropFor` already existed for this; the sites now use them.
- The season strip, the Live TV programme sheet and the profile avatar built
`CachedNetworkImage` directly, keeping the sized request but losing the
`plex_optimized_<sha1>` disk key, so the same artwork was cached twice and
re-downloaded on detail open. The season strip also discarded its height bound.
Sources without published variants (Trakt, AniList, MAL, MDBList) still fall back
to the base URL.
Switching to the Explore tab hands focus to the first hub, and
HubSection unconditionally ran Scrollable.ensureVisible, scrolling the
app bar and search field off-screen in touch mode. The scroll is now
gated on keyboard/D-pad input mode, mirroring FocusableWrapper's
autoScroll.
Moving UP between hubs showed the focused card's glow immediately: the
glow paints in the root overlay, unclipped by the rail viewport, so it
flashed over the spotlight while the target row was still offscreen.
The rail now suppresses only the glow for the 250 ms vertical scroll
and lets it fade in once the row settles. FocusGlowOverlay defers
attached-portal shows out of build, which OverlayPortalController
asserts against.
On TV the detail hero caps the description at three lines with no way to
read the rest, and the fitted metadata line sheds rating badges and
quality labels when they do not fit, leaving that information
unreachable by D-pad.
The hero information block is now a focusable target: D-pad up from the
action row lands on it, and select (or tap) opens a sheet with the
complete metadata fields, every rating badge, the genres, and the
untruncated description. Long descriptions page with D-pad up/down.
close#2042
The Android performance overlay showed only sample rate and channels for
EAC3, FLAC, DTS, TrueHD, and Opus tracks. The overlay reads the codec
from media3's Format.codecs, an RFC 6381 string only MP4/HLS provide
(hence AAC working), and the bitrate from Format.bitrate, which Matroska
carries only when the muxer wrote BPS statistics tags.
Fall back to the already-transmitted sample MIME type for the codec name
(audio and video), and measure the audio bitrate in the FFmpeg demuxer
from packet sizes over their pts span — the same source mpv uses for its
audio-bitrate property — when the container declares none.
close#2063
Hiding the chrome in the frame between the post-frame opaque mark and the
next build trusted an opaque flag the renderer never realized: hide() kept
controlsPresented while the fade-in target never rendered, so no fade-out
ran, markControlsHidden never arrived, and Back/Escape stayed swallowed
until a later show/hide cycle repaired the flag.
The controller's opaque flag now follows the real fade-in completion via
AnimatedOpacity.onEnd, so any hide it observes can rely on a fade-out that
will actually run. Branches that mount the chrome directly at full opacity
keep marking immediately, since those insertions fire no onEnd.
Backing out of a focused text field on TV fires onBack on KeyDown and arms
BackKeyUpSuppressor so the orphaned KeyUp cannot run a second back. When the
closing IME session swallowed that KeyUp entirely, the armed state persisted
and silently consumed the next back press anywhere in the app.
A matching KeyDown now proves the suppressed press ended without its KeyUp
reaching us: the arming clears and the fresh press acts normally. The select
suppressor keeps consuming KeyDowns because the hotkey recorder arms against
the very KeyDown it re-dispatches; KeyRepeat while armed is still consumed.
Jellyseerr shifts MediaStatus codes 6/7 relative to Overseerr (6=blocklisted, 7=deleted vs 6=deleted), and the shared unconditional mapping misread both. The product is now detected from the presence of mediaServerType in /settings/public, persisted on the session, and used to resolve raw wire codes; blocklisted titles are shown as non-requestable.
Request statuses failed(4) and completed(5) previously decoded as pending, blocking re-requests and mislabeling failures; both are now modeled and active requests are defined positively as pending or approved. Seerr also marks a request Failed on arr-push failure while leaving the media status Processing, and precedence checked processing first, so failed titles rendered as Processing and the request sheet kept blocking re-requests; a failed request with no live pending/approved request now wins over stale pipeline status in both the catalog state and the sheet's blocked labels.
Reauthentication completing from a stale session snapshot wholesale-replaced the session and could downgrade a concurrently detected product discriminator back to unknown permanently (the settings cache never reapplies it); adoption now merges the known product.
The non-dismissible file-info loading dialog was popped only from the card's own context; if a list refresh removed the card mid-fetch, both pop sites were skipped and the modal stuck forever. The spinner is now owned by ScopedLoadingDialogController and dismissed in a finally.
Post-restart completion recovery joined directory/filename, losing the base-directory component the Task constructor strips on custom roots. An unresolvable queue head (offline server) broke out of the drain loop, stalling every other queued download; the drain now excludes it and continues. The download tree also read all-cancelled or partial containers as completed.
The queued-download resume was a one-shot on the first online client, so a persisted row skipped because its server was offline stayed queued until restart. The resume now re-fires whenever a server comes online that the last resume did not cover (including reconnects), and a resume landing mid-drain re-drives the pass instead of being swallowed by the processing guard.
Deleting an episode fanned out one network playback-extras request per sibling row; the reference scan is now cache-only, retaining thumbnails when a sibling cannot be resolved. Season/show deletion removes rows sequentially, so that conservative retention would have counted siblings queued in the same batch - a sibling, or the container row whose extras cache is always a miss, made every episode retain its thumbnails and orphaned the files once the batch's rows were gone. The fan-out threads the batch's rating keys through so rows scheduled to disappear neither retain thumbnails nor contribute in-use paths; genuinely surviving rows still trigger retention.
A show/hide inside one frame left the chrome flagged presented with no fade to clear it, so back and Escape did nothing until a later full cycle repaired the flag; hide now retires presentation directly when the chrome never became opaque. The sync-offset rows and slider seed also read the global pref while writes and the player used the scoped store, so a title/library-scoped offset displayed as 0ms and the first slider tap jumped from the wrong baseline.
With the rail collapsed and Libraries expanded, DOWN from the header landed on a mounted but focus-excluded library node and was swallowed, trapping traversal; the focus order now matches the render exclusion and traversal scans past non-focusable candidates.
An action bar rebuilding on count change disposed every node, silently dropping focus and never notifying the TV host; bindings are now reused by stable identity (external focus node, else debugLabel) and the transition is reported. Positional reuse remains the only option for an unlabeled action, but it applies only while the action-list shape is provably unchanged - catalog detail enrichment inserting Request before a focused Trailer used to hand Trailer's focused binding, focus ring and next Select included, to Request. Those actions also carry stable debugLabels so their focus survives enrichment.
The text field consumed back on KeyDown outside the shared handler, letting one press act twice. It now marks the back coordinator like handleBackKeyAction does and keeps the down-only shape solely on TV, where the closing IME session swallows the matching KeyUp. Because onBack can move focus (empty search field -> sidebar), the orphaned KeyUp would otherwise drive app-level back on the new focus chain, so the field arms BackKeyUpSuppressor; the suppressor gained a hardware-level KeyUp observer that clears the armed state in a microtask after the press ends, removing the long-documented pinning hazard where an unconsumed KeyUp left it swallowing the next press.
Removing a tag containing a space or symbol silently did nothing: the removed values were pre-encoded and the transport encoded them again, so Plex matched nothing while the UI showed the tag gone. Folder listings and the shared list wrapper also converted every transport failure (401, 500, timeout) into a successful empty list, so an unreachable server rendered 'no folders'/'no results' with no retry while Jellyfin surfaced an error. Failures now propagate to the existing error paths, and the match screen gained one.
While a Skip Intro / Skip Credits prompt is up and the chrome is down,
Back walked the screen's staged chain and exited playback. On a remote
nothing else means "no thanks": left/right seek, OK takes the skip,
up/down raises the OSD. Declining an intro cost you the episode.
Adds a stage for it alongside the sheet and content-strip stages that
already run locally in the player controls. Gated on the same condition
as Select's skip path, off on phones (#1938), off without canControl,
and off while a screen-level prompt owns the key.
The claim is latched for the whole press: handleBackKeyAction acts on
the key-up, and the button's own 7s auto-dismiss can fire in between.
Co-authored-by: cajunflavoredbob <cajunflavoredbob@users.noreply.github.com>
A continue-watching episode card was 180dp on home but 232dp in the
grid behind "see all" at 480dp: hub rows widened the resolved poster
cell (x1.5) while grids widen the max extent (x1.8) before the integral
column packing, and the TV shelf multiplied the rail's tall card by the
same 1.5. Rows now adopt the grid's packed wide cell via
MediaGridDelegate.wideCellWidth, the TV shelf passes the hub's wide
flag to TvBrowseRailLayout.cardWidthFor, and the single widening
scheme is stated in MediaGridDelegate. Grids are untouched.
Plex "Other Videos" libraries returned items as type=movie with
subtype=clip, which mapped to MediaKind.movie and rendered 2:3 poster
cards that cropped the generated 16:9 video-frame thumbs.
Items keep the movie kind so downloads, add-to, delete-from-server, and
detail navigation stay available, but subtype=clip now renders wide with
the thumb-first artwork clips already use. Sections marked subtype=clip
in /media/providers map to the clip library kind, giving Plex home-video
libraries the same folder-first grouping and wide grid cells as
MediaBrowser homevideos views. Wide-only hubs also keep 16:9 cards in
the poster episode modes, matching the TV rail's gate.
close#2036
Closing an episode context menu (or its Rate / File Info sheets) on a
show opened from a Home section scrolled the detail page back to the
top, and on shows with many seasons jumped the season selector back to
the entered-from season. The Home path parks invisible focus on the
initial episode/season target; dismissing the menu restored focus to
that parked node, whose focus-gain auto-scroll then yanked the viewport.
Focus chrome is already keyboard-mode-only, so make the focus-gain
reveal match: FocusableWrapper, FocusableChipStateMixin, and
FocusableTileStateMixin now scroll into view only during keyboard/D-pad
sessions. The gate reads the tracker's live state (new
InputModeTracker.currentMode) because the inherited provider is one
frame stale on the first navigation key of a session. The touch OSK
search submit keeps its jump-to-results via an explicit reveal, matching
the existing pointer-mode convention of pairing requestFocus with an
explicit scroll.
close#2031
When mpv bitstreams (audio-spdif), audio-params describe the IEC 61937
carrier - 192 kHz "stereo" for E-AC-3 - so the performance overlay told
users their 5.1 track was playing as 2ch stereo. Every recent "EAC3
downgraded to PCM" report in #1300 reads back this string while the
receiver route stays healthy.
Show the source track's channel layout and sample rate instead, plus an
explicit Passthrough row naming the bitstreamed codec, on both the
mpv-channel and Android mpv-fallback stats paths.
The custom sidebar read as an ad-hoc widget: collapsed rows were the
expanded layout clipped to the strip, the libraries tree used a
staircase of indents and mixed row heights, and hover expansion pushed
the whole content area sideways.
Rebuild the rail on M3 Expressive geometry: collapsed destinations are
icon-only 56x32 pills centered in an 80px strip (48px icon-only on TV),
expanded rows are fixed-height destinations with a full-width stadium
indicator, and the two layouts crossfade/lerp during the width morph
instead of clipping. The libraries section is flattened into full-width
destination rows under a chevron header at standard destination
metrics, hover/touch expansion overlays content as a rounded, shadowed
panel over a dimmed scrim instead of pushing it, and rail width, item
morphs, content translate, and bleed all share one duration/curve.
Exiting the player after enough progress moved the played item to the front of Continue Watching left D-pad focus at its old position, now occupied by a completely different item.
TvBrowseRail and HubSection now remap the focused index to follow the focused item identity when a hub reorders underneath it, falling back to the same series replacement entry when a finished episode is swapped for the next one, and HubSection focus memory is remapped so re-entering the row lands on the same item.
close#1987
Square-card (music) grids had zero delegate spacing - the only
separation was each card's internal 3px padding, and square shapes are
excluded from the full-card spacing path, so playlist and album tiles
sat nearly edge to edge. Square grids now get an 8px cross/main gutter
(automotive keeps its larger spacing); poster, list, and full-card
layouts are unchanged.
Plex Live TV lost DVB subtitles when 2.10 moved the live path to HLS
(#1983): the transcode starts with subtitles=none, which drops a
tuner's bitmap subtitle streams from the output entirely - unlike
in-band CEA captions, they are separate elementary streams.
The tune response now surfaces the part's embedded bitmap subtitle
streams as selectable tracks in the player's subtitle menu. Picking one
points the part's server-side selection at that stream and rebuilds the
live transcode with subtitles=burn, preserving the time-shift position.
Off stays the default so tuner captions are never auto-burned (#1590),
and channels without bitmap streams keep the native track list. The
selection survives stream recovery by re-mapping onto the re-tuned
session's streams and resets on channel zap. Jellyfin live sessions
record subtitle selection as intentionally unsupported: their one
negotiated URL has no rebuild to deliver it through.
MemoryLogOutput extended LogOutput but was never wired as a Logger output (storage happens in the printer); DeletionNotifier/WatchStateNotifier's forServer/forItem filtered streams and WatchStateEvent.mediaType had no production consumers (tests now filter .stream directly); context.hiddenLibraries/profileSettings and toPlexUrl had zero call sites; MonoTokens.splashFactory was never read; OptimizedMediaImage's enableTranscoding/cacheKey chains, FocusableMediaCard.width/height/forceGridMode, TvBrowseRail's constant-zero gap functions, and media_image_helper's scaleFactor were never varied by any caller. The sha1 LRU stays (rebuild-hot artwork URLs); TvRailTrailing.none and the mono-theme copyWith stay live (lerp delegates to copyWith). Also carries the MusicPlayContext.id argument drops in the music screens and remaining sweep test updates.
classifyPosition's return enum was discarded by its only production caller (rearm is the side effect that matters); the latch's tolerance/retry knobs were never tuned; _loadAdjacentEpisodes' result was unread by all three callers; countsAsApplied carried a subsumed disjunct and _syncMediaControlsAvailability a stale-generation guard with no suspension point before it; _SettingsView.audioSync/subtitleSync could never be current (both routes early-return into the sync bar); PerformanceStats.dvRpuOutputTooSmall/dvPlaybackReason and ContentStrip.chaptersLoaded were written but never read.
TvTextInputAutoOpenBehavior.onFocus/.onFirstFocus were handled by both TV-keyboard state machines but selected by no caller; FocusableWrapper.longPressDuration duplicated the controller's own 500ms default; FocusableActionBar.mainAxisSize was never passed; FocusableActionBuildState carried focusNode/isFocused/isKeyboardMode that no builder reads (AGENTS.md updated to document the surviving showFocus/animationDuration pair). includeFocusSemantics stays — focusable_media_card passes it.
The screen's player stream subscriptions were enumerated by hand in four places (re-wire, rollback, dispose, plus a parallel nulling list) kept in sync only by prose — _cancelPlayerStreamSubscriptions is now the single cancel-and-null authority, living beside the fields. PlayerChromeFocusTarget was a one-value enum with request/consume plumbing, now a bool with identical post-frame consumption timing. The desktop tap and controls-overlay tap duplicated the click-toggles-playback/double-click-fullscreen contract around shared state, now one helper. SyncOffsetControl shipped two layouts of which production only ever rendered compact — the full layout existed solely for tests, which now cover the shipped layout. TrackControlsState's never-non-null onLoadSeekTimes/onSyncOffsetChanged sheet-compat callbacks are gone.
Grep-verified dead surface across shared widgets: the app-menu system's minWidth/maxWidth/childPadding/alignmentOffset knobs threaded through four layers with no caller ever passing them; FocusableListTile's suppressInitialSelect/hoverColor/textColor/iconColor machinery (~55 lines) reachable by no call site; OptimizedMediaImage.playlist with zero constructions; TvSpotlightBackground's primary-action button that neither caller can render; MediaGridDelegate.createDelegate whose only caller was a test (MediaGridGeometry.resolve is the single delegate composition now); and the CustomAppBar->Config->DesktopTopBar chain collapsed onto DesktopSliverAppBar with its ~11 never-passed passthrough params (snap kept — real callers pass it).