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:
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user