diff --git a/lib/mixins/tab_navigation_mixin.dart b/lib/mixins/tab_navigation_mixin.dart index 64d80f73..7288c465 100644 --- a/lib/mixins/tab_navigation_mixin.dart +++ b/lib/mixins/tab_navigation_mixin.dart @@ -84,7 +84,7 @@ mixin TabNavigationMixin on State, TickerProviderSt } void onTabBarBack() { - MainScreenFocusScope.of(context)?.focusSidebar(); + MainScreenFocusScope.of(context, listen: false)?.focusSidebar(); } /// Shared tab chip builder — eliminates duplication between screens. diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index a7e1c162..f41e50a7 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -316,7 +316,7 @@ class _DiscoverScreenState extends State void _focusContentFromAppBar() { if (PlatformDetector.isTV()) { - _focusTvBrowseRailWhenReady(); + _focusTvBrowseRailWhenReady(immediate: true); return; } @@ -331,7 +331,7 @@ class _DiscoverScreenState extends State } } - void _focusTvBrowseRailWhenReady() { + void _focusTvBrowseRailWhenReady({bool immediate = false}) { if (!PlatformDetector.isTV()) return; if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) { _pendingTvBrowseRailFocus = false; @@ -339,6 +339,15 @@ class _DiscoverScreenState extends State } _pendingTvBrowseRailFocus = true; + if (immediate && _tvBrowseHubs.isNotEmpty) { + final rail = _tvBrowseRailKey.currentState; + if (rail != null) { + _pendingTvBrowseRailFocus = false; + rail.requestFocus(); + return; + } + } + WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) { @@ -393,7 +402,7 @@ class _DiscoverScreenState extends State /// Navigate focus to the sidebar void _navigateToSidebar() { - MainScreenFocusScope.of(context)?.focusSidebar(); + MainScreenFocusScope.of(context, listen: false)?.focusSidebar(); } @override @@ -1552,20 +1561,23 @@ class _DiscoverScreenState extends State child: Stack( clipBehavior: Clip.none, children: [ - Positioned( - top: 0, - bottom: 0, - left: -sidebarBleed, - width: fullBleedWidth, - child: TvSpotlightBackground( - item: spotlight, - client: _getMediaClientForItem(spotlight), - hideSpoilers: hideSpoilers, - contentTop: spotlightTop, - contentBottom: spotlightBottom, - contentLeft: spotlightLeft + sidebarBleed, - compact: true, - showPrimaryAction: false, + SideNavigationBleedBuilder( + targetBleed: sidebarBleed, + builder: (context, animatedBleed, _) => Positioned( + top: 0, + bottom: 0, + left: -animatedBleed, + width: fullBleedWidth, + child: TvSpotlightBackground( + item: spotlight, + client: _getMediaClientForItem(spotlight), + hideSpoilers: hideSpoilers, + contentTop: spotlightTop, + contentBottom: spotlightBottom, + contentLeft: spotlightLeft + animatedBleed, + compact: true, + showPrimaryAction: false, + ), ), ), if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()), @@ -1617,11 +1629,11 @@ class _DiscoverScreenState extends State backgroundBleedLeft: sidebarBleed, ), ), - Positioned( - top: 0, - left: -sidebarBleed, - width: fullBleedWidth, + SideNavigationBleedBuilder( + targetBleed: sidebarBleed, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()), + builder: (context, animatedBleed, child) => + Positioned(top: 0, left: -animatedBleed, width: fullBleedWidth, child: child!), ), if (_switchingProfile) const ProfileSwitchingOverlay(), ], diff --git a/lib/screens/downloads/downloads_screen.dart b/lib/screens/downloads/downloads_screen.dart index bbbf5669..b4517f15 100644 --- a/lib/screens/downloads/downloads_screen.dart +++ b/lib/screens/downloads/downloads_screen.dart @@ -233,7 +233,7 @@ class DownloadsScreenState extends State }, onCancel: downloadProvider.cancelDownload, onDelete: downloadProvider.deleteDownload, - onNavigateLeft: () => MainScreenFocusScope.of(context)?.focusSidebar(), + onNavigateLeft: () => MainScreenFocusScope.of(context, listen: false)?.focusSidebar(), onBack: focusTabBar, suppressAutoFocus: suppressAutoFocus, ); @@ -299,7 +299,7 @@ class _DownloadsGridContentState extends State<_DownloadsGridContent> { /// Navigate focus to the sidebar void _navigateToSidebar() { - MainScreenFocusScope.of(context)?.focusSidebar(); + MainScreenFocusScope.of(context, listen: false)?.focusSidebar(); } @override diff --git a/lib/screens/libraries/tabs/library_browse_tab.dart b/lib/screens/libraries/tabs/library_browse_tab.dart index ffc493d3..172f5719 100644 --- a/lib/screens/libraries/tabs/library_browse_tab.dart +++ b/lib/screens/libraries/tabs/library_browse_tab.dart @@ -954,7 +954,7 @@ class _LibraryBrowseTabState extends BaseLibraryTabState Positioned( + top: 0, + bottom: 0, + left: -animatedBleed, + width: fullBleedWidth, + child: TvSpotlightBackground( + item: spotlight, + client: client, + hideSpoilers: svc.read(SettingsService.hideSpoilers), + contentTop: spotlightTop, + contentBottom: spotlightBottom, + contentLeft: spotlightLeft + animatedBleed, + compact: true, + showPrimaryAction: false, + ), ), ), if (tvHubs.isNotEmpty) diff --git a/lib/screens/main_screen.dart b/lib/screens/main_screen.dart index a1e04231..b24cc879 100644 --- a/lib/screens/main_screen.dart +++ b/lib/screens/main_screen.dart @@ -79,8 +79,9 @@ class MainScreenFocusScope extends InheritedWidget { required super.child, }); - static MainScreenFocusScope? of(BuildContext context) { - return context.dependOnInheritedWidgetOfExactType(); + static MainScreenFocusScope? of(BuildContext context, {bool listen = true}) { + if (listen) return context.dependOnInheritedWidgetOfExactType(); + return context.getElementForInheritedWidgetOfExactType()?.widget as MainScreenFocusScope?; } static double sideNavigationBleedOf(BuildContext context, {required bool alwaysKeepSidebarOpen}) { @@ -116,6 +117,25 @@ class MainScreenFocusScope extends InheritedWidget { } } +class SideNavigationBleedBuilder extends StatelessWidget { + final double targetBleed; + final Widget? child; + final Widget Function(BuildContext context, double bleed, Widget? child) builder; + + const SideNavigationBleedBuilder({super.key, required this.targetBleed, this.child, required this.builder}); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(end: targetBleed), + duration: const Duration(milliseconds: 200), + curve: Curves.easeOutCubic, + child: child, + builder: builder, + ); + } +} + @visibleForTesting ({double left, double width}) mainScreenSideNavigationContentLayout({ required double viewportWidth, @@ -1312,7 +1332,7 @@ class _MainScreenState extends State focusSidebar: _focusSidebar, focusContent: _focusContent, isSidebarFocused: _isSidebarFocused, - sideNavigationWidth: contentLeftPadding, + sideNavigationWidth: targetContentOffset, reservedSideNavigationWidth: reservedContentOffset, foregroundWidth: contentLayout.width, viewportWidth: viewportWidth, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index d8dcf610..4ada284a 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -113,6 +113,7 @@ class _MediaDetailScreenState extends State bool _isLoadingEpisodes = false; bool _isLoadingAllEpisodes = false; int _episodesLoadGeneration = 0; + int _tvSeasonEpisodeCacheWarmGeneration = 0; bool _showEpisodesDirectly = false; MediaItem? _fullMetadata; MediaItem? _onDeckEpisode; @@ -729,7 +730,7 @@ class _MediaDetailScreenState extends State if (_showEpisodesDirectly) return _hasLoadedEpisodes && !_isLoadingEpisodes; if (_seasons.isEmpty) return true; if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false; - return !_isLoadingSeasonEpisodes && _seasons.every((season) => _episodeCache.containsKey(season.id)); + return !_isLoadingSeasonEpisodes && _episodeCache.containsKey(_seasons[_selectedSeasonIndex].id); } if (metadata.isSeason) { @@ -1435,7 +1436,8 @@ class _MediaDetailScreenState extends State await _fetchAllEpisodes(); } else if (seasonsWithServerId.isNotEmpty) { if (PlatformDetector.isTV()) { - await _fetchTvSeasonEpisodeCaches(onDeckSeasonIndex); + await _fetchSeasonEpisodes(onDeckSeasonIndex); + unawaited(_warmTvSeasonEpisodeCaches(onDeckSeasonIndex)); } else { // Fetch episodes for the auto-selected season unawaited(_fetchSeasonEpisodes(onDeckSeasonIndex)); @@ -1640,40 +1642,42 @@ class _MediaDetailScreenState extends State _seasons[seasonIndex].id == seasonId; } - Future _fetchTvSeasonEpisodeCaches(int selectedSeasonIndex) async { + Future _warmTvSeasonEpisodeCaches(int selectedSeasonIndex) async { if (widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return; - final generation = ++_episodesLoadGeneration; - - setStateIfMounted(() { - _isLoadingSeasonEpisodes = true; - }); + final generation = ++_tvSeasonEpisodeCacheWarmGeneration; + final seasons = List.of(_seasons); + final seasonIdsToWarm = { + for (var i = 0; i < seasons.length; i++) + if (i != selectedSeasonIndex && !_episodeCache.containsKey(seasons[i].id)) seasons[i].id, + }; + if (seasonIdsToWarm.isEmpty) return; final serverId = _metadata.serverId; final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); - if (serverId == null || client == null) { - _completeTvSeasonEpisodeCaches(selectedSeasonIndex, const >{}, generation); - return; - } + if (serverId == null || client == null) return; - final seasonsById = {for (final season in _seasons) season.id: season}; + final seasonsById = {for (final season in seasons) season.id: season}; final seasonsByIndex = { - for (final season in _seasons) + for (final season in seasons) if (season.index != null) season.index!: season, }; - final episodesBySeasonId = {for (final season in _seasons) season.id: []}; + final episodesBySeasonId = { + for (final season in seasons) + if (seasonIdsToWarm.contains(season.id)) season.id: [], + }; try { var offset = 0; var total = 0; do { final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); - if (!mounted || generation != _episodesLoadGeneration) return; + if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; if (page.items.isEmpty) break; final enriched = _enrichPlayableEpisodes(page.items, serverId); for (final episode in enriched) { final seasonId = _seasonIdForEpisode(episode, seasonsById: seasonsById, seasonsByIndex: seasonsByIndex); - if (seasonId == null) continue; + if (seasonId == null || !seasonIdsToWarm.contains(seasonId)) continue; episodesBySeasonId[seasonId]?.add(episode); } @@ -1681,27 +1685,28 @@ class _MediaDetailScreenState extends State total = page.totalCount; } while (offset < total && total > 0); - _completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation); + _completeWarmedTvSeasonEpisodeCaches(seasons, episodesBySeasonId, generation); } catch (e, st) { appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st); - await _fetchTvSeasonEpisodeCachesBySeason(selectedSeasonIndex, client, serverId, generation); + await _warmTvSeasonEpisodeCachesBySeason(seasons, seasonIdsToWarm, client, serverId, generation); } } - Future _fetchTvSeasonEpisodeCachesBySeason( - int selectedSeasonIndex, + Future _warmTvSeasonEpisodeCachesBySeason( + List seasons, + Set seasonIdsToWarm, MediaServerClient client, String serverId, int generation, ) async { - final seasons = List.of(_seasons); final episodesBySeasonId = >{}; for (final season in seasons) { - if (!mounted || generation != _episodesLoadGeneration) return; + if (!seasonIdsToWarm.contains(season.id) || _episodeCache.containsKey(season.id)) continue; + if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; try { final episodes = await client.fetchChildren(season.id); - if (!mounted || generation != _episodesLoadGeneration) return; + if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; episodesBySeasonId[season.id] = episodes .map( (episode) => _withFallbackLibrary( @@ -1724,7 +1729,7 @@ class _MediaDetailScreenState extends State } } - _completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation); + _completeWarmedTvSeasonEpisodeCaches(seasons, episodesBySeasonId, generation); } String? _seasonIdForEpisode( @@ -1741,21 +1746,18 @@ class _MediaDetailScreenState extends State return null; } - void _completeTvSeasonEpisodeCaches( - int selectedSeasonIndex, + void _completeWarmedTvSeasonEpisodeCaches( + List seasons, Map> episodesBySeasonId, int generation, ) { - if (!mounted || generation != _episodesLoadGeneration) return; + if (!mounted || generation != _tvSeasonEpisodeCacheWarmGeneration) return; + final currentSeasonIds = _seasons.map((season) => season.id).toSet(); setStateIfMounted(() { - for (final season in _seasons) { + for (final season in seasons) { + if (!currentSeasonIds.contains(season.id) || _episodeCache.containsKey(season.id)) continue; _episodeCache[season.id] = List.of(episodesBySeasonId[season.id] ?? const []); } - - if (selectedSeasonIndex >= 0 && selectedSeasonIndex < _seasons.length) { - _episodes = List.of(_episodeCache[_seasons[selectedSeasonIndex].id] ?? const []); - } - _isLoadingSeasonEpisodes = false; }); } diff --git a/lib/screens/search_screen.dart b/lib/screens/search_screen.dart index 63f15b73..12aa5973 100644 --- a/lib/screens/search_screen.dart +++ b/lib/screens/search_screen.dart @@ -163,7 +163,7 @@ class _SearchScreenState extends State /// Navigate focus to the sidebar void _navigateToSidebar() { - MainScreenFocusScope.of(context)?.focusSidebar(); + MainScreenFocusScope.of(context, listen: false)?.focusSidebar(); } Widget _buildResultsList(BuildContext context) { diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index f9b614e9..eec71332 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -121,7 +121,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun } void _navigateToSidebar() { - MainScreenFocusScope.of(context)?.focusSidebar(); + MainScreenFocusScope.of(context, listen: false)?.focusSidebar(); } KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { diff --git a/lib/utils/media_navigation_helper.dart b/lib/utils/media_navigation_helper.dart index 1e4333f8..c12426ea 100644 --- a/lib/utils/media_navigation_helper.dart +++ b/lib/utils/media_navigation_helper.dart @@ -73,7 +73,7 @@ Future navigateToMediaItem( final sectionKey = mi.librarySectionKey; if (sectionKey != null && mi.serverId != null) { final libraryGlobalKey = buildGlobalKey(mi.serverId!, sectionKey); - MainScreenFocusScope.of(context)?.selectLibrary?.call(libraryGlobalKey); + MainScreenFocusScope.of(context, listen: false)?.selectLibrary?.call(libraryGlobalKey); return MediaNavigationResult.librarySelected; } return MediaNavigationResult.unsupported; diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 825bb839..4570320a 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -335,6 +335,19 @@ class TvBrowseRailState extends State { @override void didUpdateWidget(covariant TvBrowseRail oldWidget) { super.didUpdateWidget(oldWidget); + final hubStateChanged = !_hasSameHubState(oldWidget.hubs, widget.hubs); + final initialSelectionChanged = + oldWidget.initialHubId != widget.initialHubId || oldWidget.initialItemId != widget.initialItemId; + + if (!hubStateChanged && !initialSelectionChanged) { + if (!oldWidget.autofocus && widget.autofocus) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _focusNode.requestFocus(); + }); + } + return; + } + final oldActiveHubId = oldWidget.hubs.isEmpty ? null : oldWidget.hubs[_hubIndex.clamp(0, oldWidget.hubs.length - 1)].id; @@ -373,6 +386,21 @@ class TvBrowseRailState extends State { }); } + bool _hasSameHubState(List oldHubs, List newHubs) { + if (oldHubs.length != newHubs.length) return false; + for (var i = 0; i < oldHubs.length; i++) { + final oldHub = oldHubs[i]; + final newHub = newHubs[i]; + if (oldHub.id != newHub.id || oldHub.more != newHub.more || oldHub.items.length != newHub.items.length) { + return false; + } + for (var j = 0; j < oldHub.items.length; j++) { + if (oldHub.items[j].globalKey != newHub.items[j].globalKey) return false; + } + } + return true; + } + @override void dispose() { _longPressTimer?.cancel(); @@ -726,7 +754,6 @@ class TvBrowseRailState extends State { scale, ).clamp(0.0, horizontalInset).toDouble(); final width = constraints.maxWidth.isFinite ? constraints.maxWidth : MediaQuery.sizeOf(context).width; - final backgroundWidth = math.max(width + widget.backgroundBleedLeft, MediaQuery.sizeOf(context).width); final availableWidth = (width - horizontalInset).clamp(1.0, double.infinity).toDouble(); final railViewportWidth = (availableWidth + interactionExpansion).clamp(1.0, double.infinity).toDouble(); final density = svc.read(SettingsService.libraryDensity); @@ -788,20 +815,10 @@ class TvBrowseRailState extends State { child: Stack( clipBehavior: Clip.none, children: [ - Positioned( - top: 0, - bottom: 0, - left: -widget.backgroundBleedLeft, - width: backgroundWidth, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)], - ), - ), - ), + _RailBackgroundBleed( + width: width, + targetBleedLeft: widget.backgroundBleedLeft, + backgroundColor: theme.scaffoldBackgroundColor, ), Padding( padding: EdgeInsets.fromLTRB( @@ -1125,6 +1142,36 @@ class TvBrowseRailState extends State { } } +class _RailBackgroundBleed extends StatelessWidget { + final double width; + final double targetBleedLeft; + final Color backgroundColor; + + const _RailBackgroundBleed({required this.width, required this.targetBleedLeft, required this.backgroundColor}); + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(end: targetBleedLeft), + duration: FocusTheme.getAnimationDuration(context), + curve: Curves.easeOutCubic, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, backgroundColor.withValues(alpha: 0.7)], + ), + ), + ), + builder: (context, bleedLeft, child) { + final backgroundWidth = math.max(width + bleedLeft, MediaQuery.sizeOf(context).width); + return Positioned(top: 0, bottom: 0, left: -bleedLeft, width: backgroundWidth, child: child!); + }, + ); + } +} + class _RailClipper extends CustomClipper { final double leftOverflow; final double rightOverflow; diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index db248530..fffb62e0 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -1,5 +1,6 @@ import 'package:drift/native.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/connection/connection.dart'; import 'package:plezy/connection/connection_registry.dart'; @@ -189,6 +190,15 @@ void main() { await tester.pump(); expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); + await tester.pump(); + + tester.state(find.byType(FocusableActionBar)).requestFocusOnFirst(); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + (discoverKey.currentState! as FocusableTab).focusActiveTabIfReady(); (discoverKey.currentState! as TabVisibilityAware).onTabHidden(); await tester.pump(); diff --git a/test/screens/main_screen_layout_test.dart b/test/screens/main_screen_layout_test.dart index 0d8420e3..662f6616 100644 --- a/test/screens/main_screen_layout_test.dart +++ b/test/screens/main_screen_layout_test.dart @@ -1,3 +1,4 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:plezy/screens/main_screen.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; @@ -39,4 +40,42 @@ void main() { expect(expanded.width, viewportWidth - SideNavigationRailState.expandedWidth); expect(expanded.left + expanded.width, viewportWidth); }); + + testWidgets('side navigation bleed animates from the previous value', (tester) async { + Widget build(double targetBleed) { + return Directionality( + textDirection: TextDirection.ltr, + child: Stack( + children: [ + SideNavigationBleedBuilder( + targetBleed: targetBleed, + builder: (context, bleed, _) => Positioned( + key: const ValueKey('bleed-position'), + top: 0, + left: -bleed, + width: 1280, + height: 10, + child: const SizedBox.shrink(), + ), + ), + ], + ), + ); + } + + double left() => tester.widget(find.byKey(const ValueKey('bleed-position'))).left!; + + await tester.pumpWidget(build(SideNavigationRailState.tvCollapsedWidth)); + expect(left(), -SideNavigationRailState.tvCollapsedWidth); + + await tester.pumpWidget(build(SideNavigationRailState.expandedWidth)); + expect(left(), closeTo(-SideNavigationRailState.tvCollapsedWidth, 0.001)); + + await tester.pump(const Duration(milliseconds: 100)); + expect(left(), lessThan(-SideNavigationRailState.tvCollapsedWidth)); + expect(left(), greaterThan(-SideNavigationRailState.expandedWidth)); + + await tester.pumpAndSettle(); + expect(left(), closeTo(-SideNavigationRailState.expandedWidth, 0.001)); + }); } diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index e06e6b78..b7d7c414 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -35,7 +35,7 @@ void main() { TvDetectionService.debugSetAppleTVOverride(null); }); - testWidgets('TV detail reveals season hubs together after all episode caches load', (tester) async { + testWidgets('TV detail reveals selected season before remaining episode caches load', (tester) async { await SettingsService.getInstance(); final show = MediaItem( @@ -120,7 +120,7 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.text('Season 1'), findsNothing); + expect(find.text('Season 1'), findsOneWidget); expect(find.text('Season 2'), findsNothing); descendantsCompleter.complete([episode1, episode2]); diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 1d049d36..4886c860 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -696,6 +696,51 @@ void main() { expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth); expect(backgroundPosition.width, 1280); }); + + testWidgets('background bleed updates do not renotify rail focus', (tester) async { + final serverManager = MultiServerManager(); + final multiServerProvider = MultiServerProvider(serverManager, DataAggregationService(serverManager)); + addTearDown(multiServerProvider.dispose); + + final focusedItemIds = []; + final activeHubIds = []; + final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); + + Widget buildRail(double backgroundBleedLeft) { + return ChangeNotifierProvider.value( + value: multiServerProvider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + key: const ValueKey('rail'), + hubs: [hub], + iconForHub: (_, _) => Icons.movie_rounded, + backgroundBleedLeft: backgroundBleedLeft, + onFocusedItemChanged: (focused) => focusedItemIds.add(focused.id), + onActiveHubChanged: (active, _) => activeHubIds.add(active.id), + ), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildRail(0)); + await tester.pump(); + focusedItemIds.clear(); + activeHubIds.clear(); + + await tester.pumpWidget(buildRail(SideNavigationRailState.expandedWidth)); + await tester.pump(); + + expect(focusedItemIds, isEmpty); + expect(activeHubIds, isEmpty); + }); } ScrollPosition _activeRailPosition(WidgetTester tester) {