perf: shrink semantics tree on media lists

Poster images are decorative (the card exposes one merged node), and
the default per-child AutomaticKeepAlive/_SelectionKeepAlive/
IndexedSemantics wrappers are inert here — no keep-alive clients or
SelectionArea exist. The semantics phase ran ~9ms every frame on TV
devices with an accessibility service; it scales with node count.
This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent c38f24aee9
commit 43ab5d3246
11 changed files with 107 additions and 37 deletions
@@ -303,6 +303,8 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> {
);
return GridView.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
padding: effectivePadding,
// Allow focus decoration to render outside scroll bounds
clipBehavior: Clip.none,
@@ -181,6 +181,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
return SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: items.length,
itemBuilder: (context, index) {
final item = items[index];
@@ -214,6 +216,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
fullBleedImage: fullCardLayout,
);
return SliverGrid.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: items.length,
itemBuilder: (context, index) {
@@ -288,6 +292,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
return SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: totalItems,
itemBuilder: (context, index) => buildTile(index, inFirstRow: index == 0, disableScale: true),
),
@@ -305,6 +311,8 @@ mixin FocusableDetailScreenMixin<T extends StatefulWidget> on State<T>, GridFocu
fullBleedImage: fullCardLayout,
);
return SliverGrid.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: totalItems,
itemBuilder: (context, index) => buildTile(
+31 -22
View File
@@ -538,6 +538,10 @@ class _HubDetailScreenState extends State<HubDetailScreen>
return SliverPadding(
padding: const EdgeInsets.all(8),
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: _filteredItems.length,
itemBuilder: (context, index) {
final item = _filteredItems[index];
@@ -580,29 +584,34 @@ class _HubDetailScreenState extends State<HubDetailScreen>
return SliverGrid(
gridDelegate: geometry.delegate,
delegate: SliverChildBuilderDelegate((context, index) {
final item = _filteredItems[index];
final focusNode = _focusNodeForIndex(index);
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount);
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = _filteredItems[index];
final focusNode = _focusNodeForIndex(index);
final isFirstRow = GridSizeCalculator.isFirstRow(index, columnCount);
final isFirstColumn = GridSizeCalculator.isFirstColumn(index, columnCount);
return FocusableMediaCard(
focusNode: focusNode,
item: item,
onRefresh: _handleItemRefresh,
onRemoveFromContinueWatching: widget.isInContinueWatching
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: isFirstRow ? navigateToAppBar : null,
onNavigateLeft: isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub,
fullBleedImage: fullCardLayout,
);
}, childCount: _filteredItems.length),
return FocusableMediaCard(
focusNode: focusNode,
item: item,
onRefresh: _handleItemRefresh,
onRemoveFromContinueWatching: widget.isInContinueWatching
? _handleRemoveFromContinueWatching
: null,
isInContinueWatching: widget.isInContinueWatching,
usesContinueWatchingAction: widget.usesContinueWatchingAction,
onNavigateUp: isFirstRow ? navigateToAppBar : null,
onNavigateLeft: isFirstColumn ? () {} : null,
onBack: handleBackFromContent,
onFocusChange: (hasFocus) => trackGridItemFocus(index, hasFocus),
mixedHubContext: isMixedHub,
fullBleedImage: fullCardLayout,
);
},
childCount: _filteredItems.length,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
),
);
},
),
@@ -1741,6 +1741,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
return SliverPadding(
padding: .fromLTRB(8, topPadding, rightPadding, 8),
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: itemCount,
itemBuilder: (context, index) {
final child = _buildMediaCardItem(
@@ -1784,6 +1788,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState<MediaItem, LibraryBrows
itemHeight: geometry.itemHeight,
);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: itemCount,
itemBuilder: (context, index) => _buildMediaCardItem(
@@ -139,6 +139,10 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
return SliverPadding(
padding: _effectivePadding,
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: totalSize,
itemBuilder: (context, index) =>
_buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true),
@@ -158,6 +162,10 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState<MediaItem, Library
fullBleedImage: fullCardLayout,
);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: totalSize,
itemBuilder: (context, index) => _buildMediaCardItem(
@@ -141,6 +141,10 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
return SliverPadding(
padding: _effectivePadding,
sliver: SliverList.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
itemCount: totalSize,
itemBuilder: (context, index) =>
_buildPlaylistCard(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true),
@@ -160,6 +164,10 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState<MediaPlaylist, Libra
fullBleedImage: fullCardLayout,
);
return SliverGrid.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
gridDelegate: geometry.delegate,
itemCount: totalSize,
itemBuilder: (context, index) => _buildPlaylistCard(
+6
View File
@@ -2589,6 +2589,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
final client = _getMediaClientForMetadata(context);
final hasPinnedLastEpisode = _hasPinnedLastEpisodeInList;
return ListView.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: .zero,
@@ -4347,6 +4349,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: HorizontalScrollWithArrows(
controller: _castScrollController,
builder: (scrollController) => ListView.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
@@ -4440,6 +4444,8 @@ class _MediaDetailScreenState extends State<MediaDetailScreen>
child: HorizontalScrollWithArrows(
controller: _extrasScrollController,
builder: (scrollController) => ListView.builder(
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
+20 -15
View File
@@ -224,21 +224,26 @@ class _SearchScreenState extends State<SearchScreen>
return SliverPadding(
padding: const EdgeInsets.all(16),
sliver: SliverList(
delegate: SliverChildBuilderDelegate((context, index) {
final item = _searchResults[index];
return FocusableMediaCard(
key: Key(item.globalKey),
item: item,
forceListMode: true,
disableScale: true,
focusNode: index == 0 ? _firstResultFocusNode : null,
onRefresh: updateItem,
onListRefresh: () => updateItem(item.id),
onNavigateLeft: _navigateToSidebar,
onNavigateUp: index == 0 ? focusSearchInput : null,
showServerName: showServerName,
);
}, childCount: _searchResults.length),
delegate: SliverChildBuilderDelegate(
(context, index) {
final item = _searchResults[index];
return FocusableMediaCard(
key: Key(item.globalKey),
item: item,
forceListMode: true,
disableScale: true,
focusNode: index == 0 ? _firstResultFocusNode : null,
onRefresh: updateItem,
onListRefresh: () => updateItem(item.id),
onNavigateLeft: _navigateToSidebar,
onNavigateUp: index == 0 ? focusSearchInput : null,
showServerName: showServerName,
);
},
childCount: _searchResults.length,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
),
),
);
}
+4
View File
@@ -503,6 +503,10 @@ class HubSectionState extends State<HubSection> with MountedSetStateMixin {
child: HorizontalScrollWithArrows(
controller: _scrollController,
builder: (scrollController) => ListView.builder(
// Inert on media lists (no keep-alive clients): dropping the
// per-child wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
+6
View File
@@ -248,6 +248,10 @@ class OptimizedMediaImage extends StatelessWidget {
// Only cacheHeight: leaving cacheWidth null preserves decode aspect
// ratio, mirroring the network branch's ResizeImage wrapper.
cacheHeight: memHeight > 0 ? memHeight : null,
// Artwork is decorative: the enclosing card exposes one merged node
// with the title, and a per-image node just grows the semantics tree
// the TV a11y services make Flutter rebuild every frame.
excludeFromSemantics: true,
fit: fit,
filterQuality: filterQuality,
alignment: alignment,
@@ -311,6 +315,8 @@ class OptimizedMediaImage extends StatelessWidget {
image: ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider),
width: width,
height: height,
// Decorative — see the Image.file branch.
excludeFromSemantics: true,
fit: fit,
filterQuality: filterQuality,
alignment: alignment,
+6
View File
@@ -1165,6 +1165,10 @@ class TvBrowseRailState extends State<TvBrowseRail> {
key: const ValueKey('tv_browse_rail_vertical'),
controller: _verticalController,
physics: const NeverScrollableScrollPhysics(),
// Inert on media lists (no keep-alive clients): dropping the per-child
// wrappers shrinks build + semantics work per item.
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
clipBehavior: Clip.none,
padding: .only(bottom: bottomPadding),
itemExtentBuilder: (index, _) => sectionHeights[index],
@@ -1359,6 +1363,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
controller: scrollController,
scrollDirection: Axis.horizontal,
clipBehavior: Clip.none,
addAutomaticKeepAlives: false,
addSemanticIndexes: false,
padding: .fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale),
itemExtentBuilder: (itemIndex, _) => TvBrowseRailLayout.itemExtentForIndex(
hub: hub,