From 43ab5d324662f8f7b9a765a4806263e35393a6e0 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:33:07 +0200 Subject: [PATCH] perf: shrink semantics tree on media lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/screens/downloads/downloads_screen.dart | 2 + .../focusable_detail_screen_mixin.dart | 8 +++ lib/screens/hub_detail_screen.dart | 53 +++++++++++-------- .../libraries/tabs/library_browse_tab.dart | 8 +++ .../tabs/library_collections_tab.dart | 8 +++ .../libraries/tabs/library_playlists_tab.dart | 8 +++ lib/screens/media_detail_screen.dart | 6 +++ lib/screens/search_screen.dart | 35 ++++++------ lib/widgets/hub_section.dart | 4 ++ lib/widgets/optimized_media_image.dart | 6 +++ lib/widgets/tv_browse_rail.dart | 6 +++ 11 files changed, 107 insertions(+), 37 deletions(-) diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index dc262bc0..46bfc65f 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -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, diff --git a/lib/screens/focusable_detail_screen_mixin.dart b/lib/screens/focusable_detail_screen_mixin.dart index debcbc10..05da003c 100644 --- a/lib/screens/focusable_detail_screen_mixin.dart +++ b/lib/screens/focusable_detail_screen_mixin.dart @@ -181,6 +181,8 @@ mixin FocusableDetailScreenMixin on State, 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 on State, 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 on State, 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 on State, GridFocu fullBleedImage: fullCardLayout, ); return SliverGrid.builder( + addAutomaticKeepAlives: false, + addSemanticIndexes: false, gridDelegate: geometry.delegate, itemCount: totalItems, itemBuilder: (context, index) => buildTile( diff --git a/lib/screens/hub_detail_screen.dart b/lib/screens/hub_detail_screen.dart index 7d19d2dc..e02ef209 100644 --- a/lib/screens/hub_detail_screen.dart +++ b/lib/screens/hub_detail_screen.dart @@ -538,6 +538,10 @@ class _HubDetailScreenState extends State 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 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, + ), ); }, ), diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index 8dad6fbf..5f097ad1 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -1741,6 +1741,10 @@ class _LibraryBrowseTabState extends BaseLibraryTabState _buildMediaCardItem( diff --git a/lib/screens/libraries/tabs/library_collections_tab.dart b/lib/screens/libraries/tabs/library_collections_tab.dart index 66157edc..43bd7829 100644 --- a/lib/screens/libraries/tabs/library_collections_tab.dart +++ b/lib/screens/libraries/tabs/library_collections_tab.dart @@ -139,6 +139,10 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState _buildMediaCardItem(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true), @@ -158,6 +162,10 @@ class _LibraryCollectionsTabState extends BaseLibraryTabState _buildMediaCardItem( diff --git a/lib/screens/libraries/tabs/library_playlists_tab.dart b/lib/screens/libraries/tabs/library_playlists_tab.dart index aefb49cc..07b24593 100644 --- a/lib/screens/libraries/tabs/library_playlists_tab.dart +++ b/lib/screens/libraries/tabs/library_playlists_tab.dart @@ -141,6 +141,10 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState _buildPlaylistCard(index, isFirstRow: index == 0, isFirstColumn: true, disableScale: true), @@ -160,6 +164,10 @@ class _LibraryPlaylistsTabState extends BaseLibraryTabState _buildPlaylistCard( diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 5dea351e..ed45d114 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -2589,6 +2589,8 @@ class _MediaDetailScreenState extends State 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 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 child: HorizontalScrollWithArrows( controller: _extrasScrollController, builder: (scrollController) => ListView.builder( + addAutomaticKeepAlives: false, + addSemanticIndexes: false, controller: scrollController, scrollDirection: Axis.horizontal, clipBehavior: Clip.none, diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 87f98ca6..6a3e48c4 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -224,21 +224,26 @@ class _SearchScreenState extends State 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, + ), ), ); } diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index a7cfe8cf..ae5bb257 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -503,6 +503,10 @@ class HubSectionState extends State 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, diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index 8cea67d2..e1be2567 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -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, diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 85b8c76d..e0f2fa00 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -1165,6 +1165,10 @@ class TvBrowseRailState extends State { 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 { 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,