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
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.
plex_pin_auth_flow's QR knobs, ProfileNameField's navigation params, MusicDetailHeader.wideArtworkSize, and _buildReorderableList's bool were never passed; DownloadType.manage was never constructed; MediaContextMenu.onTap was declared but never invoked — removed with its four card passes (each card's own gesture handlers stay); the pin dialog's obscure chain was always true and _PinKey.label's icon branches unreachable; libraries_screen._isInitialLoad was write-only; LiveTvActionsMixin loses dead findChannel (the generic parameter stays — required by the on-clause, the report was wrong); add_jellyfin's focus-map removal branch ran only against an empty map; the recommended tab's hub-key null fallback was dead behind _ensureHubKeys. media_card also loses its always-false seasonal-rank comparison from the CatalogRankScope removal.
The catalog detail screen and the card badges each kept their own enum-to-i18n switches; the genuinely shared mappings (season name, rank scope, request state, status/format/relation labels) now live in catalog_labels.dart, while badge precedence and the deliberately different availability/4k wording stay local with comments naming the divergence.
One server with overlapping libraries (movies in both an HD and a 4K
library, shows duplicated per language) returns search results that
cannot be told apart, forcing users to open each copy to find the
right one (#1970).
Each search row now carries a source line under the summary: backend
icon, server name, and library name, shown whenever the owning server
has more than one library. Plex rows name their section inline, with
sectionKey-only rows back-filled from the loaded libraries; Jellyfin
and Emby send no library field on search hits, so search always runs
the per-library scoped fan-out that previously only served
hidden-library exclusion and stamps every hit with its library. The
Jellyfin detail lookup adds a best-effort /Items/{id}/Ancestors stamp
so full metadata stays attributed, row refreshes merge instead of
dropping the stamp, and the mapper no longer misreads SeriesStudio or
ParentId as library identity.
Episode rows trade one summary line for the source line so the
wide-thumb row keeps its height, and the badge paints slightly below
the line-box center to sit on the text's optical middle.
close#1970
Explore shelf cards drew a poster, a title and a year. An audit of all six
catalog sources found the rest was lost at two boundaries — the wire-to-DTO
mapping and the DTO-to-CatalogItem mapping — and then simply not drawn: the
grid card fell through every branch of buildMetadataSubtitle to the year-only
case, while the list card used by search already composed certification,
runtime and rating from fields the synthesized MediaItem already held.
Extend CatalogItem with the neutral facts every provider had been dropping:
attributed rating sources, leaderboard ranks that keep their season window,
audience counters that keep their timeframe, broadcast slots, next-episode air
times, server availability and request state, exact release dates, alternate
titles, format, source material, studios, countries, languages, credits, tags,
links, artwork variants, play state, gallery art and background prose. Replace
fetchCast and fetchRelated with one fetchDetail returning the enriched item,
its cast, its recommendations and labelled franchise relations without adding
a request: sources needing two calls keep two and run them concurrently with
isolated failures.
Map those fields in all six sources, widening only field selections that cost
no extra round trip — MAL's fields list, AniList's selection set and a bounded
row cast that lets detail skip its character call, Trakt's guest stars, Seerr's
language parameter and TMDB size ladder, and Plex's includeUserState. Plex hub
artwork widens only on TV, where the spotlight is its only consumer, because it
doubles the payload.
Render them: a rating-first caption and bounded badges on the shelf card,
labelled sections on the detail screen, provider hub styles and result counts
on shelves, and logo, banner and accent art in the TV spotlight.
Verified against live Plex, AniList, Simkl and MAL responses, and on a Pixel 7.
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.
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.
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.
Un-gates music libraries; adds CardShape.square plumbing (circular
artists), artists/albums/tracks browse groupings with per-grouping
sorts, audio playlists tab, artist + album detail screens with dpad
track rows, music context-menu actions, and a stub MusicPlaybackService
seam for the upcoming engine.
Profile traces showed 100-370ms UI-thread frames while scrolling the
library screen, dominated by rebuilding and inflating media cards.
- Gate per-card focus/pointer chrome on input mode: FocusableWrapper
skips the scale/border wrappers and creates its AnimationController
lazily outside keyboard mode, and ClickableCursor plus the card tap
region only build MouseRegion/InkWell machinery on desktop - TV and
touch use a bare GestureDetector. Hub cards also drop their outer
gesture wrapper outside keyboard mode; the card's own tap region
always won the gesture arena anyway.
- Memoize sliver children (SliverChildMemo): browse/collections grids
and hub rows return identical widget instances for unchanged items,
so delegate swaps from pagination, watch-state, and deletion
setStates no longer rebuild every realized card inside layout. The
browse tab prunes the memo in lockstep with focus-node eviction so a
cached card can never resurrect a disposed FocusNode.
- Budget fresh inflation (CardInflationBudget): while a scrollable is
moving in pointer/touch mode at most one new card inflates per frame,
the rest render as SkeletonMediaCard and upgrade on following frames.
Hub rows also stop pre-inflating 250px of off-screen cards on entry.
Device traces: worst frame 373ms -> 103ms, per-card build 3.6ms ->
2.4ms median; remaining row-entry work is spread across frames.
Memoize the per-image SHA-1 disk-cache key (ran on the UI thread on
every image build), fade images in via paint alpha instead of an
AnimatedSwitcher saveLayer per in-flight tile, create SettingsBuilder's
merged listenable once instead of per build, and drop the pointer
chrome (InkWell ink machinery, cursor MouseRegions) from cards on TV
where no pointer exists.
Adds context.withFreshWatchState/readFreshWatchState and sweeps the
remaining session-critical readers: context menu actions, folder tree
rows, discover hero, and TV spotlight minutes-left.
Cards consult the hierarchy-aware store so container marks flip episode
rows instantly and a stale per-episode patch can no longer shadow a newer
show/season mark; playback opens with the session-fresh resume offset.
Resolution now considers parentChain ancestors (newest event wins) and
watched patches set container leaf counts, so container marks reach
descendant cards and vice versa.