From 743a8668ec7e207e6ad59bc55038d5d040ad04e5 Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 21 May 2026 17:04:23 +0200 Subject: [PATCH] feat(tv): refine sidebar backdrop --- lib/screens/discover_screen.dart | 37 ++- lib/screens/libraries/libraries_screen.dart | 16 +- .../tabs/library_recommended_tab.dart | 30 ++- lib/screens/main_screen.dart | 1 + lib/widgets/side_navigation_rail.dart | 241 ++++++++++-------- lib/widgets/tv_browse_rail.dart | 83 +++--- lib/widgets/tv_spotlight_background.dart | 24 +- test/widgets/side_navigation_rail_test.dart | 59 +++++ 8 files changed, 312 insertions(+), 179 deletions(-) diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index d700d264..4f776a07 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -41,6 +41,7 @@ import '../providers/user_profile_provider.dart'; import '../services/storage_service.dart'; import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; +import '../widgets/side_navigation_rail.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_background.dart'; import '../mixins/refreshable.dart'; @@ -1508,20 +1509,30 @@ 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 = svc.read(SettingsService.alwaysKeepSidebarOpen) + ? 0.0 + : SideNavigationRailState.collapsedWidthForContext(context); return Material( color: theme.scaffoldBackgroundColor, child: Stack( + clipBehavior: Clip.none, children: [ - TvSpotlightBackground( - item: spotlight, - client: _getMediaClientForItem(spotlight), - hideSpoilers: hideSpoilers, - contentTop: spotlightTop, - contentBottom: spotlightBottom, - contentLeft: spotlightLeft, - compact: true, - showPrimaryAction: false, + Positioned( + top: 0, + bottom: 0, + left: -sidebarBleed, + right: 0, + child: TvSpotlightBackground( + item: spotlight, + client: _getMediaClientForItem(spotlight), + hideSpoilers: hideSpoilers, + contentTop: spotlightTop, + contentBottom: spotlightBottom, + contentLeft: spotlightLeft + sidebarBleed, + compact: true, + showPrimaryAction: false, + ), ), if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()), if (_errorMessage != null) @@ -1569,9 +1580,15 @@ class _DiscoverScreenState extends State onNavigateUp: _focusTopActions, onNavigateToSidebar: _navigateToSidebar, tallPosterScale: TvBrowseRailLayout.compactTallPosterScale, + backgroundBleedLeft: sidebarBleed, ), ), - Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())), + Positioned( + top: 0, + left: -sidebarBleed, + right: 0, + child: ExcludeFocusTraversal(child: _buildOverlaidAppBar()), + ), if (_switchingProfile) const ProfileSwitchingOverlay(), ], ), diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index e9eb74e2..21e0aa1a 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -1135,14 +1135,15 @@ class _LibrariesScreenState extends State ); } else if (selectedLibrary != null) { Widget buildTab(int index) { - return ClipRect( - child: _buildTabContent( - _visibleTabs[index], - library: selectedLibrary, - isActive: tabController.index == index, - tabIndex: index, - ), + final tabContent = _buildTabContent( + _visibleTabs[index], + library: selectedLibrary, + isActive: tabController.index == index, + tabIndex: index, ); + if (useTvRecommendedBackdrop) return tabContent; + + return ClipRect(child: tabContent); } Widget buildTabs({bool activeOnly = false}) { @@ -1169,6 +1170,7 @@ class _LibrariesScreenState extends State onKeyEvent: (_, event) => event.logicalKey.isDpadDirection ? KeyEventResult.handled : KeyEventResult.ignored, child: Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ buildTabs(activeOnly: true), Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())), diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 351df321..ee3c691b 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -16,6 +16,7 @@ import '../../../utils/provider_extensions.dart'; import '../../../utils/watch_state_notifier.dart'; import '../../../widgets/hub_section.dart'; import '../../../widgets/settings_builder.dart'; +import '../../../widgets/side_navigation_rail.dart'; import '../../../widgets/tv_browse_rail.dart'; import '../../../widgets/tv_spotlight_background.dart'; import '../../main_screen.dart'; @@ -320,22 +321,32 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState maxSpotlightBottom ? maxSpotlightBottom : desiredSpotlightBottom; final spotlightLeft = (24 * scale).clamp(18.0, 40.0).toDouble(); + final sidebarBleed = svc.read(SettingsService.alwaysKeepSidebarOpen) + ? 0.0 + : SideNavigationRailState.collapsedWidthForContext(context); return Material( color: theme.scaffoldBackgroundColor, child: SizedBox.expand( child: Stack( fit: StackFit.expand, + clipBehavior: Clip.none, children: [ - TvSpotlightBackground( - item: spotlight, - client: client, - hideSpoilers: svc.read(SettingsService.hideSpoilers), - contentTop: spotlightTop, - contentBottom: spotlightBottom, - contentLeft: spotlightLeft, - compact: true, - showPrimaryAction: false, + Positioned( + top: 0, + bottom: 0, + left: -sidebarBleed, + right: 0, + child: TvSpotlightBackground( + item: spotlight, + client: client, + hideSpoilers: svc.read(SettingsService.hideSpoilers), + contentTop: spotlightTop, + contentBottom: spotlightBottom, + contentLeft: spotlightLeft + sidebarBleed, + compact: true, + showPrimaryAction: false, + ), ), if (tvHubs.isNotEmpty) Positioned( @@ -354,6 +365,7 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState Expanded( child: IndexedStack( index: _currentIndex, + clipBehavior: Clip.none, children: [ for (var i = 0; i < _screens.length; i++) TickerMode(enabled: i == _currentIndex, child: _screens[i]), ], diff --git a/lib/widgets/side_navigation_rail.dart b/lib/widgets/side_navigation_rail.dart index 2f5a8604..f4fd012c 100644 --- a/lib/widgets/side_navigation_rail.dart +++ b/lib/widgets/side_navigation_rail.dart @@ -59,6 +59,7 @@ class NavigationRailItem extends StatelessWidget { final bool autofocus; final BorderRadius borderRadius; final double iconSize; + final double horizontalPadding; /// Called when RIGHT arrow is pressed to navigate to content area. final VoidCallback? onNavigateRight; @@ -77,6 +78,7 @@ class NavigationRailItem extends StatelessWidget { this.autofocus = false, this.borderRadius = const BorderRadius.all(Radius.circular(12)), this.iconSize = 22, + this.horizontalPadding = 17, this.onNavigateRight, }); @@ -108,6 +110,7 @@ class NavigationRailItem extends StatelessWidget { child: Container( decoration: BoxDecoration( color: () { + if (isCollapsed) return isFocused ? t.text.withValues(alpha: 0.12) : null; if (isSelected && isFocused) return t.text.withValues(alpha: 0.15); if (isSelected) return t.text.withValues(alpha: 0.1); if (isFocused) return t.text.withValues(alpha: 0.12); @@ -123,7 +126,7 @@ class NavigationRailItem extends StatelessWidget { child: SizedBox( width: SideNavigationRailState.expandedWidth - 24, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17), + padding: EdgeInsets.symmetric(vertical: 12, horizontal: horizontalPadding), child: Row( children: [ AppIcon( @@ -194,7 +197,7 @@ class SideNavigationRailState extends State with MountedSetS bool _isTouchExpanded = false; Timer? _collapseTimer; static const double collapsedWidth = 80.0; - static const double tvCollapsedWidth = 64.0; + static const double tvCollapsedWidth = 48.0; static const double expandedWidth = 220.0; static const double _horizontalPadding = 12.0; static const double _itemHorizontalPadding = 17.0; @@ -203,9 +206,18 @@ class SideNavigationRailState extends State with MountedSetS static double collapsedWidthForContext(BuildContext _) => PlatformDetector.isTV() ? tvCollapsedWidth : collapsedWidth; + static double itemHorizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) { + if (isCollapsed && PlatformDetector.isTV()) { + return ((tvCollapsedWidth - _defaultIconSize) / 2).clamp(0.0, _itemHorizontalPadding).toDouble(); + } + return _itemHorizontalPadding; + } + static double horizontalPaddingForContext(BuildContext context, {required bool isCollapsed}) { if (!isCollapsed) return _horizontalPadding; - final centeredPadding = ((collapsedWidthForContext(context) - _defaultIconSize) / 2) - _itemHorizontalPadding; + final centeredPadding = + ((collapsedWidthForContext(context) - _defaultIconSize) / 2) - + itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return centeredPadding.clamp(0.0, _horizontalPadding).toDouble(); } @@ -525,7 +537,9 @@ class SideNavigationRailState extends State with MountedSetS final isCollapsed = !_shouldExpand; final effectiveCollapsedWidth = collapsedWidthForContext(context); final horizontalPadding = horizontalPaddingForContext(context, isCollapsed: isCollapsed); + final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); final hasLiveTv = context.watch().hasLiveTv; + final surfaceOpacity = isCollapsed && PlatformDetector.isTV() ? 0.0 : 1.0; // Listen to fullscreen + groupLibrariesByServer setting so the rail // rebuilds when the user toggles "Group libraries by server" in Appearance. @@ -584,118 +598,119 @@ class SideNavigationRailState extends State with MountedSetS curve: Curves.easeOutCubic, width: isCollapsed ? effectiveCollapsedWidth : expandedWidth, clipBehavior: Clip.hardEdge, - decoration: BoxDecoration(color: t.surface), - child: IgnorePointer( - ignoring: isCollapsed, - child: Focus( - canRequestFocus: false, - skipTraversal: true, - onKeyEvent: (node, event) => _handleVerticalNavigation(node, event, focusOrder), - child: Column( - children: [ - SizedBox(height: _getTopPadding(context)), - - Expanded( - child: ListView( - padding: EdgeInsets.symmetric(horizontal: horizontalPadding), - clipBehavior: Clip.hardEdge, - children: [ - if (widget.isOfflineMode && widget.onReconnect != null) ...[ - _buildReconnectItem(isCollapsed: isCollapsed), - const SizedBox(height: 8), - ], - - if (!widget.isOfflineMode) ...[ - _buildNavItem( - icon: Symbols.home_rounded, - selectedIcon: Symbols.home_rounded, - label: Translations.of(context).common.home, - isSelected: widget.selectedTab == NavigationTabId.discover, - isFocused: _focusTracker.isFocused(_kHome), - onTap: () => widget.onDestinationSelected(NavigationTabId.discover), - focusNode: _focusTracker.get(_kHome), - isCollapsed: isCollapsed, - ), - - const SizedBox(height: 8), - - _buildLibrariesSection( - visibleRows, - hiddenRows, - hiddenLibraries.length, - t, - isCollapsed: isCollapsed, - ), - - const SizedBox(height: 8), - - if (context.watch().hasLiveTv) ...[ + decoration: const BoxDecoration(), + child: Stack( + children: [ + Positioned.fill( + child: AnimatedOpacity( + opacity: surfaceOpacity, + duration: t.normal, + curve: Curves.easeOutCubic, + child: ColoredBox(color: t.surface), + ), + ), + IgnorePointer( + ignoring: isCollapsed, + child: Focus( + canRequestFocus: false, + skipTraversal: true, + onKeyEvent: (node, event) => _handleVerticalNavigation(node, event, focusOrder), + child: Column( + children: [ + SizedBox(height: _getTopPadding(context)), + Expanded( + child: ListView( + padding: EdgeInsets.symmetric(horizontal: horizontalPadding), + clipBehavior: Clip.hardEdge, + children: [ + if (widget.isOfflineMode && widget.onReconnect != null) ...[ + _buildReconnectItem(isCollapsed: isCollapsed), + const SizedBox(height: 8), + ], + if (!widget.isOfflineMode) ...[ + _buildNavItem( + icon: Symbols.home_rounded, + selectedIcon: Symbols.home_rounded, + label: Translations.of(context).common.home, + isSelected: widget.selectedTab == NavigationTabId.discover, + isFocused: _focusTracker.isFocused(_kHome), + onTap: () => widget.onDestinationSelected(NavigationTabId.discover), + focusNode: _focusTracker.get(_kHome), + isCollapsed: isCollapsed, + ), + const SizedBox(height: 8), + _buildLibrariesSection( + visibleRows, + hiddenRows, + hiddenLibraries.length, + t, + isCollapsed: isCollapsed, + itemHorizontalPadding: itemHorizontalPadding, + ), + const SizedBox(height: 8), + if (context.watch().hasLiveTv) ...[ + _buildNavItem( + icon: Symbols.live_tv_rounded, + selectedIcon: Symbols.live_tv_rounded, + label: Translations.of(context).navigation.liveTv, + isSelected: widget.selectedTab == NavigationTabId.liveTv, + isFocused: _focusTracker.isFocused('liveTv'), + onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv), + focusNode: _focusTracker.get('liveTv'), + isCollapsed: isCollapsed, + ), + const SizedBox(height: 8), + ], + _buildNavItem( + icon: Symbols.search_rounded, + selectedIcon: Symbols.search_rounded, + label: Translations.of(context).common.search, + isSelected: widget.selectedTab == NavigationTabId.search, + isFocused: _focusTracker.isFocused(_kSearch), + onTap: () => widget.onDestinationSelected(NavigationTabId.search), + focusNode: _focusTracker.get(_kSearch), + isCollapsed: isCollapsed, + ), + const SizedBox(height: 8), + ], + // Downloads (hidden on Apple TV — no user + // file storage) + if (!PlatformDetector.isAppleTV()) ...[ + _buildNavItem( + icon: Symbols.download_rounded, + selectedIcon: Symbols.download_rounded, + label: Translations.of(context).navigation.downloads, + isSelected: widget.selectedTab == NavigationTabId.downloads, + isFocused: _focusTracker.isFocused(_kDownloads), + onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), + focusNode: _focusTracker.get(_kDownloads), + isCollapsed: isCollapsed, + ), + const SizedBox(height: 8), + ], _buildNavItem( - icon: Symbols.live_tv_rounded, - selectedIcon: Symbols.live_tv_rounded, - label: Translations.of(context).navigation.liveTv, - isSelected: widget.selectedTab == NavigationTabId.liveTv, - isFocused: _focusTracker.isFocused('liveTv'), - onTap: () => widget.onDestinationSelected(NavigationTabId.liveTv), - focusNode: _focusTracker.get('liveTv'), + icon: Symbols.settings_rounded, + selectedIcon: Symbols.settings_rounded, + label: Translations.of(context).common.settings, + isSelected: widget.selectedTab == NavigationTabId.settings, + isFocused: _focusTracker.isFocused(_kSettings), + onTap: () => widget.onDestinationSelected(NavigationTabId.settings), + focusNode: _focusTracker.get(_kSettings), isCollapsed: isCollapsed, ), - - const SizedBox(height: 8), ], - - _buildNavItem( - icon: Symbols.search_rounded, - selectedIcon: Symbols.search_rounded, - label: Translations.of(context).common.search, - isSelected: widget.selectedTab == NavigationTabId.search, - isFocused: _focusTracker.isFocused(_kSearch), - onTap: () => widget.onDestinationSelected(NavigationTabId.search), - focusNode: _focusTracker.get(_kSearch), - isCollapsed: isCollapsed, - ), - - const SizedBox(height: 8), - ], - - // Downloads (hidden on Apple TV — no user - // file storage) - if (!PlatformDetector.isAppleTV()) ...[ - _buildNavItem( - icon: Symbols.download_rounded, - selectedIcon: Symbols.download_rounded, - label: Translations.of(context).navigation.downloads, - isSelected: widget.selectedTab == NavigationTabId.downloads, - isFocused: _focusTracker.isFocused(_kDownloads), - onTap: () => widget.onDestinationSelected(NavigationTabId.downloads), - focusNode: _focusTracker.get(_kDownloads), - isCollapsed: isCollapsed, - ), - const SizedBox(height: 8), - ], - - _buildNavItem( - icon: Symbols.settings_rounded, - selectedIcon: Symbols.settings_rounded, - label: Translations.of(context).common.settings, - isSelected: widget.selectedTab == NavigationTabId.settings, - isFocused: _focusTracker.isFocused(_kSettings), - onTap: () => widget.onDestinationSelected(NavigationTabId.settings), - focusNode: _focusTracker.get(_kSettings), - isCollapsed: isCollapsed, ), - ], - ), + ), + if (_showFullscreenToggle) + Padding( + padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12), + child: _buildFullscreenItem(isCollapsed: isCollapsed), + ), + ], ), - - if (_showFullscreenToggle) - Padding( - padding: EdgeInsets.fromLTRB(horizontalPadding, 0, horizontalPadding, 12), - child: _buildFullscreenItem(isCollapsed: isCollapsed), - ), - ], + ), ), - ), + ], ), ), ), @@ -717,6 +732,7 @@ class SideNavigationRailState extends State with MountedSetS bool autofocus = false, }) { final t = tokens(context); + final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: icon, @@ -737,6 +753,7 @@ class SideNavigationRailState extends State with MountedSetS onTap: onTap, focusNode: focusNode, autofocus: autofocus, + horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } @@ -744,6 +761,7 @@ class SideNavigationRailState extends State with MountedSetS Widget _buildReconnectItem({required bool isCollapsed}) { final t = tokens(context); final isFocused = _focusTracker.isFocused(_kReconnect); + final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: widget.isReconnecting ? Symbols.sync_rounded : Symbols.wifi_rounded, @@ -761,6 +779,7 @@ class SideNavigationRailState extends State with MountedSetS // ignore: no-empty-block - no-op tap handler while reconnecting onTap: widget.isReconnecting ? () {} : () => widget.onReconnect?.call(), focusNode: _focusTracker.get(_kReconnect), + horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } @@ -769,6 +788,7 @@ class SideNavigationRailState extends State with MountedSetS final t = tokens(context); final isFullscreen = FullscreenStateManager().isFullscreen; final isFocused = _focusTracker.isFocused(_kFullscreen); + final itemHorizontalPadding = itemHorizontalPaddingForContext(context, isCollapsed: isCollapsed); return NavigationRailItem( icon: isFullscreen ? Symbols.fullscreen_exit_rounded : Symbols.fullscreen_rounded, @@ -783,6 +803,7 @@ class SideNavigationRailState extends State with MountedSetS isCollapsed: isCollapsed, onTap: () => unawaited(FullscreenStateManager().toggleFullscreen()), focusNode: _focusTracker.get(_kFullscreen), + horizontalPadding: itemHorizontalPadding, onNavigateRight: widget.onNavigateToContent, ); } @@ -793,6 +814,7 @@ class SideNavigationRailState extends State with MountedSetS int hiddenLibraryCount, dynamic t, { bool isCollapsed = false, + required double itemHorizontalPadding, }) { final librariesProvider = context.watch(); final isLoading = librariesProvider.isLoading; @@ -833,6 +855,7 @@ class SideNavigationRailState extends State with MountedSetS child: Container( decoration: BoxDecoration( color: () { + if (isCollapsed) return isLibrariesFocused ? t.text.withValues(alpha: 0.08) : null; if (isLibrariesSelected) return t.text.withValues(alpha: 0.1); if (isLibrariesFocused) return t.text.withValues(alpha: 0.08); return null; @@ -847,7 +870,7 @@ class SideNavigationRailState extends State with MountedSetS child: SizedBox( width: expandedWidth - 24, child: Padding( - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 17), + padding: EdgeInsets.symmetric(vertical: 12, horizontal: itemHorizontalPadding), child: Row( children: [ AppIcon( diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 5b82b0a7..4d4b5d26 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -256,6 +256,7 @@ class TvBrowseRail extends StatefulWidget { final bool autofocus; final EpisodePosterMode Function(MediaHub hub)? episodePosterModeForHub; final double Function(MediaHub hub)? widePosterScaleForHub; + final double backgroundBleedLeft; const TvBrowseRail({ super.key, @@ -279,6 +280,7 @@ class TvBrowseRail extends StatefulWidget { this.autofocus = false, this.episodePosterModeForHub, this.widePosterScaleForHub, + this.backgroundBleedLeft = 0, }); @override @@ -781,43 +783,56 @@ class TvBrowseRailState extends State { heightFactor: 1, child: SizedBox( height: totalHeight, - child: Container( - padding: EdgeInsets.fromLTRB( - horizontalInset, - TvBrowseRailLayout.railTopPaddingForScale(scale), - 0, - TvBrowseRailLayout.railBottomPaddingForScale(scale), - ), - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)], - ), - ), - child: AnimatedOpacity( - opacity: hasFocus ? 1 : 0.6, - duration: FocusTheme.getAnimationDuration(context), - curve: Curves.easeOutCubic, - child: ClipRect( - clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0), - child: SizedBox( - height: viewportHeight, - child: _buildHubSectionList( - context, - hasFocus: hasFocus, - modes: modes, - metricsByHub: metricsByHub, - sectionHeights: sectionHeights, - scale: scale, - leftOverflow: horizontalInset, - interactionExpansion: interactionExpansion, - railViewportWidth: railViewportWidth, - bottomPadding: bottomPadding, + child: Stack( + clipBehavior: Clip.none, + children: [ + Positioned( + top: 0, + bottom: 0, + left: -widget.backgroundBleedLeft, + right: 0, + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)], + ), ), ), ), - ), + Padding( + padding: EdgeInsets.fromLTRB( + horizontalInset, + TvBrowseRailLayout.railTopPaddingForScale(scale), + 0, + TvBrowseRailLayout.railBottomPaddingForScale(scale), + ), + child: AnimatedOpacity( + opacity: hasFocus ? 1 : 0.6, + duration: FocusTheme.getAnimationDuration(context), + curve: Curves.easeOutCubic, + child: ClipRect( + clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0, verticalOverflow: 0), + child: SizedBox( + height: viewportHeight, + child: _buildHubSectionList( + context, + hasFocus: hasFocus, + modes: modes, + metricsByHub: metricsByHub, + sectionHeights: sectionHeights, + scale: scale, + leftOverflow: horizontalInset, + interactionExpansion: interactionExpansion, + railViewportWidth: railViewportWidth, + bottomPadding: bottomPadding, + ), + ), + ), + ), + ), + ], ), ), ), diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index cf76870c..4aad1497 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -59,16 +59,7 @@ class TvSpotlightBackground extends StatelessWidget { fit: StackFit.expand, children: [ if (media != null) _buildArtwork(context, media) else ColoredBox(color: bgColor), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.centerLeft, - end: Alignment.centerRight, - colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent], - stops: const [0.0, 0.56, 1.0], - ), - ), - ), + _buildHorizontalScrim(context, bgColor), DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( @@ -150,6 +141,19 @@ class TvSpotlightBackground extends StatelessWidget { ); } + Widget _buildHorizontalScrim(BuildContext context, Color bgColor) { + return DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [bgColor.withValues(alpha: 0.86), bgColor.withValues(alpha: 0.32), Colors.transparent], + stops: const [0.0, 0.56, 1.0], + ), + ), + ); + } + Widget _buildInfo(BuildContext context, MediaItem media) { final scale = _scale(context); final shouldHideSpoiler = hideSpoilers && media.shouldHideSpoiler; diff --git a/test/widgets/side_navigation_rail_test.dart b/test/widgets/side_navigation_rail_test.dart index 66e54efb..e0132aee 100644 --- a/test/widgets/side_navigation_rail_test.dart +++ b/test/widgets/side_navigation_rail_test.dart @@ -13,6 +13,8 @@ import 'package:plezy/services/data_aggregation_service.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_tokens.dart'; +import 'package:plezy/utils/platform_detector.dart'; +import 'package:plezy/widgets/app_icon.dart'; import 'package:plezy/widgets/side_navigation_rail.dart'; import 'package:provider/provider.dart'; @@ -60,9 +62,66 @@ void main() { setUp(() { resetSharedPreferencesForTest(); SettingsService.resetForTesting(); + TvDetectionService.debugSetAppleTVOverride(null); + TvDetectionService.setForceTVSync(false); LocaleSettings.setLocaleSync(AppLocale.en); }); + testWidgets('closed TV rail is slim and keeps primary icons centered', (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); + + 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( + selectedTab: NavigationTabId.discover, + isSidebarFocused: false, + alwaysExpanded: false, + onDestinationSelected: (_) {}, + onLibrarySelected: (_) {}, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final rail = find.descendant(of: find.byType(SideNavigationRail), matching: find.byType(AnimatedContainer)).first; + expect(tester.getSize(rail).width, SideNavigationRailState.tvCollapsedWidth); + + final firstIconCenter = tester.getCenter(find.byType(AppIcon).first).dx; + expect(firstIconCenter - tester.getTopLeft(rail).dx, closeTo(SideNavigationRailState.tvCollapsedWidth / 2, 0.1)); + + final selectedItem = find.byType(NavigationRailItem).first; + final selectedItemContainer = tester.widget( + find.descendant(of: selectedItem, matching: find.byType(Container)).first, + ); + expect((selectedItemContainer.decoration as BoxDecoration?)?.color, isNull); + }); + testWidgets('D-pad down from a hidden server header focuses that hidden server library', (tester) async { await SettingsService.getInstance();