diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index 8024be45..deeb40e9 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -333,11 +333,18 @@ class _DiscoverScreenState extends State void _focusTvBrowseRailWhenReady() { if (!PlatformDetector.isTV()) return; - if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; + if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) { + _pendingTvBrowseRailFocus = false; + return; + } _pendingTvBrowseRailFocus = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; + if (!mounted) return; + if (!_isTabVisible || !(ModalRoute.of(context)?.isCurrent ?? false)) { + _pendingTvBrowseRailFocus = false; + return; + } if (_tvBrowseHubs.isEmpty) return; final rail = _tvBrowseRailKey.currentState; if (rail == null) return; @@ -606,6 +613,7 @@ class _DiscoverScreenState extends State @override void onTabHidden() { _isTabVisible = false; + _pendingTvBrowseRailFocus = false; _autoScrollTimer?.cancel(); _stopIndicatorProgress(); } @@ -746,14 +754,16 @@ class _DiscoverScreenState extends State // On initial load, focus the hero so the user starts on content (not the toolbar) if (!_initialLoadComplete && onDeck.isNotEmpty) { _initialLoadComplete = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; - if (PlatformDetector.isTV()) { - _tvBrowseRailKey.currentState?.requestFocus(); - } else if (_heroFocusNode.canRequestFocus) { - _heroFocusNode.requestFocus(); - } - }); + if (PlatformDetector.isTV()) { + _focusTvBrowseRailWhenReady(); + } else { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; + if (_heroFocusNode.canRequestFocus) { + _heroFocusNode.requestFocus(); + } + }); + } } // Wait for global hubs @@ -787,11 +797,7 @@ class _DiscoverScreenState extends State if (PlatformDetector.isTV() && !_initialLoadComplete && filteredHubs.isNotEmpty) { _initialLoadComplete = true; - WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && (ModalRoute.of(context)?.isCurrent ?? false)) { - _tvBrowseRailKey.currentState?.requestFocus(); - } - }); + _focusTvBrowseRailWhenReady(); } appLogger.d('Discover content loaded successfully'); @@ -1516,6 +1522,10 @@ class _DiscoverScreenState extends State final hideSpoilers = svc.read(SettingsService.hideSpoilers); final browseHubs = _tvBrowseHubs; final scale = TvLayoutConstants.scaleForSize(size); + final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf( + context, + alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen), + ); final railHeight = browseHubs.isEmpty ? 0.0 : TvBrowseRailLayout.estimateHeight( @@ -1534,10 +1544,6 @@ class _DiscoverScreenState extends State final maxSpotlightBottom = (size.height - spotlightTop - (96 * scale)).clamp(0.0, double.infinity).toDouble(); final spotlightBottom = desiredSpotlightBottom > maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); - final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf( - context, - alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen), - ); return Material( color: theme.scaffoldBackgroundColor, @@ -1548,7 +1554,7 @@ class _DiscoverScreenState extends State top: 0, bottom: 0, left: -sidebarBleed, - right: 0, + width: size.width, child: TvSpotlightBackground( item: spotlight, client: _getMediaClientForItem(spotlight), @@ -1607,12 +1613,13 @@ class _DiscoverScreenState extends State onNavigateToSidebar: _navigateToSidebar, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, backgroundBleedLeft: sidebarBleed, + visibleRightInset: sidebarBleed, ), ), Positioned( top: 0, left: -sidebarBleed, - right: 0, + width: size.width, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()), ), if (_switchingProfile) const ProfileSwitchingOverlay(), diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 98d95242..9741802b 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -302,6 +302,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); - final sidebarBleed = MainScreenFocusScope.sideNavigationBleedOf( - context, - alwaysKeepSidebarOpen: svc.read(SettingsService.alwaysKeepSidebarOpen), - ); return Material( color: theme.scaffoldBackgroundColor, @@ -336,7 +336,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState return SettingValueBuilder( pref: SettingsService.alwaysKeepSidebarOpen, builder: (context, alwaysExpanded, _) { - final targetContentLeftPadding = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded); + final targetContentOffset = _sideNavigationWidth(context, alwaysExpanded: alwaysExpanded); return OverlaySheetHost( child: PopScope( @@ -1250,7 +1254,7 @@ class _MainScreenState extends State child: TweenAnimationBuilder( duration: const Duration(milliseconds: 200), curve: Curves.easeOutCubic, - tween: Tween(end: targetContentLeftPadding), + tween: Tween(end: targetContentOffset), child: FocusScope( node: _contentFocusScope, // No autofocus - we control focus programmatically to prevent @@ -1265,43 +1269,50 @@ class _MainScreenState extends State sideNavigationWidth: contentLeftPadding, selectLibrary: _selectLibrary, child: SideNavigationScope( - child: Stack( - children: [ - Positioned.fill( - child: Padding( - padding: EdgeInsets.only(left: contentLeftPadding), - child: contentChild!, - ), - ), - Positioned( - top: 0, - bottom: 0, - left: 0, - child: FocusScope( - node: _sidebarFocusScope, - child: SideNavigationRail( - key: _sideNavKey, - selectedTab: _currentTab, - selectedLibraryKey: _selectedLibraryGlobalKey, - isOfflineMode: _isOffline, - isSidebarFocused: _isSidebarFocused, - alwaysExpanded: alwaysExpanded, - isReconnecting: _isReconnecting, - onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged, - onDestinationSelected: (tab) { - _selectTab(tab); - _focusContent(); - }, - onLibrarySelected: (key) { - _selectLibrary(key); - _focusContent(); - }, - onNavigateToContent: _focusContent, - onReconnect: _triggerReconnect, + child: LayoutBuilder( + builder: (context, constraints) { + final contentWidth = constraints.maxWidth; + return Stack( + clipBehavior: Clip.hardEdge, + children: [ + Positioned( + top: 0, + bottom: 0, + left: contentLeftPadding, + width: contentWidth, + child: contentChild!, ), - ), - ), - ], + Positioned( + top: 0, + bottom: 0, + left: 0, + child: FocusScope( + node: _sidebarFocusScope, + child: SideNavigationRail( + key: _sideNavKey, + selectedTab: _currentTab, + selectedLibraryKey: _selectedLibraryGlobalKey, + isOfflineMode: _isOffline, + isSidebarFocused: _isSidebarFocused, + alwaysExpanded: alwaysExpanded, + isReconnecting: _isReconnecting, + onInteractionExpandedChanged: _handleSidebarInteractionExpandedChanged, + onDestinationSelected: (tab) { + _selectTab(tab); + _focusContent(); + }, + onLibrarySelected: (key) { + _selectLibrary(key); + _focusContent(); + }, + onNavigateToContent: _focusContent, + onReconnect: _triggerReconnect, + ), + ), + ), + ], + ); + }, ), ), ); diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index 33ea2fa5..d8dcf610 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -1642,6 +1642,7 @@ class _MediaDetailScreenState extends State Future _fetchTvSeasonEpisodeCaches(int selectedSeasonIndex) async { if (widget.isOffline || _showEpisodesDirectly || _seasons.isEmpty) return; + final generation = ++_episodesLoadGeneration; setStateIfMounted(() { _isLoadingSeasonEpisodes = true; @@ -1650,7 +1651,7 @@ class _MediaDetailScreenState extends State final serverId = _metadata.serverId; final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); if (serverId == null || client == null) { - _completeTvSeasonEpisodeCaches(selectedSeasonIndex, const >{}); + _completeTvSeasonEpisodeCaches(selectedSeasonIndex, const >{}, generation); return; } @@ -1666,7 +1667,7 @@ class _MediaDetailScreenState extends State var total = 0; do { final page = await client.fetchPlayableDescendantsPage(_metadata.id, start: offset, size: _episodesPageSize); - if (!mounted) return; + if (!mounted || generation != _episodesLoadGeneration) return; if (page.items.isEmpty) break; final enriched = _enrichPlayableEpisodes(page.items, serverId); @@ -1680,13 +1681,52 @@ class _MediaDetailScreenState extends State total = page.totalCount; } while (offset < total && total > 0); - _completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId); + _completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation); } catch (e, st) { appLogger.w('Failed to load TV season episode caches', error: e, stackTrace: st); - _completeTvSeasonEpisodeCaches(selectedSeasonIndex, const >{}); + await _fetchTvSeasonEpisodeCachesBySeason(selectedSeasonIndex, client, serverId, generation); } } + Future _fetchTvSeasonEpisodeCachesBySeason( + int selectedSeasonIndex, + MediaServerClient client, + String serverId, + int generation, + ) async { + final seasons = List.of(_seasons); + final episodesBySeasonId = >{}; + + for (final season in seasons) { + if (!mounted || generation != _episodesLoadGeneration) return; + try { + final episodes = await client.fetchChildren(season.id); + if (!mounted || generation != _episodesLoadGeneration) return; + episodesBySeasonId[season.id] = episodes + .map( + (episode) => _withFallbackLibrary( + episode.copyWith( + serverId: serverId, + serverName: _metadata.serverName ?? episode.serverName, + grandparentId: _metadata.id, + grandparentTitle: _metadata.title ?? episode.grandparentTitle, + parentId: season.id, + parentIndex: season.index ?? episode.parentIndex, + ), + season.libraryId != null ? season : _metadata, + ), + ) + .map(_applyLocalProgress) + .toList(); + } catch (e, st) { + appLogger.w('Failed to load TV season episodes for ${season.id}', error: e, stackTrace: st); + episodesBySeasonId[season.id] = const []; + } + } + + _completeTvSeasonEpisodeCaches(selectedSeasonIndex, episodesBySeasonId, generation); + } + String? _seasonIdForEpisode( MediaItem episode, { required Map seasonsById, @@ -1701,7 +1741,12 @@ class _MediaDetailScreenState extends State return null; } - void _completeTvSeasonEpisodeCaches(int selectedSeasonIndex, Map> episodesBySeasonId) { + void _completeTvSeasonEpisodeCaches( + int selectedSeasonIndex, + Map> episodesBySeasonId, + int generation, + ) { + if (!mounted || generation != _episodesLoadGeneration) return; setStateIfMounted(() { for (final season in _seasons) { _episodeCache[season.id] = List.of(episodesBySeasonId[season.id] ?? const []); diff --git a/lib/screens/settings/settings_screen.dart b/lib/screens/settings/settings_screen.dart index f9b614e9..d8fd4cc2 100644 --- a/lib/screens/settings/settings_screen.dart +++ b/lib/screens/settings/settings_screen.dart @@ -136,6 +136,7 @@ class _SettingsScreenState extends State with FocusableTab, Moun @override Widget build(BuildContext context) { + final rightInset = MainScreenFocusScope.clippedContentRightInsetOf(context); return Scaffold( body: Focus( onKeyEvent: _handleKeyEvent, @@ -143,39 +144,42 @@ class _SettingsScreenState extends State with FocusableTab, Moun primary: false, slivers: [ ExcludeFocus(child: CustomAppBar(title: Text(t.settings.title), pinned: true)), - SliverList( - delegate: SliverChildListDelegate([ - if (DonationService.isEnabled) _buildDonateTile(), + SliverPadding( + padding: EdgeInsets.only(right: rightInset), + sliver: SliverList( + delegate: SliverChildListDelegate([ + if (DonationService.isEnabled) _buildDonateTile(), - _buildAppearanceTile(), + _buildAppearanceTile(), - _buildPlaybackTile(), + _buildPlaybackTile(), - _buildTrackersTile(), + _buildTrackersTile(), - _buildConnectionsSection(), + _buildConnectionsSection(), - _buildProfilesSection(), + _buildProfilesSection(), - if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), + if (!PlatformDetector.isAppleTV()) _buildDownloadsSection(), - if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()], + if (_keyboardShortcutsSupported) ...[_buildKeyboardShortcutsSection()], - _buildAdvancedSection(), + _buildAdvancedSection(), - if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], + if (UpdateService.isUpdateCheckEnabled) ...[_buildUpdateSection()], - if (!PlatformDetector.isTV()) _buildBackupSection(), + if (!PlatformDetector.isTV()) _buildBackupSection(), - SettingNavigationTile( - focusNode: _focusTracker.get(_kAbout), - icon: Symbols.info_rounded, - title: t.settings.about, - subtitle: t.settings.aboutDescription, - destinationBuilder: (context) => const AboutScreen(), - ), - const SizedBox(height: 24), - ]), + SettingNavigationTile( + focusNode: _focusTracker.get(_kAbout), + icon: Symbols.info_rounded, + title: t.settings.about, + subtitle: t.settings.aboutDescription, + destinationBuilder: (context) => const AboutScreen(), + ), + const SizedBox(height: 24), + ]), + ), ), ], ), diff --git a/lib/utils/scroll_utils.dart b/lib/utils/scroll_utils.dart index 7cee80a4..de7d50e1 100644 --- a/lib/utils/scroll_utils.dart +++ b/lib/utils/scroll_utils.dart @@ -59,6 +59,7 @@ void scrollListToIndex( int index, { required double itemExtent, double leadingPadding = 12.0, + double visibleTrailingInset = 0.0, bool animate = true, }) { if (controller.positions.length != 1 || itemExtent <= 0) return; @@ -67,7 +68,8 @@ void scrollListToIndex( final maxExtent = controller.position.maxScrollExtent; if (!viewport.isFinite || !maxExtent.isFinite) return; final targetCenter = leadingPadding + (index * itemExtent) + (itemExtent / 2); - final desiredOffset = (targetCenter - (viewport / 2)).clamp(0.0, maxExtent); + final visibleViewport = (viewport - visibleTrailingInset).clamp(1.0, double.infinity).toDouble(); + final desiredOffset = (targetCenter - (visibleViewport / 2)).clamp(0.0, maxExtent); if (animate) { controller.animateTo(desiredOffset, duration: const Duration(milliseconds: 150), curve: Curves.easeOut); diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 16dec70c..cb2184c2 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -19,6 +19,7 @@ import '../media/media_hub.dart'; import '../media/media_item.dart'; import '../mixins/mounted_set_state_mixin.dart'; import '../screens/hub_detail_screen.dart'; +import '../screens/main_screen.dart'; import '../utils/media_navigation_helper.dart'; import 'focus_builders.dart'; import 'media_card.dart'; @@ -106,6 +107,7 @@ class HubSectionState extends State with MountedSetStateMixin { ? TvLayoutConstants.shelfHorizontalInset : 12.0; double get _leadingPadding => _leadingPaddingFor(PlatformDetector.isTV()); + double get _visibleRightInset => MainScreenFocusScope.clippedContentRightInsetOf(context); Timer? _longPressTimer; bool _isSelectKeyDown = false; @@ -209,6 +211,7 @@ class HubSectionState extends State with MountedSetStateMixin { index, itemExtent: _itemExtent, leadingPadding: _leadingPadding, + visibleTrailingInset: _visibleRightInset, animate: animate, ); } @@ -484,8 +487,13 @@ class HubSectionState extends State with MountedSetStateMixin { scrollDirection: Axis.horizontal, clipBehavior: Clip.none, padding: widget.inset - ? EdgeInsets.symmetric(vertical: isTv ? 6 : 2) - : EdgeInsets.symmetric(horizontal: isTv ? leadingPadding : 8, vertical: isTv ? 6 : 2), + ? EdgeInsets.fromLTRB(0, isTv ? 6 : 2, _visibleRightInset + 4, isTv ? 6 : 2) + : EdgeInsets.fromLTRB( + isTv ? leadingPadding : 8, + isTv ? 6 : 2, + (isTv ? leadingPadding : 8) + _visibleRightInset, + isTv ? 6 : 2, + ), itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length, itemBuilder: (context, index) { final isItemFocused = hasFocus && index == _focusedIndex; diff --git a/lib/widgets/settings_page.dart b/lib/widgets/settings_page.dart index c3b4f59d..bec971de 100644 --- a/lib/widgets/settings_page.dart +++ b/lib/widgets/settings_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import '../screens/main_screen.dart'; import 'focused_scroll_scaffold.dart'; /// Standard scaffold for settings pages made of ordinary list rows. @@ -38,13 +39,23 @@ class SettingsPage extends StatelessWidget { @override Widget build(BuildContext context) { final pageSlivers = slivers ?? [_buildListSliver()]; + final rightInset = MainScreenFocusScope.clippedContentRightInsetOf(context); + final insetSlivers = rightInset == 0 + ? pageSlivers + : [ + for (final sliver in pageSlivers) + SliverPadding( + padding: EdgeInsets.only(right: rightInset), + sliver: sliver, + ), + ]; return FocusedScrollScaffold( title: title, actions: actions, pinned: pinned, automaticallyImplyLeading: automaticallyImplyLeading, onBackPressed: onBackPressed, - slivers: pageSlivers, + slivers: insetSlivers, ); } diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 4be3cd0f..f354798c 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -248,6 +248,8 @@ class SideNavigationRailState extends State with MountedSetS bool get _interactionExpanded => _isHovered || _isTouchExpanded; + bool get _showDownloads => !PlatformDetector.isAppleTV(); + /// macOS has the system green button; mobile/TV have no OS fullscreen toggle. bool get _showFullscreenToggle => Platform.isWindows || Platform.isLinux; @@ -366,7 +368,7 @@ class SideNavigationRailState extends State with MountedSetS _kHome, _kLibraries, _kSearch, - _kDownloads, + if (_showDownloads) _kDownloads, _kSettings, _kReconnect, if (hasHiddenLibraries) _kHiddenLibraries, @@ -453,7 +455,7 @@ class SideNavigationRailState extends State with MountedSetS if (hasLiveTv) 'liveTv', _kSearch, ], - _kDownloads, + if (_showDownloads) _kDownloads, _kSettings, if (_showFullscreenToggle) _kFullscreen, ]; @@ -711,7 +713,7 @@ class SideNavigationRailState extends State with MountedSetS ], // Downloads (hidden on Apple TV — no user // file storage) - if (!PlatformDetector.isAppleTV()) ...[ + if (_showDownloads) ...[ _buildNavItem( icon: Symbols.download_rounded, selectedIcon: Symbols.download_rounded, diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 4d4b5d26..d248c5a3 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -181,10 +181,11 @@ class TvBrowseRailLayout { required TvBrowseRailLayoutMetrics metrics, required double viewportWidth, required double scale, + double visibleTrailingInset = 0.0, }) { final itemContentWidth = hub.items.length * (metrics.cardWidth + metrics.itemGap); final moreContentWidth = hub.more ? (132 * scale) + metrics.itemGap : 0.0; - final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth; + final contentWidth = (metrics.railEdgePadding * 2) + itemContentWidth + moreContentWidth + visibleTrailingInset; return (contentWidth - viewportWidth).clamp(0.0, double.infinity).toDouble(); } @@ -193,10 +194,12 @@ class TvBrowseRailLayout { required TvBrowseRailLayoutMetrics metrics, required double viewportWidth, required double maxScrollExtent, + double visibleTrailingInset = 0.0, }) { final itemExtent = metrics.cardWidth + metrics.itemGap; final targetCenter = metrics.railEdgePadding + (index * itemExtent) + (itemExtent / 2); - return (targetCenter - (viewportWidth / 2)).clamp(0.0, maxScrollExtent).toDouble(); + final visibleViewport = (viewportWidth - visibleTrailingInset).clamp(1.0, double.infinity).toDouble(); + return (targetCenter - (visibleViewport / 2)).clamp(0.0, maxScrollExtent).toDouble(); } static double estimateHeight({ @@ -257,6 +260,7 @@ class TvBrowseRail extends StatefulWidget { final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub; final double Function(MediaHub hub)? widePosterScaleForHub; final double backgroundBleedLeft; + final double visibleRightInset; const TvBrowseRail({ super.key, @@ -281,6 +285,7 @@ class TvBrowseRail extends StatefulWidget { this.episodePosterModeForHub, this.widePosterScaleForHub, this.backgroundBleedLeft = 0, + this.visibleRightInset = 0, }); @override @@ -625,6 +630,7 @@ class TvBrowseRailState extends State { _itemIndex, itemExtent: _itemExtent, leadingPadding: _railLeadingPadding, + visibleTrailingInset: widget.visibleRightInset, animate: animate, ); } @@ -649,12 +655,14 @@ class TvBrowseRailState extends State { metrics: metrics, viewportWidth: viewportWidth, scale: scale, + visibleTrailingInset: widget.visibleRightInset, ); final initialScrollOffset = TvBrowseRailLayout.scrollOffsetForIndex( index: initialItemIndex, metrics: metrics, viewportWidth: viewportWidth, maxScrollExtent: maxScrollExtent, + visibleTrailingInset: widget.visibleRightInset, ); return ScrollController(initialScrollOffset: initialScrollOffset); }); @@ -790,7 +798,7 @@ class TvBrowseRailState extends State { top: 0, bottom: 0, left: -widget.backgroundBleedLeft, - right: 0, + width: width, child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -979,7 +987,12 @@ class TvBrowseRailState extends State { controller: scrollController, scrollDirection: Axis.horizontal, clipBehavior: Clip.none, - padding: EdgeInsets.fromLTRB(metrics.railEdgePadding, 2 * scale, metrics.railEdgePadding, 6 * scale), + padding: EdgeInsets.fromLTRB( + metrics.railEdgePadding, + 2 * scale, + metrics.railEdgePadding + widget.visibleRightInset, + 6 * scale, + ), itemCount: totalCount, itemBuilder: (context, itemIndex) { final isFocused = hasFocus && isActiveHub && itemIndex == _itemIndex; diff --git a/test/screens/discover_screen_test.dart b/test/screens/discover_screen_test.dart index 364999db..f6da1c10 100644 --- a/test/screens/discover_screen_test.dart +++ b/test/screens/discover_screen_test.dart @@ -13,6 +13,7 @@ import 'package:plezy/media/media_kind.dart'; import 'package:plezy/media/media_server_client.dart'; import 'package:plezy/media/server_capabilities.dart'; import 'package:plezy/mixins/refreshable.dart'; +import 'package:plezy/mixins/tab_visibility_aware.dart'; import 'package:plezy/profiles/active_profile_provider.dart'; import 'package:plezy/profiles/plex_home_service.dart'; import 'package:plezy/profiles/profile.dart'; @@ -55,7 +56,14 @@ void main() { }); testWidgets('TV tab focus returns to discover browse rail instead of reload action', (tester) async { - await SettingsService.getInstance(); + final settings = await SettingsService.getInstance(); + await settings.write(SettingsService.libraryDensity, LibraryDensity.max); + tester.view.devicePixelRatio = 1.0; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(() { + tester.view.resetDevicePixelRatio(); + tester.view.resetPhysicalSize(); + }); final item = MediaItem( id: 'movie_1', @@ -92,6 +100,7 @@ void main() { storage: storage, ); final discoverKey = GlobalKey>(); + const sidebarOffset = SideNavigationRailState.expandedWidth; addTearDown(() async { activeProfileProvider.dispose(); @@ -121,7 +130,7 @@ void main() { focusSidebar: () {}, focusContent: () {}, isSidebarFocused: false, - sideNavigationWidth: SideNavigationRailState.expandedWidth, + sideNavigationWidth: sidebarOffset, child: SizedBox(width: 1280, height: 720, child: DiscoverScreen(key: discoverKey)), ), ), @@ -135,21 +144,50 @@ void main() { final scale = TvLayoutConstants.scaleForSize(const Size(1280, 720)); final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); final spotlightBackground = tester.widget(find.byType(TvSpotlightBackground)); - expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + SideNavigationRailState.expandedWidth, 0.001)); - expect( - tester.widget(find.byType(TvBrowseRail)).backgroundBleedLeft, - SideNavigationRailState.expandedWidth, + expect(spotlightBackground.contentLeft, closeTo(spotlightLeft + sidebarOffset, 0.001)); + + final railHeight = TvBrowseRailLayout.estimateHeight( + size: const Size(1280, 720), + hubs: [hub], + density: LibraryDensity.max, + episodePosterMode: settings.read(SettingsService.episodePosterMode), + tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, ); + final minimumSpotlightBottom = railHeight + (8 * scale); + final baseSpotlightBottom = (720 * 0.48).clamp(160.0, 820.0).toDouble(); + final desiredSpotlightBottom = minimumSpotlightBottom > baseSpotlightBottom + ? minimumSpotlightBottom + : baseSpotlightBottom; + final maxSpotlightBottom = (720 - ((720 * 0.075).clamp(64.0 * scale, 120.0 * scale)) - (96 * scale)) + .clamp(0.0, double.infinity) + .toDouble(); + final expectedSpotlightBottom = desiredSpotlightBottom > maxSpotlightBottom + ? maxSpotlightBottom + : desiredSpotlightBottom; + expect(spotlightBackground.contentBottom, closeTo(expectedSpotlightBottom, 0.001)); + + final browseRail = tester.widget(find.byType(TvBrowseRail)); + expect(browseRail.backgroundBleedLeft, sidebarOffset); + expect(browseRail.visibleRightInset, sidebarOffset); final backgroundPosition = tester.widget( find.ancestor(of: find.byType(TvSpotlightBackground), matching: find.byType(Positioned)).first, ); - expect(backgroundPosition.left, -SideNavigationRailState.expandedWidth); + expect(backgroundPosition.left, -sidebarOffset); + expect(backgroundPosition.width, 1280); 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(); + await tester.pump(); + + expect(FocusManager.instance.primaryFocus?.debugLabel, 'ActionBar[0]'); + + (discoverKey.currentState! as TabVisibilityAware).onTabShown(); (discoverKey.currentState! as FocusableTab).focusActiveTabIfReady(); await tester.pump(); await tester.pump(); diff --git a/test/screens/media_detail_screen_test.dart b/test/screens/media_detail_screen_test.dart index 005b5fb7..e06e6b78 100644 --- a/test/screens/media_detail_screen_test.dart +++ b/test/screens/media_detail_screen_test.dart @@ -130,17 +130,109 @@ void main() { expect(find.text('Season 1'), findsOneWidget); expect(find.text('Season 2'), findsOneWidget); }); + + testWidgets('TV detail falls back to per-season episodes when descendant cache fails', (tester) async { + await SettingsService.getInstance(); + + final show = MediaItem( + id: 'show_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.show, + title: 'The Show', + serverId: 'server_1', + serverName: 'Server', + ); + final season1 = MediaItem( + id: 'season_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 1', + index: 1, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final season2 = MediaItem( + id: 'season_2', + backend: MediaBackend.jellyfin, + kind: MediaKind.season, + title: 'Season 2', + index: 2, + parentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final episode1 = MediaItem( + id: 'episode_1', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode 1', + index: 1, + parentId: season1.id, + parentIndex: season1.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + final episode2 = MediaItem( + id: 'episode_2', + backend: MediaBackend.jellyfin, + kind: MediaKind.episode, + title: 'Episode 2', + index: 1, + parentId: season2.id, + parentIndex: season2.index, + grandparentId: show.id, + serverId: show.serverId, + serverName: show.serverName, + ); + + final client = _FakeMediaServerClient( + show: show, + childrenByParent: { + show.id: [season1, season2], + season1.id: [episode1], + season2.id: [episode2], + }, + playableDescendantsError: Exception('descendant cache failed'), + ); + final manager = MultiServerManager()..debugRegisterClientForTesting(client); + final provider = MultiServerProvider(manager, DataAggregationService(manager)); + addTearDown(provider.dispose); + + await tester.pumpWidget( + TranslationProvider( + child: ChangeNotifierProvider.value( + value: provider, + child: MaterialApp( + theme: monoTheme(dark: true), + home: SizedBox(width: 1280, height: 720, child: MediaDetailScreen(metadata: show)), + ), + ), + ), + ); + + await tester.pump(); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 200)); + + expect(find.text('Season 1'), findsOneWidget); + expect(find.text('Season 2'), findsOneWidget); + }); } class _FakeMediaServerClient implements MediaServerClient { final MediaItem show; final Map> childrenByParent; - final Future> pendingPlayableDescendants; + final Future>? pendingPlayableDescendants; + final Object? playableDescendantsError; _FakeMediaServerClient({ required this.show, required this.childrenByParent, - required this.pendingPlayableDescendants, + this.pendingPlayableDescendants, + this.playableDescendantsError, }); @override @@ -169,7 +261,9 @@ class _FakeMediaServerClient implements MediaServerClient { int? size, AbortController? abort, }) async { - final items = await pendingPlayableDescendants; + final error = playableDescendantsError; + if (error != null) throw error; + final items = await pendingPlayableDescendants!; return LibraryPage(items: items, totalCount: items.length, offset: start ?? 0); } diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 709b9226..0b7be0db 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -239,6 +239,64 @@ void main() { expect(reports.last, isFalse); }); + testWidgets('Apple TV D-pad focus skips hidden downloads item', (tester) async { + TvDetectionService.debugSetAppleTVOverride(true); + addTearDown(() => TvDetectionService.debugSetAppleTVOverride(null)); + await SettingsService.getInstance(); + + final librariesProvider = LibrariesProvider(); + addTearDown(librariesProvider.dispose); + + final hiddenLibrariesProvider = HiddenLibrariesProvider(); + await hiddenLibrariesProvider.ensureInitialized(); + addTearDown(hiddenLibrariesProvider.dispose); + + final manager = MultiServerManager(); + final aggregation = DataAggregationService(manager); + final multiServerProvider = MultiServerProvider(manager, aggregation); + addTearDown(multiServerProvider.dispose); + + final sideNavKey = GlobalKey(); + NavigationTabId? selectedTab; + + await tester.pumpWidget( + TranslationProvider( + child: MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: librariesProvider), + ChangeNotifierProvider.value(value: hiddenLibrariesProvider), + ChangeNotifierProvider.value(value: multiServerProvider), + ], + child: MaterialApp( + theme: ThemeData(extensions: const [_testTokens]), + home: Scaffold( + body: SideNavigationRail( + key: sideNavKey, + selectedTab: NavigationTabId.discover, + isSidebarFocused: true, + alwaysExpanded: true, + onDestinationSelected: (tab) => selectedTab = tab, + onLibrarySelected: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + sideNavKey.currentState!.focusActiveItem(); + await tester.pumpAndSettle(); + + // Home -> Libraries -> Search -> Settings. Downloads is hidden on Apple TV. + await _press(tester, LogicalKeyboardKey.arrowDown); + await _press(tester, LogicalKeyboardKey.arrowDown); + await _press(tester, LogicalKeyboardKey.arrowDown); + await _press(tester, LogicalKeyboardKey.enter); + + expect(selectedTab, NavigationTabId.settings); + }); + testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async { await SettingsService.getInstance();