From 7690a16efef1781fbc467b1fb6ff573c7366d97b Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Sat, 16 May 2026 11:56:12 +0200 Subject: [PATCH] feat(tv): add spotlight browse layouts --- lib/screens/discover_screen.dart | 696 ++++++++---- lib/screens/libraries/libraries_screen.dart | 134 ++- .../tabs/library_recommended_tab.dart | 90 ++ lib/screens/media_detail/action_buttons.dart | 84 +- lib/screens/media_detail_screen.dart | 937 ++++++++++++--- lib/utils/layout_constants.dart | 17 + lib/widgets/focusable_tab_chip.dart | 10 +- lib/widgets/hub_section.dart | 386 ++++--- lib/widgets/tv_browse_rail.dart | 1005 +++++++++++++++++ lib/widgets/tv_spotlight_background.dart | 325 ++++++ test/widgets/tv_browse_rail_test.dart | 153 +++ 11 files changed, 3220 insertions(+), 617 deletions(-) create mode 100644 lib/widgets/tv_browse_rail.dart create mode 100644 lib/widgets/tv_spotlight_background.dart create mode 100644 test/widgets/tv_browse_rail_test.dart diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index b4e5bd57..b4d441aa 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -40,6 +40,8 @@ import '../providers/user_profile_provider.dart'; import '../services/storage_service.dart'; import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; +import '../widgets/tv_browse_rail.dart'; +import '../widgets/tv_spotlight_background.dart'; import '../mixins/refreshable.dart'; import '../mixins/tab_visibility_aware.dart'; import '../i18n/strings.g.dart'; @@ -130,6 +132,8 @@ class _DiscoverScreenState extends State Timer? _indicatorTimer; final ValueNotifier _indicatorProgress = ValueNotifier(0.0); bool _isAutoScrollPaused = false; + bool _heroFocusPausedAutoScroll = false; + MediaItem? _spotlightItem; bool _isTabVisible = true; HiddenLibrariesProvider? _hiddenLibrariesProvider; LibrariesProvider? _librariesProvider; @@ -194,6 +198,7 @@ class _DiscoverScreenState extends State // Hub navigation keys GlobalKey? _continueWatchingHubKey; final List> _hubKeys = []; + final _tvBrowseRailKey = GlobalKey(); // Hero and app bar focus late FocusNode _heroFocusNode; @@ -234,22 +239,85 @@ class _DiscoverScreenState extends State bool get _isHeroSectionVisible => _onDeck.isNotEmpty && context.settingsRead(SettingsService.showHeroSection); + MediaItem? get _defaultSpotlightItem { + if (_onDeck.isNotEmpty) return _onDeck.first; + for (final hub in _hubs) { + if (hub.items.isNotEmpty) return hub.items.first; + } + return null; + } + + List get _tvBrowseHubs { + final hubs = []; + if (_onDeck.isNotEmpty) { + hubs.add( + MediaHub( + id: 'continue_watching', + title: t.discover.continueWatching, + type: 'mixed', + identifier: '_continue_watching_', + size: _onDeck.length + (_hasMoreContinueWatching ? 1 : 0), + more: _hasMoreContinueWatching, + items: _onDeck, + ), + ); + } + hubs.addAll(_hubs.where((hub) => hub.items.isNotEmpty)); + return hubs; + } + + MediaItem? get _effectiveSpotlightItem { + final current = _spotlightItem; + if (current == null) return _defaultSpotlightItem; + if (_onDeck.any((item) => item.globalKey == current.globalKey)) return current; + for (final hub in _hubs) { + if (hub.items.any((item) => item.globalKey == current.globalKey)) return current; + } + return _defaultSpotlightItem; + } + + void _setSpotlightItem(MediaItem item) { + if (_spotlightItem?.globalKey == item.globalKey) return; + setState(() => _spotlightItem = item); + } + void _scrollToTop() { if (!_scrollController.hasClients) return; _scrollController.animateTo(0, duration: const Duration(milliseconds: 200), curve: Curves.easeOut); } + void _focusTopActions() { + if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; + final actionBar = _actionBarKey.currentState; + if (actionBar != null) { + actionBar.requestFocusOnFirst(); + return; + } + + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; + _actionBarKey.currentState?.requestFocusOnFirst(); + }); + } + void _focusTopBoundary() { if (!(ModalRoute.of(context)?.isCurrent ?? false)) return; - if (_isHeroSectionVisible) { + if (PlatformDetector.isTV()) { + _focusTopActions(); + } else if (_isHeroSectionVisible) { _heroFocusNode.requestFocus(); } else { - _actionBarKey.currentState?.requestFocusOnFirst(); + _focusTopActions(); } _scrollToTop(); } void _focusContentFromAppBar() { + if (PlatformDetector.isTV()) { + _tvBrowseRailKey.currentState?.requestFocus(); + return; + } + if (_isHeroSectionVisible) { _heroFocusNode.requestFocus(); return; @@ -269,6 +337,10 @@ class _DiscoverScreenState extends State // UP from first hub: navigate to hero when visible, otherwise app bar if (isUp && hubIndex == 0) { + if (PlatformDetector.isTV()) { + _focusTopActions(); + return true; + } _focusTopBoundary(); return true; } @@ -301,10 +373,27 @@ class _DiscoverScreenState extends State super.initState(); WidgetsBinding.instance.addObserver(this); _heroFocusNode = FocusNode(debugLabel: 'hero_section'); + _heroFocusNode.addListener(_onHeroFocusChanged); _loadContent(); _startAutoScroll(); } + void _onHeroFocusChanged() { + if (!PlatformDetector.isTV()) return; + + if (_heroFocusNode.hasFocus) { + _heroFocusPausedAutoScroll = true; + _autoScrollTimer?.cancel(); + _stopIndicatorProgress(); + return; + } + + if (_heroFocusPausedAutoScroll) { + _heroFocusPausedAutoScroll = false; + if (_isTabVisible && !_isAutoScrollPaused) _startAutoScroll(); + } + } + @override void didChangeDependencies() { super.didChangeDependencies(); @@ -360,31 +449,36 @@ class _DiscoverScreenState extends State return true; } - /// Handle key events for the hero section - late final _handleHeroKeyEvent = dpadKeyHandler( - onDown: () { - final keys = _allHubKeys; - if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory(); - }, - onUp: () => _actionBarKey.currentState?.requestFocusOnFirst(), - onLeft: () { - if (_currentHeroIndex > 0) { - _heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut); - } else { - _navigateToSidebar(); - } - }, - onRight: () { - if (_currentHeroIndex < _onDeck.length - 1) { - _heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut); - } - }, - onSelect: () { - if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) { - navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]); - } - }, - ); + /// Handle key events for the hero section. + KeyEventResult _handleHeroKeyEvent(FocusNode node, KeyEvent event) { + final backResult = handleBackKeyAction(event, _navigateToSidebar); + if (backResult != KeyEventResult.ignored) return backResult; + + return dpadKeyHandler( + onDown: () { + final keys = _allHubKeys; + if (keys.isNotEmpty) keys.first.currentState?.requestFocusFromMemory(); + }, + onUp: _focusTopActions, + onLeft: () { + if (_currentHeroIndex > 0) { + _heroController.previousPage(duration: tokens(context).slow, curve: Curves.easeInOut); + } else { + _navigateToSidebar(); + } + }, + onRight: () { + if (_currentHeroIndex < _onDeck.length - 1) { + _heroController.nextPage(duration: tokens(context).slow, curve: Curves.easeInOut); + } + }, + onSelect: () { + if (_onDeck.isNotEmpty && _currentHeroIndex < _onDeck.length) { + navigateToVideoPlayer(context, metadata: _onDeck[_currentHeroIndex]); + } + }, + )(node, event); + } @override void dispose() { @@ -396,6 +490,7 @@ class _DiscoverScreenState extends State _indicatorProgress.dispose(); _heroController.dispose(); _scrollController.dispose(); + _heroFocusNode.removeListener(_onHeroFocusChanged); _heroFocusNode.dispose(); super.dispose(); } @@ -419,6 +514,7 @@ class _DiscoverScreenState extends State void _startAutoScroll() { _autoScrollTimer?.cancel(); + if (PlatformDetector.isTV()) return; if (_isAutoScrollPaused) return; _startIndicatorProgress(); @@ -607,7 +703,7 @@ class _DiscoverScreenState extends State }); // Focus hero section now that it's visible, but only if no modal route is on top - if (onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) { + if (!PlatformDetector.isTV() && onDeck.isNotEmpty && (ModalRoute.of(context)?.isCurrent ?? false)) { _heroFocusNode.requestFocus(); } @@ -625,7 +721,10 @@ class _DiscoverScreenState extends State if (!_initialLoadComplete && onDeck.isNotEmpty) { _initialLoadComplete = true; WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted && _heroFocusNode.canRequestFocus && (ModalRoute.of(context)?.isCurrent ?? false)) { + if (!mounted || !(ModalRoute.of(context)?.isCurrent ?? false)) return; + if (PlatformDetector.isTV()) { + _tvBrowseRailKey.currentState?.requestFocus(); + } else if (_heroFocusNode.canRequestFocus) { _heroFocusNode.requestFocus(); } }); @@ -659,6 +758,15 @@ class _DiscoverScreenState extends State _updateHubKeys(); }); + if (PlatformDetector.isTV() && !_initialLoadComplete && filteredHubs.isNotEmpty) { + _initialLoadComplete = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted && (ModalRoute.of(context)?.isCurrent ?? false)) { + _tvBrowseRailKey.currentState?.requestFocus(); + } + }); + } + appLogger.d('Discover content loaded successfully'); } catch (e) { appLogger.e('Failed to load discover content', error: e); @@ -1226,8 +1334,13 @@ class _DiscoverScreenState extends State Widget _buildContent(BuildContext context) { final svc = SettingsService.instanceOrNull!; - final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); final showHeroSection = svc.read(SettingsService.showHeroSection); + + if (PlatformDetector.isTV()) { + return _buildTvContent(context); + } + + final showServerNameOnHubs = svc.read(SettingsService.showServerNameOnHubs); final duplicateHubTitles = _getDuplicateHubTitles(); final bottomPadding = MediaQuery.paddingOf(context).bottom; @@ -1365,10 +1478,93 @@ class _DiscoverScreenState extends State ); } + Widget _buildTvContent(BuildContext context) { + final size = MediaQuery.sizeOf(context); + final theme = Theme.of(context); + final spotlight = _effectiveSpotlightItem; + final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers); + final browseHubs = _tvBrowseHubs; + final spotlightTop = (size.height * 0.1).clamp(96.0, 150.0).toDouble(); + final spotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble(); + final spotlightLeft = (24 * TvLayoutConstants.scaleForSize(size)).clamp(18.0, 40.0).toDouble(); + + return Material( + color: theme.scaffoldBackgroundColor, + child: Stack( + children: [ + TvSpotlightBackground( + item: spotlight, + client: _getMediaClientForItem(spotlight), + hideSpoilers: hideSpoilers, + contentTop: spotlightTop, + contentBottom: spotlightBottom, + contentLeft: spotlightLeft, + compact: true, + showPrimaryAction: false, + ), + if (_isLoading || (_areHubsLoading && browseHubs.isEmpty)) const Center(child: CircularProgressIndicator()), + if (_errorMessage != null) + Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const AppIcon(Symbols.error_outline_rounded, fill: 1, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(_errorMessage!), + const SizedBox(height: 16), + FilledButton(onPressed: _loadContent, child: Text(t.common.retry)), + ], + ), + ), + if (!_isLoading && _errorMessage == null && browseHubs.isEmpty && !_areHubsLoading) + Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), + const SizedBox(height: 16), + Text(t.discover.noContentAvailable), + const SizedBox(height: 8), + Text(t.discover.addMediaToLibraries, style: const TextStyle(color: Colors.grey)), + ], + ), + ), + if (browseHubs.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: TvBrowseRail( + key: _tvBrowseRailKey, + hubs: browseHubs, + iconForHub: (hub, _) => + hub.id == 'continue_watching' ? Symbols.play_circle_rounded : _getHubIcon(hub.title), + onFocusedItemChanged: _setSpotlightItem, + onRefresh: updateItem, + onRemoveFromContinueWatching: _refreshContinueWatching, + isContinueWatchingHub: (hub) => hub.id == 'continue_watching', + loadMoreItems: (hub) => + hub.id == 'continue_watching' ? _loadAllContinueWatchingItems() : Future.value(hub.items), + onNavigateUp: _focusTopActions, + onNavigateToSidebar: _navigateToSidebar, + ), + ), + Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: _buildOverlaidAppBar())), + if (_switchingProfile) const ProfileSwitchingOverlay(), + ], + ), + ); + } + Widget _buildHeroSection() { final statusBarHeight = MediaQuery.paddingOf(context).top; final useSideNav = PlatformDetector.shouldUseSideNavigation(context); - final heroHeight = useSideNav ? MediaQuery.sizeOf(context).height * 0.75 : 500 + statusBarHeight; + final isTv = PlatformDetector.isTV(); + final heroHeight = isTv + ? MediaQuery.sizeOf(context).height * 0.82 + : useSideNav + ? MediaQuery.sizeOf(context).height * 0.75 + : 500 + statusBarHeight; return SliverToBoxAdapter( child: Focus( focusNode: _heroFocusNode, @@ -1488,6 +1684,8 @@ class _DiscoverScreenState extends State final showName = heroItem.grandparentTitle ?? heroItem.displayTitle; final screenWidth = MediaQuery.sizeOf(context).width; final isLargeScreen = ScreenBreakpoints.isWideTabletOrLarger(screenWidth); + final isTv = PlatformDetector.isTV(); + final alignLeft = isTv || isLargeScreen; final theme = Theme.of(context); final colorScheme = theme.colorScheme; @@ -1595,7 +1793,7 @@ class _DiscoverScreenState extends State begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], - stops: const [0.5, 0.85, 1.0], + stops: isTv ? const [0.25, 0.78, 1.0] : const [0.5, 0.85, 1.0], ), ), ); @@ -1606,155 +1804,185 @@ class _DiscoverScreenState extends State // Content with responsive alignment Positioned( - bottom: isLargeScreen ? 80 : 50, + bottom: isTv + ? 88 + : isLargeScreen + ? 80 + : 50, left: 0, - right: isLargeScreen ? 200 : 0, + right: isTv + ? screenWidth * 0.36 + : isLargeScreen + ? 200 + : 0, child: Padding( - padding: EdgeInsets.symmetric(horizontal: isLargeScreen ? 40 : 24), - child: Column( - crossAxisAlignment: isLargeScreen ? CrossAxisAlignment.start : CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - // Show logo or name/title - if (heroItem.clearLogoPath != null) - SizedBox( - height: 120, - width: 400, - child: Builder( - builder: (context) { - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final logoUrl = MediaImageHelper.getOptimizedImageUrl( - client: heroClient, - thumbPath: heroItem.clearLogoPath, - maxWidth: 400, - maxHeight: 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); + padding: EdgeInsets.symmetric( + horizontal: isTv + ? TvLayoutConstants.horizontalInset + : isLargeScreen + ? 40 + : 24, + ), + child: Align( + alignment: alignLeft ? Alignment.centerLeft : Alignment.center, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: isTv ? TvLayoutConstants.heroContentMaxWidth : double.infinity, + ), + child: Column( + crossAxisAlignment: alignLeft ? CrossAxisAlignment.start : CrossAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + // Show logo or name/title + if (heroItem.clearLogoPath != null) + SizedBox( + height: isTv ? TvLayoutConstants.heroLogoHeight : 120, + width: isTv ? TvLayoutConstants.heroLogoWidth : 400, + child: Builder( + builder: (context) { + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final logoUrl = MediaImageHelper.getOptimizedImageUrl( + client: heroClient, + thumbPath: heroItem.clearLogoPath, + maxWidth: isTv ? TvLayoutConstants.heroLogoWidth : 400, + maxHeight: isTv ? TvLayoutConstants.heroLogoHeight : 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); - return blurArtwork( - CachedNetworkImage( - imageUrl: logoUrl, - cacheManager: PlexImageCacheManager.instance, - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - memCacheWidth: (400 * dpr).clamp(200, 800).round(), - alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter, - placeholder: (context, url) => const SizedBox.shrink(), - errorBuilder: (context, error, stackTrace) { - // Fallback to text if logo fails to load - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - return Align( - alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center, - child: Text( - showName, - style: theme.textTheme.displaySmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - shadows: [ - Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8), - ], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - ), - ); - }, - ), - sigma: 10, - clip: false, - ); - }, - ), - ) - else - Text( - showName, - style: theme.textTheme.displaySmall?.copyWith( - color: colorScheme.onSurface, - fontWeight: FontWeight.bold, - shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - ), - - // Metadata as dot-separated text with content type - if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[ - const SizedBox(height: 16), - Text( - [ - contentTypeLabel, - if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}', - if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!), - if (heroItem.year != null) heroItem.year.toString(), - ].join(' • '), - style: const TextStyle(color: Colors.white, fontSize: 14, fontWeight: FontWeight.w500), - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - ), - ], - - // On small screens: show button before summary - if (!isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], - - // Summary with episode info (Apple TV style) - if (heroItem.summary != null && !shouldHideSpoiler) ...[ - const SizedBox(height: 12), - RichText( - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - text: TextSpan( - style: TextStyle( - color: isLargeScreen - ? Colors.white.withValues(alpha: 0.7) - : colorScheme.onSurface.withValues(alpha: 0.7), - fontSize: 14, - height: 1.4, - ), - children: [ - if (isEpisode && heroItem.parentIndex != null && heroItem.index != null) - TextSpan( - text: 'S${heroItem.parentIndex}, E${heroItem.index}: ', - style: TextStyle( - fontWeight: FontWeight.bold, - color: isLargeScreen ? Colors.white : colorScheme.onSurface, - ), - ), - TextSpan( - text: heroItem.summary?.isNotEmpty == true - ? heroItem.summary! - : t.messages.noDescriptionAvailable, + return blurArtwork( + CachedNetworkImage( + imageUrl: logoUrl, + cacheManager: PlexImageCacheManager.instance, + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + memCacheWidth: ((isTv ? TvLayoutConstants.heroLogoWidth : 400) * dpr) + .clamp(200, isTv ? 1000 : 800) + .round(), + alignment: alignLeft ? Alignment.bottomLeft : Alignment.bottomCenter, + placeholder: (context, url) => const SizedBox.shrink(), + errorBuilder: (context, error, stackTrace) { + // Fallback to text if logo fails to load + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + return Align( + alignment: alignLeft ? Alignment.centerLeft : Alignment.center, + child: Text( + showName, + style: theme.textTheme.displaySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: isTv ? 52 : null, + shadows: [ + Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8), + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + ), + ); + }, + ), + sigma: 10, + clip: false, + ); + }, ), - ], - ), - ), - ] else if (shouldHideSpoiler && - isEpisode && - heroItem.parentIndex != null && - heroItem.index != null) ...[ - const SizedBox(height: 12), - Text( - 'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}', - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - style: TextStyle( - color: isLargeScreen - ? Colors.white.withValues(alpha: 0.7) - : colorScheme.onSurface.withValues(alpha: 0.7), - fontSize: 14, - height: 1.4, - ), - ), - ], + ) + else + Text( + showName, + style: theme.textTheme.displaySmall?.copyWith( + color: colorScheme.onSurface, + fontWeight: FontWeight.bold, + fontSize: isTv ? 52 : null, + shadows: [Shadow(color: colorScheme.surface.withValues(alpha: 0.8), blurRadius: 8)], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + ), - // On large screens: show button after summary - if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], - ], + // Metadata as dot-separated text with content type + if (heroItem.year != null || heroItem.contentRating != null || heroItem.rating != null) ...[ + const SizedBox(height: 16), + Text( + [ + contentTypeLabel, + if (heroItem.rating != null) '★ ${formatRating(heroItem.rating!)}', + if (heroItem.contentRating != null) formatContentRating(heroItem.contentRating!), + if (heroItem.year != null) heroItem.year.toString(), + ].join(' • '), + style: TextStyle( + color: Colors.white, + fontSize: isTv ? 18 : 14, + fontWeight: FontWeight.w600, + ), + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + ), + ], + + // On small screens: show button before summary + if (!alignLeft) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], + + // Summary with episode info (Apple TV style) + if (heroItem.summary != null && !shouldHideSpoiler) ...[ + const SizedBox(height: 12), + RichText( + maxLines: isTv ? 3 : 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + text: TextSpan( + style: TextStyle( + color: alignLeft + ? Colors.white.withValues(alpha: 0.7) + : colorScheme.onSurface.withValues(alpha: 0.7), + fontSize: isTv ? 18 : 14, + height: isTv ? 1.45 : 1.4, + ), + children: [ + if (isEpisode && heroItem.parentIndex != null && heroItem.index != null) + TextSpan( + text: 'S${heroItem.parentIndex}, E${heroItem.index}: ', + style: TextStyle( + fontWeight: FontWeight.bold, + color: alignLeft ? Colors.white : colorScheme.onSurface, + ), + ), + TextSpan( + text: heroItem.summary?.isNotEmpty == true + ? heroItem.summary! + : t.messages.noDescriptionAvailable, + ), + ], + ), + ), + ] else if (shouldHideSpoiler && + isEpisode && + heroItem.parentIndex != null && + heroItem.index != null) ...[ + const SizedBox(height: 12), + Text( + 'S${heroItem.parentIndex}, E${heroItem.index}: ${heroItem.title}', + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: alignLeft ? TextAlign.left : TextAlign.center, + style: TextStyle( + color: alignLeft + ? Colors.white.withValues(alpha: 0.7) + : colorScheme.onSurface.withValues(alpha: 0.7), + fontSize: isTv ? 18 : 14, + height: isTv ? 1.45 : 1.4, + ), + ), + ], + + // On large screens: show button after summary + if (alignLeft) ...[SizedBox(height: isTv ? 28 : 20), _buildSmartPlayButton(heroItem)], + ], + ), + ), ), ), ), @@ -1766,58 +1994,84 @@ class _DiscoverScreenState extends State Widget _buildSmartPlayButton(MediaItem heroItem) { final hasProgress = heroItem.hasActiveProgress; + final isTv = PlatformDetector.isTV(); final minutesLeft = hasProgress ? ((heroItem.durationMs! - heroItem.viewOffsetMs!) / 60000).round() : 0; final progress = hasProgress ? heroItem.viewOffsetMs! / heroItem.durationMs! : 0.0; - return InkWell( - onTap: () { - appLogger.d('Playing: ${heroItem.title}'); - navigateToVideoPlayer(context, metadata: heroItem); - }, - borderRadius: const BorderRadius.all(Radius.circular(24)), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 12), - decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.all(Radius.circular(24))), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const AppIcon(Symbols.play_arrow_rounded, fill: 1, size: 20, color: Colors.black), - const SizedBox(width: 8), - if (hasProgress) ...[ - // Progress bar - Container( - width: 40, - height: 6, - decoration: const BoxDecoration( - color: Colors.black26, - borderRadius: BorderRadius.all(Radius.circular(3)), - ), - child: FractionallySizedBox( - alignment: Alignment.centerLeft, - widthFactor: progress, - child: Container( - decoration: const BoxDecoration( - color: Colors.black, - borderRadius: BorderRadius.all(Radius.circular(2)), + return ListenableBuilder( + listenable: _heroFocusNode, + builder: (context, _) { + final showFocus = isTv && _heroFocusNode.hasFocus && InputModeTracker.isKeyboardMode(context); + final colorScheme = Theme.of(context).colorScheme; + final backgroundColor = showFocus ? colorScheme.primary : Colors.white; + final foregroundColor = showFocus ? colorScheme.onPrimary : Colors.black; + return InkWell( + onTap: () { + appLogger.d('Playing: ${heroItem.title}'); + navigateToVideoPlayer(context, metadata: heroItem); + }, + borderRadius: BorderRadius.all(Radius.circular(isTv ? 32 : 24)), + child: AnimatedContainer( + duration: const Duration(milliseconds: 150), + curve: Curves.easeOutCubic, + padding: EdgeInsets.symmetric(horizontal: isTv ? 34 : 24, vertical: isTv ? 16 : 12), + decoration: BoxDecoration( + color: backgroundColor, + borderRadius: BorderRadius.all(Radius.circular(isTv ? 32 : 24)), + boxShadow: showFocus + ? [BoxShadow(color: colorScheme.primary.withValues(alpha: 0.35), blurRadius: 28, spreadRadius: 4)] + : null, + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(Symbols.play_arrow_rounded, fill: 1, size: isTv ? 28 : 20, color: foregroundColor), + SizedBox(width: isTv ? 12 : 8), + if (hasProgress) ...[ + // Progress bar + Container( + width: isTv ? 56 : 40, + height: isTv ? 8 : 6, + decoration: BoxDecoration( + color: foregroundColor.withValues(alpha: 0.25), + borderRadius: BorderRadius.all(Radius.circular(isTv ? 4 : 3)), + ), + child: FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: progress, + child: Container( + decoration: BoxDecoration( + color: foregroundColor, + borderRadius: BorderRadius.all(Radius.circular(isTv ? 3 : 2)), + ), + ), ), ), - ), - ), - const SizedBox(width: 8), - Text( - t.discover.minutesLeft(minutes: minutesLeft), - style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600), - ), - ] else - Text( - t.common.play, - style: const TextStyle(color: Colors.black, fontSize: 14, fontWeight: FontWeight.w600), - ), - ], - ), - ), + SizedBox(width: isTv ? 12 : 8), + Text( + t.discover.minutesLeft(minutes: minutesLeft), + style: TextStyle( + color: foregroundColor, + fontSize: isTv ? 18 : 14, + fontWeight: isTv ? FontWeight.w700 : FontWeight.w600, + ), + ), + ] else + Text( + t.common.play, + style: TextStyle( + color: foregroundColor, + fontSize: isTv ? 18 : 14, + fontWeight: isTv ? FontWeight.w700 : FontWeight.w600, + ), + ), + ], + ), + ), + ); + }, ); } } diff --git a/lib/screens/libraries/libraries_screen.dart b/lib/screens/libraries/libraries_screen.dart index 8c166964..cca371d4 100644 --- a/lib/screens/libraries/libraries_screen.dart +++ b/lib/screens/libraries/libraries_screen.dart @@ -401,6 +401,7 @@ class _LibrariesScreenState extends State suppressAutoFocus: suppressAutoFocus, onDataLoaded: () => _handleTabDataLoaded(tabIndex), onBack: focusTabBar, + onNavigateToChrome: focusTabBar, ), LibraryTabType.browse => LibraryBrowseTab( key: _browseTabKey, @@ -1024,6 +1025,9 @@ class _LibrariesScreenState extends State : null; final showMobileTabsRow = selectedLibrary != null && !PlatformDetector.shouldUseSideNavigation(context); + final currentTabIndex = _visibleTabs.isEmpty ? 0 : tabController.index.clamp(0, _visibleTabs.length - 1).toInt(); + final currentTabType = _visibleTabs.isEmpty ? null : _visibleTabs[currentTabIndex]; + final useTvRecommendedBackdrop = PlatformDetector.isTV() && currentTabType == LibraryTabType.recommended; Widget appBar({required bool floating}) => DesktopSliverAppBar( title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting), @@ -1033,7 +1037,7 @@ class _LibrariesScreenState extends State pinned: !floating, floating: floating, snap: floating, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, + backgroundColor: useTvRecommendedBackdrop ? Colors.transparent : Theme.of(context).scaffoldBackgroundColor, surfaceTintColor: Colors.transparent, shadowColor: Colors.transparent, scrolledUnderElevation: 0, @@ -1065,6 +1069,41 @@ class _LibrariesScreenState extends State ); } + Widget buildTransparentTvTopBar() { + return SafeArea( + bottom: false, + child: AppBar( + primary: false, + backgroundColor: Colors.transparent, + surfaceTintColor: Colors.transparent, + shadowColor: Colors.transparent, + elevation: 0, + scrolledUnderElevation: 0, + title: _buildAppBarTitle(visibleLibraries, selectedLibrary, groupByServer: groupByServerSetting), + actions: [ + FocusableActionBar( + key: _actionBarKey, + onNavigateLeft: () => getTabChipFocusNode(_visibleTabs.length - 1).requestFocus(), + onNavigateDown: _focusCurrentTab, + actions: [ + if (allLibraries.isNotEmpty) + FocusableAction( + icon: Symbols.edit_rounded, + tooltip: t.libraries.manageLibraries, + onPressed: _showLibraryManagementSheet, + ), + FocusableAction( + icon: Symbols.refresh_rounded, + tooltip: t.common.refresh, + onPressed: _refreshCurrentTab, + ), + ], + ), + ], + ), + ); + } + Widget body; if (isLoadingLibraries) { body = buildSimpleScroll(body: const Center(child: CircularProgressIndicator())); @@ -1092,40 +1131,8 @@ class _LibrariesScreenState extends State ), ); } else if (selectedLibrary != null) { - body = NestedScrollView( - controller: _outerScrollController, - floatHeaderSlivers: true, - headerSliverBuilder: (context, innerBoxIsScrolled) => [ - SliverOverlapAbsorber( - handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), - sliver: appBar(floating: true), - ), - if (showMobileTabsRow) - SliverToBoxAdapter( - child: Container( - color: Theme.of(context).scaffoldBackgroundColor, - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: [ - for (int i = 0; i < _visibleTabs.length; i++) ...[ - if (i > 0) const SizedBox(width: 8), - buildTabChip( - _getTabLabel(_visibleTabs[i]), - i, - onSelectWhenActive: _focusCurrentTab, - onNavigateDown: _focusCurrentTabFromTabBar, - onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(), - ), - ], - ], - ), - ), - ), - ), - ], - body: TabBarView( + Widget buildTabs() { + return TabBarView( key: ValueKey(_selectedLibraryGlobalKey), controller: tabController, // Disable swipe on desktop - trackpad scrolling triggers accidental tab switches @@ -1144,15 +1151,64 @@ class _LibrariesScreenState extends State ), ), ], - ), - ); + ); + } + + if (useTvRecommendedBackdrop) { + body = Stack( + fit: StackFit.expand, + children: [ + buildTabs(), + Positioned(top: 0, left: 0, right: 0, child: ExcludeFocusTraversal(child: buildTransparentTvTopBar())), + ], + ); + } else { + body = NestedScrollView( + controller: _outerScrollController, + floatHeaderSlivers: true, + headerSliverBuilder: (context, innerBoxIsScrolled) => [ + SliverOverlapAbsorber( + handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context), + sliver: appBar(floating: true), + ), + if (showMobileTabsRow) + SliverToBoxAdapter( + child: Container( + color: Theme.of(context).scaffoldBackgroundColor, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Row( + children: [ + for (int i = 0; i < _visibleTabs.length; i++) ...[ + if (i > 0) const SizedBox(width: 8), + buildTabChip( + _getTabLabel(_visibleTabs[i]), + i, + onSelectWhenActive: _focusCurrentTab, + onNavigateDown: _focusCurrentTabFromTabBar, + onNavigateRightFromLast: () => _actionBarKey.currentState?.requestFocusOnFirst(), + ), + ], + ], + ), + ), + ), + ), + ], + body: buildTabs(), + ); + } } else { body = buildSimpleScroll(body: const SizedBox.shrink()); } - return Scaffold( - body: ScrollConfiguration(behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), child: body), + final scrollBody = ScrollConfiguration( + behavior: ScrollConfiguration.of(context).copyWith(scrollbars: false), + child: body, ); + + return Scaffold(body: scrollBody); } } diff --git a/lib/screens/libraries/tabs/library_recommended_tab.dart b/lib/screens/libraries/tabs/library_recommended_tab.dart index 0ffce69f..424ffc6c 100644 --- a/lib/screens/libraries/tabs/library_recommended_tab.dart +++ b/lib/screens/libraries/tabs/library_recommended_tab.dart @@ -8,16 +8,24 @@ import '../../../media/media_hub.dart'; import '../../../media/media_item.dart'; import '../../../mixins/item_updatable.dart'; import '../../../mixins/watch_state_aware.dart'; +import '../../../services/settings_service.dart'; import '../../../utils/global_key_utils.dart'; +import '../../../utils/layout_constants.dart'; +import '../../../utils/platform_detector.dart'; import '../../../utils/provider_extensions.dart'; import '../../../utils/watch_state_notifier.dart'; import '../../../widgets/hub_section.dart'; +import '../../../widgets/settings_builder.dart'; +import '../../../widgets/tv_browse_rail.dart'; +import '../../../widgets/tv_spotlight_background.dart'; import '../../main_screen.dart'; import 'base_library_tab.dart'; /// Recommended tab for library screen /// Shows library-specific hubs and recommendations, including dedicated Continue Watching class LibraryRecommendedTab extends BaseLibraryTab { + final VoidCallback? onNavigateToChrome; + const LibraryRecommendedTab({ super.key, required super.library, @@ -25,6 +33,7 @@ class LibraryRecommendedTab extends BaseLibraryTab { super.isActive, super.suppressAutoFocus, super.onBack, + this.onNavigateToChrome, }); @override @@ -35,6 +44,29 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState> _hubKeys = []; + final _tvBrowseRailKey = GlobalKey(); + MediaItem? _spotlightItem; + + MediaItem? get _defaultSpotlightItem { + for (final hub in items) { + if (hub.items.isNotEmpty) return hub.items.first; + } + return null; + } + + MediaItem? get _effectiveSpotlightItem { + final current = _spotlightItem; + if (current == null) return _defaultSpotlightItem; + for (final hub in items) { + if (hub.items.any((item) => item.globalKey == current.globalKey)) return current; + } + return _defaultSpotlightItem; + } + + void _setSpotlightItem(MediaItem item) { + if (_spotlightItem?.globalKey == item.globalKey) return; + setState(() => _spotlightItem = item); + } @override String? get itemServerId => widget.library.serverId; @@ -202,6 +234,10 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState items) { _ensureHubKeys(items.length); + if (PlatformDetector.isTV()) { + return _buildTvContent(items); + } + return CustomScrollView( // Allow focus decoration to render outside scroll bounds clipBehavior: Clip.none, @@ -251,6 +291,56 @@ class _LibraryRecommendedTabState extends BaseLibraryTabState items) { + final tvHubs = items.where((hub) => hub.items.isNotEmpty).toList(); + final spotlight = _effectiveSpotlightItem; + final size = MediaQuery.sizeOf(context); + final theme = Theme.of(context); + final client = context.tryGetMediaClientForServer(spotlight?.serverId ?? widget.library.serverId); + final spotlightTop = (size.height * 0.1).clamp(96.0, 150.0).toDouble(); + final spotlightBottom = (size.height * 0.53).clamp(180.0, 900.0).toDouble(); + final spotlightLeft = (24 * TvLayoutConstants.scaleForSize(size)).clamp(18.0, 40.0).toDouble(); + + return Material( + color: theme.scaffoldBackgroundColor, + child: SizedBox.expand( + child: Stack( + fit: StackFit.expand, + children: [ + TvSpotlightBackground( + item: spotlight, + client: client, + hideSpoilers: context.settingsRead(SettingsService.hideSpoilers), + contentTop: spotlightTop, + contentBottom: spotlightBottom, + contentLeft: spotlightLeft, + compact: true, + showPrimaryAction: false, + ), + if (tvHubs.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: TvBrowseRail( + key: _tvBrowseRailKey, + hubs: tvHubs, + iconForHub: (hub, _) => _getHubIcon(hub), + onFocusedItemChanged: _setSpotlightItem, + onRefresh: updateItem, + onRemoveFromContinueWatching: _refreshContinueWatching, + isContinueWatchingHub: _isContinueWatchingHub, + onNavigateUp: widget.onNavigateToChrome ?? widget.onBack, + onNavigateToSidebar: _navigateToSidebar, + onBack: widget.onBack, + ), + ), + ], + ), + ), + ); + } + /// Refresh the Continue Watching section void _refreshContinueWatching() { // Reload all data to refresh the continue watching section diff --git a/lib/screens/media_detail/action_buttons.dart b/lib/screens/media_detail/action_buttons.dart index 6b5f53dd..4e9661bb 100644 --- a/lib/screens/media_detail/action_buttons.dart +++ b/lib/screens/media_detail/action_buttons.dart @@ -2,8 +2,11 @@ part of '../media_detail_screen.dart'; extension _MediaDetailActionButtons on _MediaDetailScreenState { Widget _buildActionButtons(MediaItem metadata) { + final isTv = PlatformDetector.isTV(); + final tvScale = TvLayoutConstants.scaleOf(context); + final actionSize = isTv ? _tvDetailActionSize * tvScale : 48.0; final playButtonLabel = _getPlayButtonLabel(metadata); - final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: 20); + final playButtonIcon = AppIcon(_getPlayButtonIcon(metadata), fill: 1, size: isTv ? 22 * tvScale : 20); Future onPlayPressed() async { // For TV shows, play the OnDeck episode if available @@ -56,6 +59,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { final focusBg = colorScheme.inverseSurface; final focusFg = colorScheme.onInverseSurface; final tonalBg = colorScheme.secondaryContainer; + final idleBg = isTv ? tonalBg.withValues(alpha: 0.38) : tonalBg; final tonalFg = colorScheme.onSecondaryContainer; final noOverlay = WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.focused)) return Colors.transparent; @@ -63,7 +67,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }); ButtonStyle actionButtonStyle({Color? foregroundColor, EdgeInsetsGeometry? padding}) { - if (!isKeyboardMode) { + if (!isKeyboardMode && !isTv) { if (padding != null) { return FilledButton.styleFrom(padding: padding); } @@ -75,12 +79,15 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { } return ButtonStyle( padding: padding != null ? WidgetStatePropertyAll(padding) : null, - minimumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, - maximumSize: padding == null ? const WidgetStatePropertyAll(Size(48, 48)) : null, + minimumSize: WidgetStatePropertyAll(padding == null ? Size.square(actionSize) : Size(0, actionSize)), + maximumSize: padding == null ? WidgetStatePropertyAll(Size.square(actionSize)) : null, + fixedSize: padding == null ? WidgetStatePropertyAll(Size.square(actionSize)) : null, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, overlayColor: noOverlay, backgroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.focused)) return focusBg; - return tonalBg; + return idleBg; }), foregroundColor: WidgetStateProperty.resolveWith((states) { if (states.contains(WidgetState.focused)) return focusFg; @@ -95,25 +102,30 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { child: Row( children: [ SizedBox( - height: 48, + height: actionSize, child: FilledButton( focusNode: _playButtonFocusNode, autofocus: isKeyboardMode, onPressed: onPlayPressed, - style: actionButtonStyle(padding: const EdgeInsets.symmetric(horizontal: 16)), + style: actionButtonStyle( + padding: EdgeInsets.symmetric(horizontal: isTv ? 17 * tvScale : 16, vertical: isTv ? 9 * tvScale : 0), + ), child: playButtonLabel.isNotEmpty ? Row( mainAxisSize: MainAxisSize.min, children: [ playButtonIcon, - const SizedBox(width: 8), - Text(playButtonLabel, style: const TextStyle(fontSize: 16)), + SizedBox(width: isTv ? 7 * tvScale : 8), + Text( + playButtonLabel, + style: TextStyle(fontSize: isTv ? 17 * tvScale : 16, fontWeight: FontWeight.w700), + ), ], ) : playButtonIcon, ), ), - const SizedBox(width: 12), + SizedBox(width: isTv ? 8 * tvScale : 12), // Trailer button (only if trailer is available) if (primaryTrailer != null) ...[ IconButton.filledTonal( @@ -122,10 +134,10 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: const AppIcon(Symbols.theaters_rounded, fill: 1), tooltip: t.tooltips.playTrailer, - iconSize: 20, + iconSize: isTv ? 21 * tvScale : 20, style: actionButtonStyle(), ), - const SizedBox(width: 12), + SizedBox(width: isTv ? 8 * tvScale : 12), ], // Shuffle button (only for shows and seasons) if (metadata.isShow || metadata.isSeason) ...[ @@ -135,19 +147,23 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: const AppIcon(Symbols.shuffle_rounded, fill: 1), tooltip: t.tooltips.shufflePlay, - iconSize: 20, + iconSize: isTv ? 21 * tvScale : 20, style: actionButtonStyle(), ), - const SizedBox(width: 12), + SizedBox(width: isTv ? 8 * tvScale : 12), ], // Download button (hide in offline mode - already downloaded, // and on Apple TV where there's no user file storage). - if (!widget.isOffline && !PlatformDetector.isAppleTV()) _buildDownloadButton(metadata, actionButtonStyle), - const SizedBox(width: 12), + if (!widget.isOffline && !PlatformDetector.isAppleTV()) + _buildDownloadButton(metadata, actionButtonStyle, tvScale), + SizedBox(width: isTv ? 8 * tvScale : 12), // Mark as watched/unwatched toggle (works offline too) - _buildWatchedToggleButton(metadata, actionButtonStyle), + _buildWatchedToggleButton(metadata, actionButtonStyle, tvScale), // Three-dots menu button (hidden in offline mode) - if (!widget.isOffline) ...[const SizedBox(width: 12), _buildMoreActionsButton(metadata, actionButtonStyle)], + if (!widget.isOffline) ...[ + SizedBox(width: isTv ? 8 * tvScale : 12), + _buildMoreActionsButton(metadata, actionButtonStyle, tvScale), + ], ], ), ); @@ -156,6 +172,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { Widget _buildWatchedToggleButton( MediaItem metadata, ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle, + double tvScale, ) { return IconButton.filledTonal( onPressed: () async { @@ -201,7 +218,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: AppIcon(metadata.isWatched ? Symbols.remove_done_rounded : Symbols.check_rounded, fill: 1), tooltip: metadata.isWatched ? t.tooltips.markAsUnwatched : t.tooltips.markAsWatched, - iconSize: 20, + iconSize: PlatformDetector.isTV() ? 21 * tvScale : 20, style: actionButtonStyle(), ); } @@ -209,6 +226,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { Widget _buildMoreActionsButton( MediaItem metadata, ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle, + double tvScale, ) { return MediaContextMenu( key: _contextMenuKey, @@ -224,7 +242,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { } }, icon: const AppIcon(Symbols.more_vert_rounded, fill: 1), - iconSize: 20, + iconSize: PlatformDetector.isTV() ? 21 * tvScale : 20, style: actionButtonStyle(), ), ), @@ -234,9 +252,11 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { Widget _buildDownloadButton( MediaItem metadata, ButtonStyle Function({Color? foregroundColor, EdgeInsetsGeometry? padding}) actionButtonStyle, + double tvScale, ) { return Consumer( builder: (context, downloadProvider, _) { + final iconSize = PlatformDetector.isTV() ? 21.0 * tvScale : 20.0; final globalKey = metadata.globalKey; final ruleKey = _syncRuleKeyForMetadata(context, downloadProvider, metadata); final progress = downloadProvider.getProgress(globalKey); @@ -251,8 +271,8 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { if (isQueueing) { return IconButton.filledTonal( onPressed: null, - icon: const LoadingIndicatorBox(size: 20), - iconSize: 20, + icon: LoadingIndicatorBox(size: iconSize), + iconSize: iconSize, style: actionButtonStyle(), ); } @@ -268,7 +288,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { onPressed: null, tooltip: tooltip, icon: const AppIcon(Symbols.schedule_rounded, fill: 1), - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(), ); } @@ -285,7 +305,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { onPressed: null, tooltip: tooltip, icon: _buildRadialProgress(progress?.progressPercent), - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(), ); } @@ -303,7 +323,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: const AppIcon(Symbols.pause_circle_outline_rounded, fill: 1), tooltip: 'Resume download', - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(foregroundColor: Colors.amber), ); } @@ -333,7 +353,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: const AppIcon(Symbols.error_outline_rounded, fill: 1), tooltip: 'Retry download', - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(foregroundColor: Colors.red), ); } @@ -378,7 +398,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: const AppIcon(Symbols.cancel_rounded, fill: 1), tooltip: 'Cancelled download', - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(foregroundColor: Colors.grey), ); } @@ -406,7 +426,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { ), tooltip: tooltip, icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), ); } @@ -434,7 +454,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, tooltip: tooltip, icon: const AppIcon(Symbols.downloading_rounded, fill: 1), - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(foregroundColor: Colors.orange), ); } @@ -457,7 +477,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { ), icon: AppIcon(isEnabled ? Symbols.sync_rounded : Symbols.sync_disabled_rounded, fill: 1), tooltip: t.downloads.keepNUnwatched(count: syncRule?.episodeCount.toString() ?? '?'), - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(foregroundColor: isEnabled ? Colors.teal : Colors.grey), ); } @@ -480,7 +500,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: const AppIcon(Symbols.file_download_done_rounded, fill: 1), tooltip: t.downloads.deleteDownload, - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(foregroundColor: Colors.green), ); } @@ -509,7 +529,7 @@ extension _MediaDetailActionButtons on _MediaDetailScreenState { }, icon: const AppIcon(Symbols.download_rounded, fill: 1), tooltip: t.downloads.downloadNow, - iconSize: 20, + iconSize: iconSize, style: actionButtonStyle(), ); }, diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index cd4dedde..c18cf88f 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'package:cached_network_image_ce/cached_network_image.dart'; import 'package:flutter/material.dart'; +import '../main.dart' show routeObserver; import '../services/image_cache_service.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:flutter/services.dart'; @@ -44,6 +45,7 @@ import '../utils/download_utils.dart'; import '../services/settings_service.dart'; import '../widgets/settings_builder.dart'; import '../utils/grid_size_calculator.dart'; +import '../utils/layout_constants.dart'; import '../providers/download_provider.dart'; import '../providers/offline_watch_provider.dart'; import '../theme/mono_tokens.dart'; @@ -70,9 +72,18 @@ import 'actor_media_screen.dart'; import '../widgets/focusable_tab_chip.dart'; import '../widgets/hub_section.dart'; import '../widgets/loading_indicator_box.dart'; +import '../widgets/tv_browse_rail.dart'; +import '../widgets/tv_spotlight_background.dart'; part 'media_detail/action_buttons.dart'; +const double _tvDetailTallPosterScale = 0.84; +const double _tvDetailActionSize = 46; +const double _tvDetailActionRailGap = 8; +const String _tvDetailSeasonHubIdPrefix = 'detail_season_'; +const String _tvDetailActorsHubId = 'detail_actors'; +const String _tvDetailActorPersonIdRawKey = 'tvDetailActorPersonId'; + enum _SyncRuleAction { edit, remove, delete } class MediaDetailScreen extends StatefulWidget { @@ -90,7 +101,7 @@ class MediaDetailScreen extends StatefulWidget { } class _MediaDetailScreenState extends State - with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin { + with WatchStateAware, DeletionAware, MountedSetStateMixin, ServerBoundMediaMixin, RouteAware { /// Public input alias — used as the live source of truth until the detail /// fetch returns. Holds backend-neutral [MediaItem] data. MediaItem get _metadata => _fullMetadata ?? widget.metadata; @@ -107,10 +118,19 @@ class _MediaDetailScreenState extends State List? _extras; List _relatedHubs = []; List> _relatedHubKeys = []; + final _tvDetailRailKey = GlobalKey(); + PageRoute? _route; late final ScrollController _scrollController; final ScrollController _extrasScrollController = ScrollController(); bool _watchStateChanged = false; final ValueNotifier _scrollOffset = ValueNotifier(0); + bool _suppressBackAfterPop = false; + bool _tvDetailRevealed = false; + bool _tvDetailRevealScheduled = false; + bool _hasLoadedSeasons = false; + bool _hasLoadedEpisodes = false; + double? _tvDetailPendingRailHeight; + double? _tvDetailStableRailHeight; // Inline season tabs int _selectedSeasonIndex = 0; @@ -473,8 +493,115 @@ class _MediaDetailScreenState extends State _scrollOffset.value = _scrollController.offset; } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final route = ModalRoute.of(context); + if (route is! PageRoute || route == _route) return; + if (_route != null) routeObserver.unsubscribe(this); + _route = route; + routeObserver.subscribe(this, route); + } + + @override + void didPopNext() { + _suppressBackAfterPop = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) _suppressBackAfterPop = false; + }); + }); + } + + bool _consumeBackAfterChildPop(KeyEvent event) { + if (!_suppressBackAfterPop || !event.logicalKey.isBackKey) return false; + if (event is KeyUpEvent) _suppressBackAfterPop = false; + return true; + } + + KeyEventResult _handleMediaDetailBackKey(FocusNode _, KeyEvent event) { + if (_consumeBackAfterChildPop(event)) return KeyEventResult.handled; + return handleBackKeyNavigation(context, event, result: _watchStateChanged); + } + + void _popMediaDetailIfBackNotSuppressed() { + if (_suppressBackAfterPop) { + _suppressBackAfterPop = false; + return; + } + Navigator.pop(context, _watchStateChanged); + } + + bool _isTvDetailReadyToReveal(MediaItem metadata) { + if (_isLoadingMetadata) return false; + + if (metadata.isShow) { + if (_isLoadingSeasons || (!_hasLoadedSeasons && _seasons.isEmpty)) return false; + if (_showEpisodesDirectly) return _hasLoadedEpisodes && !_isLoadingEpisodes; + if (_seasons.isEmpty) return true; + if (_selectedSeasonIndex < 0 || _selectedSeasonIndex >= _seasons.length) return false; + final selectedSeason = _seasons[_selectedSeasonIndex]; + return !_isLoadingSeasonEpisodes && _episodeCache.containsKey(selectedSeason.id); + } + + if (metadata.isSeason) { + return _hasLoadedEpisodes && !_isLoadingEpisodes; + } + + return true; + } + + void _scheduleTvDetailReveal(double railHeight, {required bool focusPrimaryAction}) { + final pendingRailHeight = _tvDetailPendingRailHeight; + if (pendingRailHeight == null || railHeight > pendingRailHeight) { + _tvDetailPendingRailHeight = railHeight; + } + if (_tvDetailRevealed || _tvDetailRevealScheduled) return; + + _tvDetailRevealScheduled = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + setState(() { + _tvDetailStableRailHeight = _tvDetailPendingRailHeight ?? railHeight; + _tvDetailRevealScheduled = false; + _tvDetailRevealed = true; + }); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (focusPrimaryAction) { + _playButtonFocusNode.requestFocus(); + } else { + _tvDetailRailKey.currentState?.requestFocus(); + } + }); + }); + }); + } + + Widget _buildTvDetailRevealGate(Widget child, KeyEventResult Function(FocusNode, KeyEvent) handleBack) { + final revealed = _tvDetailRevealed; + return Focus( + canRequestFocus: !revealed, + onKeyEvent: revealed ? null : handleBack, + child: ExcludeFocus( + excluding: !revealed, + child: IgnorePointer( + ignoring: !revealed, + child: AnimatedOpacity( + opacity: revealed ? 1 : 0, + duration: const Duration(milliseconds: 160), + curve: Curves.easeOutCubic, + child: child, + ), + ), + ), + ); + } + @override void dispose() { + routeObserver.unsubscribe(this); _scrollController.dispose(); _scrollOffset.dispose(); _extrasScrollController.dispose(); @@ -495,16 +622,23 @@ class _MediaDetailScreenState extends State super.dispose(); } - /// Build title text widget for clear logo fallback - Widget _buildTitleText(BuildContext context, String title) { + /// Build title text widget for clear logo fallback. + Widget _buildDetailTitle( + BuildContext context, + String title, { + double? fontSize, + FontWeight fontWeight = FontWeight.bold, + double shadowBlur = 8, + }) { return Align( alignment: Alignment.centerLeft, child: Text( title, style: Theme.of(context).textTheme.displaySmall?.copyWith( color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], + fontWeight: fontWeight, + fontSize: fontSize, + shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: shadowBlur)], ), maxLines: 2, overflow: TextOverflow.ellipsis, @@ -516,9 +650,10 @@ class _MediaDetailScreenState extends State /// If progressPercent is null or 0, shows indeterminate spinner Widget _buildRadialProgress(double? progressPercent) { final colorScheme = Theme.of(context).colorScheme; + final size = PlatformDetector.isTV() ? 26.0 : 20.0; return SizedBox( - width: 20, - height: 20, + width: size, + height: size, child: Stack( alignment: Alignment.center, children: [ @@ -544,15 +679,16 @@ class _MediaDetailScreenState extends State /// Build a metadata chip with optional leading icon or widget Widget _buildMetadataChip(String text, {IconData? icon, Widget? leading}) { final colorScheme = Theme.of(context).colorScheme; + final isTv = PlatformDetector.isTV(); final textWidget = Text( text, - style: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: 13, fontWeight: FontWeight.w500), + style: TextStyle(color: colorScheme.onSecondaryContainer, fontSize: isTv ? 16 : 13, fontWeight: FontWeight.w600), ); final hasLeading = leading != null || icon != null; return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: EdgeInsets.symmetric(horizontal: isTv ? 14 : 12, vertical: isTv ? 8 : 6), decoration: BoxDecoration( color: colorScheme.secondaryContainer.withValues(alpha: 0.8), borderRadius: const BorderRadius.all(Radius.circular(100)), @@ -564,8 +700,8 @@ class _MediaDetailScreenState extends State if (leading != null) leading else - AppIcon(icon!, fill: 1, color: colorScheme.onSecondaryContainer, size: 16), - const SizedBox(width: 4), + AppIcon(icon!, fill: 1, color: colorScheme.onSecondaryContainer, size: isTv ? 20 : 16), + SizedBox(width: isTv ? 6 : 4), textWidget, ], ) @@ -1008,6 +1144,8 @@ class _MediaDetailScreenState extends State setState(() { _fullMetadata = _applyLocalProgress(_metadata); _isLoadingMetadata = false; + _hasLoadedSeasons = true; + _hasLoadedEpisodes = true; }); return; } @@ -1080,12 +1218,16 @@ class _MediaDetailScreenState extends State _seasonsCompleter = Completer(); setStateIfMounted(() { _isLoadingSeasons = true; + _hasLoadedSeasons = false; }); final serverId = _metadata.serverId; final client = serverId == null ? null : context.tryGetMediaClientForServer(serverId); if (client == null) { - setStateIfMounted(() => _isLoadingSeasons = false); + setStateIfMounted(() { + _isLoadingSeasons = false; + _hasLoadedSeasons = true; + }); if (!(_seasonsCompleter?.isCompleted ?? true)) _seasonsCompleter?.complete(); return; } @@ -1138,6 +1280,7 @@ class _MediaDetailScreenState extends State setStateIfMounted(() { _seasons = seasonsWithServerId; _isLoadingSeasons = false; + _hasLoadedSeasons = true; _showEpisodesDirectly = shouldShowEpisodesDirectly; _selectedSeasonIndex = onDeckSeasonIndex; }); @@ -1152,6 +1295,7 @@ class _MediaDetailScreenState extends State appLogger.w('Seasons load failed', error: e, stackTrace: st); setStateIfMounted(() { _isLoadingSeasons = false; + _hasLoadedSeasons = true; }); } finally { if (!(_seasonsCompleter?.isCompleted ?? true)) { @@ -1208,6 +1352,7 @@ class _MediaDetailScreenState extends State setState(() { _seasons = seasons; _isLoadingSeasons = false; + _hasLoadedSeasons = true; _selectedSeasonIndex = onDeckSeasonIndex; }); @@ -1231,6 +1376,7 @@ class _MediaDetailScreenState extends State setState(() { _episodes = seasonEpisodes.map(_applyLocalProgress).toList(); _isLoadingEpisodes = false; + _hasLoadedEpisodes = true; }); } @@ -1267,18 +1413,23 @@ class _MediaDetailScreenState extends State Future _fetchSeasonEpisodes(int seasonIndex) async { if (seasonIndex < 0 || seasonIndex >= _seasons.length) return; final season = _seasons[seasonIndex]; + final seasonId = season.id; // Check cache first - final cached = _episodeCache[season.id]; + final cached = _episodeCache[seasonId]; if (cached != null) { setStateIfMounted(() { - _episodes = cached.map(_applyLocalProgress).toList(); - _isLoadingSeasonEpisodes = false; + if (_isSelectedSeason(seasonIndex, seasonId)) { + _episodes = cached.map(_applyLocalProgress).toList(); + _isLoadingSeasonEpisodes = false; + } }); return; } - setStateIfMounted(() => _isLoadingSeasonEpisodes = true); + setStateIfMounted(() { + if (_isSelectedSeason(seasonIndex, seasonId)) _isLoadingSeasonEpisodes = true; + }); try { if (widget.isOffline) { @@ -1287,18 +1438,18 @@ class _MediaDetailScreenState extends State final allEpisodes = downloadProvider.getDownloadedEpisodesForShow(_metadata.id); final seasonEpisodes = allEpisodes.where((ep) => ep.parentIndex == season.index).toList() ..sort((a, b) => (a.index ?? 0).compareTo(b.index ?? 0)); - _episodeCache[season.id] = seasonEpisodes; - setStateIfMounted(() { - _episodes = seasonEpisodes.map(_applyLocalProgress).toList(); - _isLoadingSeasonEpisodes = false; - }); + _completeSeasonEpisodesLoad( + seasonIndex: seasonIndex, + seasonId: seasonId, + episodes: seasonEpisodes.map(_applyLocalProgress).toList(), + ); } else { // Resolve the right backend client so Jellyfin (where the typed // PlexClient helper returns null) loads episodes too. final serverId = _metadata.serverId; final mediaClient = serverId == null ? null : context.tryGetMediaClientForServer(serverId); if (serverId == null || mediaClient == null) { - setStateIfMounted(() => _isLoadingSeasonEpisodes = false); + _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: const []); return; } final episodes = await mediaClient.fetchChildren(season.id); @@ -1316,17 +1467,34 @@ class _MediaDetailScreenState extends State ) .map(_applyLocalProgress) .toList(); - _episodeCache[season.id] = episodesWithServerId; - setStateIfMounted(() { - _episodes = List.of(episodesWithServerId); - _isLoadingSeasonEpisodes = false; - }); + _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: episodesWithServerId); } } catch (e) { - setStateIfMounted(() => _isLoadingSeasonEpisodes = false); + _completeSeasonEpisodesLoad(seasonIndex: seasonIndex, seasonId: seasonId, episodes: const []); } } + bool _isSelectedSeason(int seasonIndex, String seasonId) { + return _selectedSeasonIndex == seasonIndex && + seasonIndex >= 0 && + seasonIndex < _seasons.length && + _seasons[seasonIndex].id == seasonId; + } + + void _completeSeasonEpisodesLoad({ + required int seasonIndex, + required String seasonId, + required List episodes, + }) { + setStateIfMounted(() { + _episodeCache[seasonId] = episodes; + if (_isSelectedSeason(seasonIndex, seasonId)) { + _episodes = List.of(episodes); + _isLoadingSeasonEpisodes = false; + } + }); + } + /// Load extras (trailers, behind-the-scenes, etc.). Plex-only — Jellyfin /// has no equivalent of `fetchExtras`. Future _loadExtras() async { @@ -1403,7 +1571,7 @@ class _MediaDetailScreenState extends State if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty && _seasonTabFocusNodes.isNotEmpty) { _seasonTabFocusNodes[_selectedSeasonIndex].requestFocus(); _scrollSectionIntoView(_seasonsSectionKey); - } else if (metadata.summary != null && metadata.summary!.isNotEmpty) { + } else if (!PlatformDetector.isTV() && metadata.summary != null && metadata.summary!.isNotEmpty) { _overviewFocusNode.requestFocus(); _scrollSectionIntoView(_overviewSectionKey); } else { @@ -1455,6 +1623,11 @@ class _MediaDetailScreenState extends State KeyEventResult _handlePlayButtonKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; if (!event.isActionable) return KeyEventResult.ignored; + final isTv = PlatformDetector.isTV(); + + if (isTv && key.isUpKey) { + return KeyEventResult.handled; + } // UP: focus the rating chip if available if (key.isUpKey) { @@ -1469,8 +1642,13 @@ class _MediaDetailScreenState extends State final metadata = _fullMetadata ?? _metadata; + if (isTv) { + _tvDetailRailKey.currentState?.requestFocus(); + return KeyEventResult.handled; + } + // DOWN order: overview → seasons → cast → extras - if (metadata.summary != null && metadata.summary!.isNotEmpty) { + if (!PlatformDetector.isTV() && metadata.summary != null && metadata.summary!.isNotEmpty) { _overviewFocusNode.requestFocus(); _scrollSectionIntoView(_overviewSectionKey); return KeyEventResult.handled; @@ -1681,6 +1859,10 @@ class _MediaDetailScreenState extends State } : null, onNavigateDown: () { + if (PlatformDetector.isTV()) { + _tvDetailRailKey.currentState?.requestFocus(); + return; + } _firstEpisodeFocusNode.requestFocus(); }, onLongPress: () => _showSeasonTabContextMenu(index), @@ -1947,7 +2129,7 @@ class _MediaDetailScreenState extends State ? () { if (!_showEpisodesDirectly) { _focusSelectedSeasonTab(); - } else if ((_fullMetadata ?? _metadata).summary?.isNotEmpty == true) { + } else if (!PlatformDetector.isTV() && (_fullMetadata ?? _metadata).summary?.isNotEmpty == true) { _overviewFocusNode.requestFocus(); _scrollSectionIntoView(_overviewSectionKey); } else { @@ -2019,12 +2201,33 @@ class _MediaDetailScreenState extends State } Future _fetchAllEpisodes() async { - if (_seasons.isEmpty) return; + if (_seasons.isEmpty) { + setStateIfMounted(() { + _isLoadingEpisodes = false; + _hasLoadedEpisodes = true; + }); + return; + } final serverId = _metadata.serverId; - if (serverId == null) return; + if (serverId == null) { + setStateIfMounted(() { + _isLoadingEpisodes = false; + _hasLoadedEpisodes = true; + }); + return; + } final client = context.tryGetMediaClientForServer(serverId); - if (client == null) return; - setStateIfMounted(() => _isLoadingEpisodes = true); + if (client == null) { + setStateIfMounted(() { + _isLoadingEpisodes = false; + _hasLoadedEpisodes = true; + }); + return; + } + setStateIfMounted(() { + _isLoadingEpisodes = true; + _hasLoadedEpisodes = false; + }); try { // One-shot recursive expansion — Plex `/grandchildren`, Jellyfin // Recursive=true. Replaces the previous per-season fan-out so a @@ -2054,10 +2257,14 @@ class _MediaDetailScreenState extends State setStateIfMounted(() { _episodes = enriched; _isLoadingEpisodes = false; + _hasLoadedEpisodes = true; }); } catch (e, st) { appLogger.w('Failed to load episodes for all seasons', error: e, stackTrace: st); - setStateIfMounted(() => _isLoadingEpisodes = false); + setStateIfMounted(() { + _isLoadingEpisodes = false; + _hasLoadedEpisodes = true; + }); } } @@ -2177,14 +2384,15 @@ class _MediaDetailScreenState extends State final isMobile = PlatformDetector.isMobile(context); final isTv = PlatformDetector.isTV(); final theme = Theme.of(context); - - KeyEventResult handleBack(FocusNode _, KeyEvent event) => - handleBackKeyNavigation(context, event, result: _watchStateChanged); + final sectionTitleStyle = theme.textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + fontSize: isTv ? 28 : null, + ); // Show loading state while fetching full metadata if (_isLoadingMetadata) { final loading = Focus( - onKeyEvent: handleBack, + onKeyEvent: _handleMediaDetailBackKey, child: Scaffold( appBar: AppBar(), body: const Center(child: CircularProgressIndicator()), @@ -2204,11 +2412,15 @@ class _MediaDetailScreenState extends State // Determine header height based on screen size final size = MediaQuery.sizeOf(context); - final headerHeight = size.height * 0.6; + final headerHeight = size.height * (isTv ? 1.0 : 0.6); + + if (isTv) { + return _buildTvDetailScreen(context, metadata, _handleMediaDetailBackKey); + } final content = OverlaySheetHost( child: Focus( - onKeyEvent: handleBack, + onKeyEvent: _handleMediaDetailBackKey, child: Scaffold( body: Stack( children: [ @@ -2216,22 +2428,21 @@ class _MediaDetailScreenState extends State controller: _scrollController, slivers: [ // Hero header with background art - SliverToBoxAdapter(child: _buildHeroHeader(context, metadata, size, headerHeight, theme)), + SliverToBoxAdapter(child: _buildHeroHeader(context, metadata, size, headerHeight)), // Main content SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.all(16), + padding: EdgeInsets.symmetric( + horizontal: isTv ? TvLayoutConstants.horizontalInset : 16, + vertical: isTv ? 8 : 16, + ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Summary - if (metadata.summary != null && metadata.summary!.isNotEmpty) ...[ - Text( - key: _overviewSectionKey, - t.discover.overview, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + if (!isTv && metadata.summary != null && metadata.summary!.isNotEmpty) ...[ + Text(key: _overviewSectionKey, t.discover.overview, style: sectionTitleStyle), const SizedBox(height: 12), Focus( focusNode: _overviewFocusNode, @@ -2279,11 +2490,7 @@ class _MediaDetailScreenState extends State else if (_seasons.isEmpty) _sectionEmpty(context, t.messages.noSeasonsFound) else ...[ - Text( - key: _seasonsSectionKey, - t.libraries.groupings.episodes, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle), const SizedBox(height: 12), _buildSeasonTabs(), const SizedBox(height: 16), @@ -2297,11 +2504,7 @@ class _MediaDetailScreenState extends State const SizedBox(height: 24), ] else if ((isShow && _showEpisodesDirectly) || metadata.isSeason) ...[ // Server says flatten — existing behavior unchanged - Text( - key: _seasonsSectionKey, - t.libraries.groupings.episodes, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + Text(key: _seasonsSectionKey, t.libraries.groupings.episodes, style: sectionTitleStyle), const SizedBox(height: 12), if (_isLoadingSeasons || _isLoadingEpisodes) _sectionLoading @@ -2314,11 +2517,7 @@ class _MediaDetailScreenState extends State // Cast if (metadata.roles != null && metadata.roles!.isNotEmpty) ...[ - Text( - key: _castSectionKey, - t.discover.cast, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + Text(key: _castSectionKey, t.discover.cast, style: sectionTitleStyle), const SizedBox(height: 12), _buildCastSection(metadata), const SizedBox(height: 24), @@ -2326,11 +2525,7 @@ class _MediaDetailScreenState extends State // Trailers & Extras Section if (!widget.isOffline && _extras != null && _extras!.isNotEmpty) ...[ - Text( - key: _extrasSectionKey, - t.discover.extras, - style: theme.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.bold), - ), + Text(key: _extrasSectionKey, t.discover.extras, style: sectionTitleStyle), const SizedBox(height: 12), _buildExtrasSection(), const SizedBox(height: 24), @@ -2345,7 +2540,7 @@ class _MediaDetailScreenState extends State inset: true, onVerticalNavigation: (isUp) => _handleRelatedHubNavigation(i, isUp), ), - const SizedBox(height: 8), + SizedBox(height: isTv ? 28 : 8), ], // Additional info — wrapped in Focus so DPAD DOWN from the @@ -2439,7 +2634,433 @@ class _MediaDetailScreenState extends State ); } - Widget _buildHeroHeader(BuildContext context, MediaItem metadata, Size size, double headerHeight, ThemeData theme) { + Widget _buildTvDetailScreen( + BuildContext context, + MediaItem metadata, + KeyEventResult Function(FocusNode, KeyEvent) handleBack, + ) { + final size = MediaQuery.sizeOf(context); + final detailHubs = _tvDetailHubs(metadata); + final hideSpoilers = SettingsService.instanceOrNull!.read(SettingsService.hideSpoilers); + final detailScale = TvLayoutConstants.scaleForSize(size); + final spotlightTop = (size.height * 0.08).clamp(44.0 * detailScale, 110.0 * detailScale).toDouble(); + final rawRailHeight = _estimateTvBrowseRailHeight(size, detailHubs); + if (!_tvDetailRevealed && _isTvDetailReadyToReveal(metadata)) { + _scheduleTvDetailReveal(rawRailHeight, focusPrimaryAction: metadata.isMovie); + } + final stableRailHeight = _tvDetailStableRailHeight; + final railHeight = stableRailHeight == null || rawRailHeight > stableRailHeight ? rawRailHeight : stableRailHeight; + final railTopPadding = 12 * detailScale; + final foregroundBottom = (railHeight - railTopPadding) + (_tvDetailActionRailGap * detailScale); + final spotlightLeft = (24 * detailScale).clamp(18.0, 40.0).toDouble(); + + final revealContent = Stack( + fit: StackFit.expand, + children: [ + Positioned( + left: spotlightLeft, + right: size.width * 0.43, + top: spotlightTop, + bottom: foregroundBottom, + child: _buildTvDetailForeground(context, metadata, hideSpoilers: hideSpoilers, scale: detailScale), + ), + Positioned( + top: 0, + left: 0, + child: DesktopAppBarHelper.buildAdjustedLeading( + AppBarBackButton( + style: BackButtonStyle.circular, + onPressed: () => Navigator.pop(context, _watchStateChanged), + ), + context: context, + )!, + ), + if (detailHubs.isNotEmpty) + Positioned( + left: 0, + right: 0, + bottom: 0, + child: TvBrowseRail( + key: _tvDetailRailKey, + hubs: detailHubs, + iconForHub: _getTvDetailHubIcon, + onFocusedItemChanged: (_) {}, + onRefresh: (_) => unawaited(_loadFullMetadata()), + onActiveHubChanged: _handleTvDetailHubChanged, + onActivateItem: _handleTvDetailRailItemActivated, + onNavigateUp: () => _playButtonFocusNode.requestFocus(), + onBack: _popMediaDetailIfBackNotSuppressed, + tallPosterScale: _tvDetailTallPosterScale, + initialHubId: _tvDetailInitialHubId(metadata), + initialItemId: _tvDetailInitialItemId(metadata), + ), + ), + ], + ); + + final content = OverlaySheetHost( + child: Focus( + onKeyEvent: handleBack, + child: Scaffold( + body: Stack( + children: [ + TvSpotlightBackground(item: metadata, client: _getArtworkMediaClient(context), showInfo: false), + _buildTvDetailRevealGate(revealContent, handleBack), + ], + ), + ), + ), + ); + + final blockSystemBack = Platform.isAndroid && InputModeTracker.isKeyboardMode(context); + if (!blockSystemBack) return content; + return PopScope( + canPop: false, + // ignore: no-empty-block - required callback, blocks system back on Android TV + onPopInvokedWithResult: (didPop, result) {}, + child: content, + ); + } + + Widget _buildTvDetailForeground( + BuildContext context, + MediaItem metadata, { + required bool hideSpoilers, + required double scale, + }) { + final theme = Theme.of(context); + final shouldHideSpoiler = hideSpoilers && metadata.shouldHideSpoiler; + final summary = shouldHideSpoiler ? null : metadata.summary; + final spoilerText = shouldHideSpoiler && metadata.isEpisode + ? (_tvDetailEpisodePrefix(metadata) ?? metadata.title ?? '') + : null; + final description = summary != null && summary.isNotEmpty ? _tvDetailSummaryText(metadata, summary) : spoilerText; + + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxHeight <= 0 || constraints.maxWidth <= 0) return const SizedBox.shrink(); + + final availableHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : 264.0; + final desiredLogoHeight = 220 * scale; + final minLogoHeight = 72 * scale; + final desiredLogoWidth = 790 * scale; + final metadataLineHeight = 22 * scale; + final logoMetadataGap = 10 * scale; + final summaryGap = 10 * scale; + final summaryFontSize = availableHeight < 260 * scale ? 16.2 * scale : 18 * scale; + final summaryLineHeight = summaryFontSize * 1.35; + final actionHeight = _tvDetailActionSize * scale; + final actionGap = 12 * scale; + final hasDescription = description != null && description.isNotEmpty; + var summaryMaxLines = 0; + var logoHeight = 0.0; + + for (var lines = hasDescription ? 3 : 0; lines >= 0; lines--) { + final descriptionHeight = lines > 0 ? summaryGap + (summaryLineHeight * lines) : 0.0; + final reservedHeight = logoMetadataGap + metadataLineHeight + descriptionHeight + actionGap + actionHeight; + final remainingForLogo = availableHeight - reservedHeight; + if (remainingForLogo >= minLogoHeight || lines == 0) { + summaryMaxLines = lines; + logoHeight = remainingForLogo <= 0 ? 0 : remainingForLogo.clamp(0, desiredLogoHeight).toDouble(); + break; + } + } + + final showLogo = logoHeight > 0; + final descriptionHeight = summaryMaxLines > 0 ? summaryGap + (summaryLineHeight * summaryMaxLines) : 0.0; + final contentHeight = + (showLogo ? logoHeight + logoMetadataGap : 0) + + metadataLineHeight + + descriptionHeight + + actionGap + + actionHeight; + final logoWidth = desiredLogoWidth < constraints.maxWidth ? desiredLogoWidth : constraints.maxWidth; + + return ClipRect( + child: SizedBox( + height: availableHeight, + child: Align( + alignment: Alignment.bottomLeft, + child: SizedBox( + height: contentHeight <= availableHeight ? contentHeight : availableHeight, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (showLogo) ...[ + _buildDetailLogoOrTitle( + context, + metadata, + width: logoWidth, + height: logoHeight, + titleBuilder: (context, title) => _buildDetailTitle( + context, + title, + fontSize: 56 * scale, + fontWeight: FontWeight.w800, + shadowBlur: 12, + ), + ), + SizedBox(height: logoMetadataGap), + ], + SizedBox( + height: metadataLineHeight, + child: Align( + alignment: Alignment.centerLeft, + child: _buildTvDetailMetadataLine(context, metadata, scale), + ), + ), + if (hasDescription && summaryMaxLines > 0) ...[ + SizedBox(height: summaryGap), + SizedBox( + height: summaryLineHeight * summaryMaxLines, + child: Text( + description, + maxLines: summaryMaxLines, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyLarge?.copyWith( + color: Colors.white.withValues(alpha: 0.78), + fontSize: summaryFontSize, + height: 1.35, + ), + ), + ), + ], + SizedBox(height: actionGap), + SizedBox(height: actionHeight, child: _buildActionButtons(metadata)), + ], + ), + ), + ), + ), + ); + }, + ); + } + + Widget _buildDetailLogoOrTitle( + BuildContext context, + MediaItem metadata, { + required double width, + required double height, + required Widget Function(BuildContext context, String title) titleBuilder, + }) { + Widget titleFallback(BuildContext context) => titleBuilder(context, metadata.displayTitle); + + if (metadata.clearLogoPath == null) { + return SizedBox(width: width, height: height, child: titleFallback(context)); + } + + return SizedBox( + width: width, + height: height, + child: Builder( + builder: (context) { + final localArtwork = _buildOfflineArtworkIfAvailable( + context, + artworkPaths: [metadata.clearLogoPath], + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + imageType: ImageType.logo, + errorWidget: (context, url, error) => titleFallback(context), + ); + if (localArtwork != null) return localArtwork; + + final client = _getArtworkMediaClient(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final logoUrl = MediaImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: metadata.clearLogoPath, + maxWidth: width, + maxHeight: height, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); + + if (logoUrl.isEmpty) return titleFallback(context); + + return blurArtwork( + CachedNetworkImage( + imageUrl: logoUrl, + cacheManager: PlexImageCacheManager.instance, + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + memCacheWidth: (width * dpr).clamp(200, 1000).round(), + placeholder: (context, url) => const SizedBox.shrink(), + errorBuilder: (context, error, stackTrace) => titleFallback(context), + ), + sigma: 10, + clip: false, + ); + }, + ), + ); + } + + Widget _buildTvDetailMetadataLine(BuildContext context, MediaItem metadata, double scale) { + final parts = [ + if (metadata.isMovie) t.discover.movie else if (metadata.isShow) t.discover.tvShow, + if (metadata.rating != null) '★ ${formatRating(metadata.rating!)}', + if (metadata.contentRating != null) formatContentRating(metadata.contentRating!), + if (metadata.durationMs != null) formatDurationTextual(metadata.durationMs!), + if (metadata.year != null) metadata.year.toString(), + ]; + + return Text( + parts.join(' • '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: Colors.white, fontSize: 18 * scale, fontWeight: FontWeight.w700, letterSpacing: 0.1), + ); + } + + String _tvDetailSummaryText(MediaItem metadata, String summary) { + final prefix = _tvDetailEpisodePrefix(metadata); + if (prefix == null) return summary; + return '$prefix: $summary'; + } + + String? _tvDetailEpisodePrefix(MediaItem metadata) { + if (!metadata.isEpisode || metadata.parentIndex == null || metadata.index == null) return null; + return 'S${metadata.parentIndex}, E${metadata.index}'; + } + + double _estimateTvBrowseRailHeight(Size size, List hubs) { + final svc = SettingsService.instanceOrNull!; + return TvBrowseRailLayout.estimateHeight( + size: size, + hubs: hubs, + density: svc.read(SettingsService.libraryDensity), + episodePosterMode: svc.read(SettingsService.episodePosterMode), + tallPosterScale: _tvDetailTallPosterScale, + ); + } + + List _tvDetailHubs(MediaItem metadata) { + final hubs = []; + if (metadata.isShow && !_showEpisodesDirectly && _seasons.isNotEmpty) { + for (var i = 0; i < _seasons.length; i++) { + final season = _seasons[i]; + final episodes = i == _selectedSeasonIndex ? _episodes : (_episodeCache[season.id] ?? const []); + hubs.add( + MediaHub( + id: '$_tvDetailSeasonHubIdPrefix$i', + title: season.title?.isNotEmpty == true ? season.title! : (season.displaySubtitle ?? season.displayTitle), + type: 'episode', + items: episodes, + size: episodes.length, + ), + ); + } + } else if (_episodes.isNotEmpty) { + hubs.add( + MediaHub( + id: 'detail_episodes', + title: t.libraries.groupings.episodes, + type: 'episode', + items: _episodes, + size: _episodes.length, + ), + ); + } + final actors = _tvDetailActorItems(metadata); + if (actors.isNotEmpty) { + hubs.add( + MediaHub(id: _tvDetailActorsHubId, title: t.discover.cast, type: 'person', items: actors, size: actors.length), + ); + } + if (_extras != null && _extras!.isNotEmpty) { + hubs.add( + MediaHub(id: 'detail_extras', title: t.discover.extras, type: 'clip', items: _extras!, size: _extras!.length), + ); + } + hubs.addAll(_relatedHubs.where((hub) => hub.items.isNotEmpty)); + return hubs; + } + + String? _tvDetailInitialHubId(MediaItem metadata) { + if (!metadata.isShow || _showEpisodesDirectly || _seasons.isEmpty) return null; + return '$_tvDetailSeasonHubIdPrefix$_selectedSeasonIndex'; + } + + String? _tvDetailInitialItemId(MediaItem metadata) { + if (!metadata.isShow) return null; + return _onDeckEpisode?.id; + } + + List _tvDetailActorItems(MediaItem metadata) { + final roles = metadata.roles; + if (roles == null || roles.isEmpty) return const []; + + return [ + for (var i = 0; i < roles.length; i++) + if (roles[i].tag.trim().isNotEmpty) _tvDetailActorItem(metadata, roles[i], i), + ]; + } + + MediaItem _tvDetailActorItem(MediaItem metadata, MediaRole actor, int index) { + final personId = actor.id?.trim(); + return MediaItem( + id: personId != null && personId.isNotEmpty ? '${metadata.id}_actor_$personId' : '${metadata.id}_actor_$index', + backend: metadata.backend, + kind: MediaKind.unknown, + title: actor.tag, + parentTitle: actor.role, + thumbPath: actor.thumbPath, + serverId: metadata.serverId, + serverName: metadata.serverName, + raw: {if (personId != null && personId.isNotEmpty) _tvDetailActorPersonIdRawKey: personId}, + ); + } + + bool _handleTvDetailRailItemActivated(MediaHub hub, MediaItem item) { + if (hub.id != _tvDetailActorsHubId) return false; + final personId = item.raw?[_tvDetailActorPersonIdRawKey]; + if (personId is String && personId.isNotEmpty) { + _navigateToActorMedia( + MediaRole(id: personId, tag: item.displayTitle, role: item.parentTitle, thumbPath: item.thumbPath), + ); + } + return true; + } + + void _handleTvDetailHubChanged(MediaHub hub, int index) { + if (!hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return; + final seasonIndex = int.tryParse(hub.id.substring(_tvDetailSeasonHubIdPrefix.length)); + if (seasonIndex == null || seasonIndex < 0 || seasonIndex >= _seasons.length) return; + final season = _seasons[seasonIndex]; + final cached = _episodeCache[season.id]; + if (_selectedSeasonIndex == seasonIndex && (cached != null || _episodes.isNotEmpty || _isLoadingSeasonEpisodes)) { + return; + } + + if (cached != null) { + setStateIfMounted(() { + _selectedSeasonIndex = seasonIndex; + _episodes = cached.map(_applyLocalProgress).toList(); + _isLoadingSeasonEpisodes = false; + }); + return; + } + + setStateIfMounted(() { + _selectedSeasonIndex = seasonIndex; + _episodes = const []; + _isLoadingSeasonEpisodes = true; + }); + unawaited(_fetchSeasonEpisodes(seasonIndex)); + } + + IconData _getTvDetailHubIcon(MediaHub hub, int index) { + if (hub.id.startsWith(_tvDetailSeasonHubIdPrefix)) return Symbols.tv_rounded; + if (hub.id == 'detail_episodes') return Symbols.tv_rounded; + if (hub.id == 'detail_extras') return Symbols.theaters_rounded; + if (hub.id == _tvDetailActorsHubId) return Symbols.group_rounded; + return _getRelatedHubIcon(hub); + } + + Widget _buildHeroHeader(BuildContext context, MediaItem metadata, Size size, double headerHeight) { return Stack( children: [ // Background Art (fixed height, no parallax) @@ -2466,7 +3087,7 @@ class _MediaDetailScreenState extends State final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( displayWidth: (mqSize.width * dpr).round(), - displayHeight: (mqSize.height * 0.6 * dpr).round(), + displayHeight: (headerHeight * dpr).round(), imageType: ImageType.art, ); @@ -2509,93 +3130,15 @@ class _MediaDetailScreenState extends State // Content at bottom Positioned( + top: 0, bottom: 16, left: 0, right: 0, child: SafeArea( + top: false, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Clear logo or title - if (metadata.clearLogoPath != null) - SizedBox( - height: 120, - width: 400, - child: Builder( - builder: (context) { - final localArtwork = _buildOfflineArtworkIfAvailable( - context, - artworkPaths: [metadata.clearLogoPath], - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - imageType: ImageType.logo, - errorWidget: (context, url, error) => _buildTitleText(context, metadata.displayTitle), - ); - if (localArtwork != null) return localArtwork; - - final client = _getArtworkMediaClient(context); - final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); - final logoUrl = MediaImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: metadata.clearLogoPath, - maxWidth: 400, - maxHeight: 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); - - return blurArtwork( - CachedNetworkImage( - imageUrl: logoUrl, - cacheManager: PlexImageCacheManager.instance, - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - alignment: Alignment.centerLeft, - memCacheWidth: (400 * dpr).clamp(200, 800).round(), - placeholder: (context, url) => const SizedBox.shrink(), - errorBuilder: (context, error, stackTrace) => - _buildTitleText(context, metadata.displayTitle), - ), - sigma: 10, - clip: false, - ); - }, - ), - ) - else - Text( - metadata.displayTitle, - style: theme.textTheme.displaySmall?.copyWith( - color: Colors.white, - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], - ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 12), - - // Metadata chips - Wrap( - spacing: 8, - runSpacing: 8, - children: [ - if (metadata.year != null) _buildMetadataChip('${metadata.year}'), - if (metadata case PlexMediaItem(:final editionTitle?)) _buildMetadataChip(editionTitle), - if (metadata.contentRating != null) - _buildMetadataChip(formatContentRating(metadata.contentRating!)), - if (metadata.durationMs != null) _buildMetadataChip(formatDurationTextual(metadata.durationMs!)), - ..._buildRatingChips(metadata), - ], - ), - const SizedBox(height: 16), - // Action buttons - _buildActionButtons(metadata), - ], - ), + child: _buildHeroHeaderContent(context, metadata), ), ), ), @@ -2603,6 +3146,92 @@ class _MediaDetailScreenState extends State ); } + Widget _buildHeroHeaderContent(BuildContext context, MediaItem metadata) { + return LayoutBuilder( + builder: (context, constraints) { + if (constraints.maxHeight <= 0 || constraints.maxWidth <= 0) return const SizedBox.shrink(); + + final availableHeight = constraints.maxHeight.isFinite ? constraints.maxHeight : 264.0; + const desiredLogoHeight = 120.0; + const desiredLogoWidth = 400.0; + const actionHeight = 48.0; + final chips = [ + if (metadata.year != null) _buildMetadataChip('${metadata.year}'), + if (metadata case PlexMediaItem(:final editionTitle?)) _buildMetadataChip(editionTitle), + if (metadata.contentRating != null) _buildMetadataChip(formatContentRating(metadata.contentRating!)), + if (metadata.durationMs != null) _buildMetadataChip(formatDurationTextual(metadata.durationMs!)), + ..._buildRatingChips(metadata), + ]; + + final showActions = availableHeight >= actionHeight; + final remainingAfterActions = availableHeight - (showActions ? actionHeight : 0); + final showChips = chips.isNotEmpty && remainingAfterActions >= 88; + final chipHeight = showChips ? (remainingAfterActions >= 170 ? 68.0 : 32.0) : 0.0; + final chipActionGap = showChips && showActions ? (availableHeight < 180 ? 8.0 : 16.0) : 0.0; + final remainingForLogo = remainingAfterActions - chipHeight - chipActionGap; + final logoGap = remainingForLogo >= 52 && (showChips || showActions) + ? (availableHeight < 180 ? 8.0 : 12.0) + : 0.0; + final logoHeight = (remainingForLogo - logoGap).clamp(0.0, desiredLogoHeight).toDouble(); + final showLogo = logoHeight >= 24; + final effectiveLogoGap = showLogo ? logoGap : 0.0; + final logoWidth = desiredLogoWidth.clamp(0.0, constraints.maxWidth).toDouble(); + final titleFontSize = (logoHeight * 0.38).clamp(24.0, 40.0).toDouble(); + final contentHeight = + (showLogo ? logoHeight + effectiveLogoGap : 0.0) + + chipHeight + + chipActionGap + + (showActions ? actionHeight : 0.0); + + return ClipRect( + child: SizedBox( + height: availableHeight, + child: Align( + alignment: Alignment.bottomLeft, + child: SizedBox( + height: contentHeight.clamp(0.0, availableHeight).toDouble(), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + if (showLogo) ...[ + _buildDetailLogoOrTitle( + context, + metadata, + width: logoWidth, + height: logoHeight, + titleBuilder: (context, title) => _buildDetailTitle( + context, + title, + fontSize: titleFontSize, + fontWeight: FontWeight.bold, + shadowBlur: 8, + ), + ), + if (effectiveLogoGap > 0) SizedBox(height: effectiveLogoGap), + ], + if (showChips) + SizedBox( + height: chipHeight, + child: ClipRect( + child: Align( + alignment: Alignment.topLeft, + child: Wrap(spacing: 8, runSpacing: 8, children: chips), + ), + ), + ), + if (chipActionGap > 0) SizedBox(height: chipActionGap), + if (showActions) SizedBox(height: actionHeight, child: _buildActionButtons(metadata)), + ], + ), + ), + ), + ), + ); + }, + ); + } + /// Get the primary trailer from the extras list MediaItem? _getPrimaryTrailer() { if (_extras == null || _extras!.isEmpty) return null; diff --git a/lib/utils/layout_constants.dart b/lib/utils/layout_constants.dart index df120885..43db3771 100644 --- a/lib/utils/layout_constants.dart +++ b/lib/utils/layout_constants.dart @@ -51,3 +51,20 @@ class GridLayoutConstants { /// Standard grid padding static EdgeInsets get gridPadding => const EdgeInsets.only(left: 2, right: 2, bottom: 2); } + +class TvLayoutConstants { + static const double horizontalInset = 72; + static const double shelfHorizontalInset = 56; + static const double shelfVerticalGap = 32; + static const double heroContentMaxWidth = 760; + static const double heroLogoWidth = 520; + static const double heroLogoHeight = 150; + static const double compactHeroLogoWidth = 420; + static const double compactHeroLogoHeight = 112; + + static double scaleForHeight(double height) => (height / 1080).clamp(0.85, 1.35).toDouble(); + + static double scaleForSize(Size size) => scaleForHeight(size.height); + + static double scaleOf(BuildContext context) => scaleForSize(MediaQuery.sizeOf(context)); +} diff --git a/lib/widgets/focusable_tab_chip.dart b/lib/widgets/focusable_tab_chip.dart index 3c637ff1..989c6d6d 100644 --- a/lib/widgets/focusable_tab_chip.dart +++ b/lib/widgets/focusable_tab_chip.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import '../focus/focusable_chip_mixin.dart'; import '../focus/input_mode_tracker.dart'; +import '../utils/platform_detector.dart'; import 'focus_builders.dart'; /// A focusable tab chip that shows a color change when focused or selected. @@ -122,8 +123,13 @@ class _FocusableTabChipState extends State with FocusableChipS foregroundColor = colorScheme.onPrimary; } else { // Neither selected nor focused - backgroundColor = colorScheme.surfaceContainerHighest; - foregroundColor = colorScheme.onSurfaceVariant; + if (PlatformDetector.isTV()) { + backgroundColor = colorScheme.secondaryContainer.withValues(alpha: 0.38); + foregroundColor = colorScheme.onSecondaryContainer; + } else { + backgroundColor = colorScheme.surfaceContainerHighest; + foregroundColor = colorScheme.onSurfaceVariant; + } } final isHighlighted = showFocus || widget.isSelected; diff --git a/lib/widgets/hub_section.dart b/lib/widgets/hub_section.dart index 991f8dbd..17bb5535 100644 --- a/lib/widgets/hub_section.dart +++ b/lib/widgets/hub_section.dart @@ -11,6 +11,8 @@ import '../focus/key_event_utils.dart'; import '../services/settings_service.dart'; import 'settings_builder.dart'; import '../utils/grid_size_calculator.dart'; +import '../utils/layout_constants.dart'; +import '../utils/platform_detector.dart'; import '../theme/mono_tokens.dart'; import '../focus/locked_hub_controller.dart'; import '../media/media_hub.dart'; @@ -41,6 +43,9 @@ class HubSection extends StatefulWidget { final bool showServerName; final Future> Function()? loadMoreItems; + /// Reports the current focused media item. Used by TV spotlight layouts. + final ValueChanged? onFocusedItemChanged; + /// Callback for vertical navigation (up/down). Return true if handled. final bool Function(bool isUp)? onVerticalNavigation; @@ -60,6 +65,9 @@ class HubSection extends StatefulWidget { /// Use when the parent already provides edge spacing (e.g. inside Padding(16)). final bool inset; + /// Vertical viewport alignment when this hub is focused. + final double focusScrollAlignment; + const HubSection({ super.key, required this.hub, @@ -69,11 +77,13 @@ class HubSection extends StatefulWidget { this.isInContinueWatching = false, this.showServerName = false, this.loadMoreItems, + this.onFocusedItemChanged, this.onVerticalNavigation, this.onBack, this.onNavigateUp, this.onNavigateToSidebar, this.inset = false, + this.focusScrollAlignment = 0.3, }); @override @@ -90,7 +100,12 @@ class HubSectionState extends State with MountedSetStateMixin { int _focusedIndex = 0; double _itemExtent = 0; - double get _leadingPadding => widget.inset ? 0.0 : 12.0; + double _leadingPaddingFor(bool isTv) => widget.inset + ? 0.0 + : isTv + ? TvLayoutConstants.shelfHorizontalInset + : 12.0; + double get _leadingPadding => _leadingPaddingFor(PlatformDetector.isTV()); Timer? _longPressTimer; bool _isSelectKeyDown = false; @@ -109,6 +124,12 @@ class HubSectionState extends State with MountedSetStateMixin { @override void didUpdateWidget(HubSection oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.hub.id != oldWidget.hub.id) { + _mediaCardKeys.clear(); + } else if (widget.hub.items.length != oldWidget.hub.items.length) { + _mediaCardKeys.removeWhere((index, _) => index >= widget.hub.items.length); + } + if (widget.hub.items.length != oldWidget.hub.items.length) { final maxIndex = _totalItemCount == 0 ? 0 : _totalItemCount - 1; if (_focusedIndex > maxIndex) { @@ -132,6 +153,8 @@ class HubSectionState extends State with MountedSetStateMixin { _longPressTimer?.cancel(); _isSelectKeyDown = false; _longPressTriggered = false; + } else { + _notifyFocusedItemChanged(); } // ignore: no-empty-block - setState triggers rebuild to update focus styling setStateIfMounted(() {}); @@ -141,10 +164,11 @@ class HubSectionState extends State with MountedSetStateMixin { void requestFocusAt(int index) { if (_totalItemCount == 0) return; - final clamped = index.clamp(0, _totalItemCount - 1); + final clamped = index.clamp(0, _totalItemCount - 1).toInt(); _focusedIndex = clamped; // Remember this position for this specific hub HubFocusMemory.setForHub(widget.hub.id, clamped); + _notifyFocusedItemChanged(); _scrollToIndex(clamped); _hubFocusNode.requestFocus(); // ignore: no-empty-block - setState triggers rebuild to update focus styling @@ -165,7 +189,7 @@ class HubSectionState extends State with MountedSetStateMixin { if (!mounted) return; Scrollable.ensureVisible( context, - alignment: 0.3, // Position hub near top third of viewport + alignment: widget.focusScrollAlignment, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); @@ -245,6 +269,7 @@ class HubSectionState extends State with MountedSetStateMixin { _focusedIndex--; }); HubFocusMemory.setForHub(widget.hub.id, _focusedIndex); + _notifyFocusedItemChanged(); _scrollToIndex(_focusedIndex); } else if (widget.onNavigateToSidebar != null) { // At leftmost item: navigate to sidebar @@ -261,6 +286,7 @@ class HubSectionState extends State with MountedSetStateMixin { _focusedIndex++; }); HubFocusMemory.setForHub(widget.hub.id, _focusedIndex); + _notifyFocusedItemChanged(); _scrollToIndex(_focusedIndex); } return KeyEventResult.handled; @@ -296,6 +322,11 @@ class HubSectionState extends State with MountedSetStateMixin { return _mediaCardKeys.putIfAbsent(index, () => GlobalKey()); } + void _notifyFocusedItemChanged() { + if (_focusedIndex < 0 || _focusedIndex >= widget.hub.items.length) return; + widget.onFocusedItemChanged?.call(widget.hub.items[_focusedIndex]); + } + void _activateCurrentItem() { if (_focusedIndex == widget.hub.items.length && widget.hub.more) { _navigateToHubDetail(context); @@ -312,7 +343,7 @@ class HubSectionState extends State with MountedSetStateMixin { _mediaCardKeys[_focusedIndex]?.currentState?.showContextMenu(); } - Future _navigateToItem(dynamic item) async { + Future _navigateToItem(MediaItem item) async { await navigateToMediaItem(context, item, onRefresh: widget.onRefresh, playDirectly: widget.isInContinueWatching); } @@ -330,124 +361,176 @@ class HubSectionState extends State with MountedSetStateMixin { ); } + double _getTvCardWidth(double availableWidth, int density, double leadingPadding) { + final f = LibraryDensity.factor(density); + final targetCards = 7.0 - (f * 2.0); + final usableWidth = (availableWidth - (leadingPadding * 2)).clamp(1.0, double.infinity); + return (usableWidth / targetCards).clamp(210.0, 340.0); + } + @override Widget build(BuildContext context) { final hasFocus = _hubFocusNode.hasFocus; final isKeyboardMode = InputModeTracker.isKeyboardMode(context); + final isTv = PlatformDetector.isTV(); + final leadingPadding = _leadingPaddingFor(isTv); + final titleStyle = Theme.of( + context, + ).textTheme.titleLarge?.copyWith(fontSize: isTv ? 26 : null, fontWeight: isTv ? FontWeight.w700 : null); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - // Hub header (NOT focusable - titles should not be focusable) - Padding( - padding: widget.inset ? const EdgeInsets.symmetric(vertical: 2) : const EdgeInsets.fromLTRB(8, 2, 8, 2), - child: ExcludeFocus( - child: InkWell( - onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - child: Padding( - padding: widget.inset - ? const EdgeInsets.symmetric(vertical: 2) - : const EdgeInsets.symmetric(horizontal: 4, vertical: 2), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - AppIcon(widget.icon, fill: 1), - const SizedBox(width: 8), - Flexible( - child: Text( - widget.hub.title, - style: Theme.of(context).textTheme.titleLarge, - overflow: TextOverflow.ellipsis, - maxLines: 1, + return Padding( + padding: EdgeInsets.only(bottom: isTv && !widget.inset ? TvLayoutConstants.shelfVerticalGap : 0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + // Hub header (NOT focusable - titles should not be focusable) + Padding( + padding: widget.inset + ? EdgeInsets.symmetric(vertical: isTv ? 6 : 2) + : EdgeInsets.fromLTRB(leadingPadding - 4, isTv ? 6 : 2, 8, isTv ? 8 : 2), + child: ExcludeFocus( + child: InkWell( + onTap: widget.hub.more ? () => _navigateToHubDetail(context) : null, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: Padding( + padding: widget.inset + ? const EdgeInsets.symmetric(vertical: 2) + : const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(widget.icon, fill: 1, size: isTv ? 28 : null), + SizedBox(width: isTv ? 12 : 8), + Flexible( + child: Text(widget.hub.title, style: titleStyle, overflow: TextOverflow.ellipsis, maxLines: 1), ), - ), - if (widget.showServerName && widget.hub.serverName != null) ...[ - const SizedBox(width: 8), - Text( - '•', - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7), + if (widget.showServerName && widget.hub.serverName != null) ...[ + const SizedBox(width: 8), + Text( + '•', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7), + ), ), - ), - const SizedBox(width: 8), - Text( - widget.hub.serverName!, - style: Theme.of(context).textTheme.titleMedium?.copyWith( - color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7), + const SizedBox(width: 8), + Text( + widget.hub.serverName!, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Theme.of(context).colorScheme.primary.withValues(alpha: 0.7), + ), ), - ), + ], + if (widget.hub.more && !isKeyboardMode) ...[ + const SizedBox(width: 4), + AppIcon(Symbols.chevron_right_rounded, fill: 1, size: isTv ? 26 : 20), + ], ], - if (widget.hub.more && !isKeyboardMode) ...[ - const SizedBox(width: 4), - const AppIcon(Symbols.chevron_right_rounded, fill: 1, size: 20), - ], - ], + ), ), ), ), ), - ), - if (widget.hub.items.isNotEmpty) - Focus( - focusNode: _hubFocusNode, - onKeyEvent: _handleKeyEvent, - child: SettingsBuilder( - prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode], - builder: (context) => LayoutBuilder( - builder: (context, constraints) { - final svc = SettingsService.instanceOrNull!; - final baseCardWidth = GridSizeCalculator.getCellWidth( - constraints.maxWidth, - context, - svc.read(SettingsService.libraryDensity), - ); + if (widget.hub.items.isNotEmpty) + Focus( + focusNode: _hubFocusNode, + onKeyEvent: _handleKeyEvent, + child: SettingsBuilder( + prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode], + builder: (context) => LayoutBuilder( + builder: (context, constraints) { + final svc = SettingsService.instanceOrNull; + if (svc == null) return const SizedBox.shrink(); + final density = svc.read(SettingsService.libraryDensity); + final baseCardWidth = isTv + ? _getTvCardWidth(constraints.maxWidth, density, leadingPadding) + : GridSizeCalculator.getCellWidth(constraints.maxWidth, context, density); - final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final episodePosterMode = svc.read(SettingsService.episodePosterMode); - final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); - final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); + final hasEpisodes = widget.hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final hasNonEpisodes = widget.hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); - final isMixedHub = hasEpisodes && hasNonEpisodes; + final isMixedHub = hasEpisodes && hasNonEpisodes; - final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; + final isEpisodeOnlyHub = hasEpisodes && !hasNonEpisodes; - // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) - final useWideLayout = - episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); + // Use 16:9 for episode-only hubs OR mixed hubs (with episode thumbnail mode) + final useWideLayout = + episodePosterMode == EpisodePosterMode.episodeThumbnail && (isEpisodeOnlyHub || isMixedHub); - // Card dimensions based on hub type - const wideCardMultiplier = 1.5; - final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth; - final posterWidth = cardWidth - 6; // 3px padding on each side - final posterHeight = useWideLayout - ? posterWidth * - (9 / 16) // 16:9 for wide layout - : posterWidth * 1.5; // 2:3 for poster layout + // Card dimensions based on hub type + const wideCardMultiplier = 1.5; + final cardWidth = useWideLayout ? baseCardWidth * wideCardMultiplier : baseCardWidth; + final posterWidth = cardWidth - 6; // 3px padding on each side + final posterHeight = useWideLayout + ? posterWidth * + (9 / 16) // 16:9 for wide layout + : posterWidth * 1.5; // 2:3 for poster layout - final containerHeight = posterHeight + 33; - final focusBorderWidth = FocusTheme.focusBorderWidth; - final focusExtra = focusBorderWidth * 2; // border on both sides - _itemExtent = cardWidth + focusExtra + 4; + final containerHeight = posterHeight + (isTv ? 48 : 33); + final focusBorderWidth = FocusTheme.focusBorderWidth; + final focusExtra = focusBorderWidth * 2; // border on both sides + _itemExtent = cardWidth + focusExtra + 4; - return SizedBox( - height: containerHeight + focusExtra + 4, // extra for scale + border top/bottom - child: HorizontalScrollWithArrows( - controller: _scrollController, - builder: (scrollController) => ListView.builder( - controller: scrollController, - scrollDirection: Axis.horizontal, - clipBehavior: Clip.none, - padding: widget.inset - ? const EdgeInsets.symmetric(vertical: 2) - : const EdgeInsets.symmetric(horizontal: 8, vertical: 2), - itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length, - itemBuilder: (context, index) { - final isItemFocused = hasFocus && index == _focusedIndex; + return SizedBox( + height: containerHeight + focusExtra + (isTv ? 12 : 4), // extra for scale + border top/bottom + child: HorizontalScrollWithArrows( + controller: _scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + 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), + itemCount: isKeyboardMode ? _totalItemCount : widget.hub.items.length, + itemBuilder: (context, index) { + final isItemFocused = hasFocus && index == _focusedIndex; + + if (index == widget.hub.items.length) { + return Padding( + padding: widget.inset + ? const EdgeInsets.only(right: 4) + : const EdgeInsets.symmetric(horizontal: 2), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isItemFocused, + onTap: () { + _onItemTapped(index); + _navigateToHubDetail(context); + }, + child: SizedBox( + width: isTv ? 118 : 80, + height: containerHeight - 10, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + Symbols.arrow_forward_rounded, + size: isTv ? 42 : 32, + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + ), + const SizedBox(height: 4), + Text( + t.common.viewAll, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + fontSize: isTv ? 16 : null, + ), + ), + ], + ), + ), + ), + ), + ); + } + + final item = widget.hub.items[index]; - if (index == widget.hub.items.length) { return Padding( padding: widget.inset ? const EdgeInsets.only(right: 4) @@ -455,88 +538,53 @@ class HubSectionState extends State with MountedSetStateMixin { child: FocusBuilders.buildLockedFocusWrapper( context: context, isFocused: isItemFocused, - onTap: () { - _onItemTapped(index); - _navigateToHubDetail(context); - }, - child: SizedBox( - width: 80, - height: containerHeight - 10, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - Symbols.arrow_forward_rounded, - size: 32, - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), - const SizedBox(height: 4), - Text( - t.common.viewAll, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), - ), - ), - ], - ), - ), + onTap: () => _onItemTapped(index), + onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(), + child: MediaCard( + key: _getMediaCardKey(index), + item: item, + width: cardWidth, + height: posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: widget.isInContinueWatching, + mixedHubContext: isMixedHub, ), ), ); - } - - final item = widget.hub.items[index]; - - return Padding( - padding: widget.inset - ? const EdgeInsets.only(right: 4) - : const EdgeInsets.symmetric(horizontal: 2), - child: FocusBuilders.buildLockedFocusWrapper( - context: context, - isFocused: isItemFocused, - onTap: () => _onItemTapped(index), - onLongPress: () => _mediaCardKeys[index]?.currentState?.showContextMenu(), - child: MediaCard( - key: _getMediaCardKey(index), - item: item, - width: cardWidth, - height: posterHeight, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - forceGridMode: true, - isInContinueWatching: widget.isInContinueWatching, - mixedHubContext: isMixedHub, - ), - ), - ); - }, + }, + ), ), - ), - ); - }, + ); + }, + ), + ), + ) + else + Padding( + padding: widget.inset + ? const EdgeInsets.symmetric(vertical: 8) + : const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Text( + t.messages.noItemsAvailable, + style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), ), ), - ) - else - Padding( - padding: widget.inset - ? const EdgeInsets.symmetric(vertical: 8) - : const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Text( - t.messages.noItemsAvailable, - style: Theme.of(context).textTheme.bodySmall?.copyWith(color: Colors.grey), - ), - ), - ], + ], + ), ); } void _onItemTapped(int index) { + if (_totalItemCount == 0) return; + final clamped = index.clamp(0, _totalItemCount - 1).toInt(); setState(() { - _focusedIndex = index; + _focusedIndex = clamped; }); - HubFocusMemory.setForHub(widget.hub.id, index); + HubFocusMemory.setForHub(widget.hub.id, clamped); + _notifyFocusedItemChanged(); + _scrollToIndex(clamped); _hubFocusNode.requestFocus(); } } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart new file mode 100644 index 00000000..778c2c0e --- /dev/null +++ b/lib/widgets/tv_browse_rail.dart @@ -0,0 +1,1005 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../focus/dpad_navigator.dart'; +import '../focus/focus_theme.dart'; +import '../focus/key_event_utils.dart'; +import '../focus/locked_hub_controller.dart'; +import '../i18n/strings.g.dart'; +import '../media/media_hub.dart'; +import '../media/media_item.dart'; +import '../screens/hub_detail_screen.dart'; +import '../services/settings_service.dart'; +import '../theme/mono_tokens.dart'; +import '../utils/media_image_helper.dart'; +import '../utils/media_navigation_helper.dart'; +import '../utils/provider_extensions.dart'; +import '../utils/layout_constants.dart'; +import '../utils/scroll_utils.dart'; +import 'app_icon.dart'; +import 'focus_builders.dart'; +import 'horizontal_scroll_with_arrows.dart'; +import 'media_card.dart'; +import 'optimized_media_image.dart'; +import 'settings_builder.dart'; + +class TvBrowseRailLayoutMetrics { + final bool isPersonHub; + final bool isMixedHub; + final bool useWideLayout; + final double focusExtra; + final double railEdgePadding; + final double itemGap; + final double cardWidth; + final double posterWidth; + final double posterHeight; + final double containerHeight; + final double height; + + const TvBrowseRailLayoutMetrics({ + required this.isPersonHub, + required this.isMixedHub, + required this.useWideLayout, + required this.focusExtra, + required this.railEdgePadding, + required this.itemGap, + required this.cardWidth, + required this.posterWidth, + required this.posterHeight, + required this.containerHeight, + required this.height, + }); +} + +class TvBrowseRailLayout { + static double scaleForSize(Size size) => TvLayoutConstants.scaleForSize(size); + + static double horizontalInsetForScale(double scale) => (24 * scale).clamp(18, 40).toDouble(); + + static double selectorWidthForScale(double scale) => (230 * scale).clamp(210, 310).toDouble(); + + static double selectorGapForScale(double scale) => 14 * scale; + + static bool isPersonHub(MediaHub hub) => hub.type == 'person'; + + static double cardWidthFor({ + required double availableWidth, + required int density, + required bool useWideLayout, + required double scale, + required double horizontalPadding, + required double itemGap, + }) { + final f = LibraryDensity.factor(density); + final targetWidth = (useWideLayout ? 330 : 205) * scale * (1 + (f * 0.12)); + final minCards = useWideLayout ? 3 : 5; + final maxCards = useWideLayout ? 7 : 12; + final cardCount = (availableWidth / targetWidth).floor().clamp(minCards, maxCards); + final fittedWidth = (availableWidth - horizontalPadding - (itemGap * cardCount)) / cardCount; + final minWidth = (useWideLayout ? 280 : 170) * scale; + final maxWidth = (useWideLayout ? 420 : 250) * scale; + return fittedWidth.clamp(minWidth, maxWidth).toDouble(); + } + + static TvBrowseRailLayoutMetrics metricsForHub({ + required MediaHub hub, + required double availableWidth, + required int density, + required EpisodePosterMode episodePosterMode, + required double scale, + double tallPosterScale = 1.0, + }) { + final focusExtra = FocusTheme.focusBorderWidth * 2 * scale; + final railEdgePadding = focusExtra + (12 * scale); + final itemGap = 8 * scale; + final isPersonHub = TvBrowseRailLayout.isPersonHub(hub); + final hasWide = !isPersonHub && hub.items.any((item) => item.usesWideAspectRatio(episodePosterMode)); + final hasTall = !isPersonHub && hub.items.any((item) => !item.usesWideAspectRatio(episodePosterMode)); + final isMixedHub = hasWide && hasTall; + final useWideLayout = hasWide && (!hasTall || episodePosterMode == EpisodePosterMode.episodeThumbnail); + final baseCardWidth = cardWidthFor( + availableWidth: availableWidth, + density: density, + useWideLayout: useWideLayout, + scale: scale, + horizontalPadding: railEdgePadding * 2, + itemGap: itemGap, + ); + final cardWidth = useWideLayout ? baseCardWidth : baseCardWidth * tallPosterScale; + final posterWidth = cardWidth - (6 * scale); + final posterHeight = isPersonHub ? posterWidth : (useWideLayout ? posterWidth * 9 / 16 : posterWidth * 1.5); + final containerHeight = (posterHeight + ((isPersonHub ? 58 : 42) * scale)).ceilToDouble(); + final height = containerHeight + focusExtra + (14 * scale); + + return TvBrowseRailLayoutMetrics( + isPersonHub: isPersonHub, + isMixedHub: isMixedHub, + useWideLayout: useWideLayout, + focusExtra: focusExtra, + railEdgePadding: railEdgePadding, + itemGap: itemGap, + cardWidth: cardWidth, + posterWidth: posterWidth, + posterHeight: posterHeight, + containerHeight: containerHeight, + height: height, + ); + } + + static double estimateHeight({ + required Size size, + required List hubs, + required int density, + required EpisodePosterMode episodePosterMode, + double tallPosterScale = 1.0, + }) { + if (hubs.isEmpty) return 0; + + final scale = scaleForSize(size); + final availableWidth = + size.width - horizontalInsetForScale(scale) - selectorWidthForScale(scale) - selectorGapForScale(scale); + if (availableWidth <= 0) return 0; + + var activeRailHeight = 0.0; + for (final hub in hubs) { + final metrics = metricsForHub( + hub: hub, + availableWidth: availableWidth, + density: density, + episodePosterMode: episodePosterMode, + scale: scale, + tallPosterScale: tallPosterScale, + ); + if (metrics.height > activeRailHeight) activeRailHeight = metrics.height; + } + + final visibleShelfCount = hubs.length < 5 ? hubs.length : 5; + final selectorHeight = (46 * scale * visibleShelfCount) + (4 * scale * (visibleShelfCount - 1).clamp(0, 4)); + final rowHeight = activeRailHeight > selectorHeight ? activeRailHeight : selectorHeight; + return (12 * scale) + rowHeight + (24 * scale); + } +} + +class TvBrowseRail extends StatefulWidget { + final List hubs; + final IconData Function(MediaHub hub, int index) iconForHub; + final ValueChanged? onFocusedItemChanged; + final void Function(String)? onRefresh; + final VoidCallback? onRemoveFromContinueWatching; + final bool Function(MediaHub hub)? isContinueWatchingHub; + final Future> Function(MediaHub hub)? loadMoreItems; + final void Function(MediaHub hub, int index)? onActiveHubChanged; + final VoidCallback? onNavigateUp; + final VoidCallback? onNavigateToSidebar; + final VoidCallback? onBack; + final FutureOr Function(MediaHub hub, MediaItem item)? onActivateItem; + final double tallPosterScale; + final String? initialHubId; + final String? initialItemId; + final bool autofocus; + + const TvBrowseRail({ + super.key, + required this.hubs, + required this.iconForHub, + this.onFocusedItemChanged, + this.onRefresh, + this.onRemoveFromContinueWatching, + this.isContinueWatchingHub, + this.loadMoreItems, + this.onActiveHubChanged, + this.onNavigateUp, + this.onNavigateToSidebar, + this.onBack, + this.onActivateItem, + this.tallPosterScale = 1.0, + this.initialHubId, + this.initialItemId, + this.autofocus = false, + }); + + @override + State createState() => TvBrowseRailState(); +} + +class TvBrowseRailState extends State { + static const _longPressDuration = Duration(milliseconds: 500); + + final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); + final ScrollController _scrollController = ScrollController(); + final Map> _mediaCardKeys = {}; + + int _hubIndex = 0; + int _itemIndex = 0; + double _itemExtent = 260; + double _railLeadingPadding = 0; + Timer? _longPressTimer; + bool _isSelectKeyDown = false; + bool _longPressTriggered = false; + bool _hasUserChangedHub = false; + bool _hasUserChangedItem = false; + + MediaHub? get _activeHub => widget.hubs.isEmpty ? null : widget.hubs[_hubIndex.clamp(0, widget.hubs.length - 1)]; + + void requestFocus() { + _notifyFocusedItem(); + _focusNode.requestFocus(); + } + + @override + void initState() { + super.initState(); + _focusNode.addListener(_handleFocusChange); + _selectInitialHubIfPossible(); + final selectedInitialItem = _selectInitialItemIfPossible(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted || widget.hubs.isEmpty) return; + if (selectedInitialItem) _scrollToItem(animate: false); + _notifyActiveHubChanged(); + _notifyFocusedItem(); + if (widget.autofocus) _focusNode.requestFocus(); + }); + } + + @override + void didUpdateWidget(covariant TvBrowseRail oldWidget) { + super.didUpdateWidget(oldWidget); + final oldActiveHubId = oldWidget.hubs.isEmpty + ? null + : oldWidget.hubs[_hubIndex.clamp(0, oldWidget.hubs.length - 1)].id; + + if (widget.hubs.isEmpty) { + _hubIndex = 0; + _itemIndex = 0; + return; + } + + final selectedInitialHub = _selectInitialHubIfPossible(); + if (!selectedInitialHub && oldActiveHubId != null) { + final preservedIndex = widget.hubs.indexWhere((hub) => hub.id == oldActiveHubId); + if (preservedIndex != -1) { + _hubIndex = preservedIndex; + } else { + _hubIndex = _hubIndex.clamp(0, widget.hubs.length - 1); + } + } else if (!selectedInitialHub) { + _hubIndex = _hubIndex.clamp(0, widget.hubs.length - 1); + } + + final hub = _activeHub; + if (hub == null) return; + _itemIndex = _itemIndex.clamp(0, _totalItemCount(hub) == 0 ? 0 : _totalItemCount(hub) - 1); + final selectedInitialItem = _selectInitialItemIfPossible(); + final activeHubChanged = oldActiveHubId != _activeHub?.id; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + if (selectedInitialItem) _scrollToItem(animate: false); + if (!oldWidget.autofocus && widget.autofocus) _focusNode.requestFocus(); + if (activeHubChanged) _notifyActiveHubChanged(); + _notifyFocusedItem(); + }); + } + + @override + void dispose() { + _longPressTimer?.cancel(); + _focusNode.removeListener(_handleFocusChange); + _focusNode.dispose(); + _scrollController.dispose(); + super.dispose(); + } + + void _handleFocusChange() { + if (_focusNode.hasFocus) _notifyFocusedItem(); + setState(() {}); + } + + int _totalItemCount(MediaHub hub) => hub.items.length + (hub.more ? 1 : 0); + + bool _isPersonHub(MediaHub hub) => TvBrowseRailLayout.isPersonHub(hub); + + void _notifyFocusedItem() { + final hub = _activeHub; + if (hub == null || hub.items.isEmpty || _itemIndex >= hub.items.length) return; + widget.onFocusedItemChanged?.call(hub.items[_itemIndex]); + } + + void _notifyActiveHubChanged() { + final hub = _activeHub; + if (hub == null) return; + widget.onActiveHubChanged?.call(hub, _hubIndex); + } + + bool _selectInitialHubIfPossible() { + final initialHubId = widget.initialHubId; + if (_hasUserChangedHub || initialHubId == null || widget.hubs.isEmpty) return false; + final initialIndex = widget.hubs.indexWhere((hub) => hub.id == initialHubId); + if (initialIndex == -1) return false; + if (initialIndex != _hubIndex) { + _hubIndex = initialIndex; + _itemIndex = 0; + } + return true; + } + + bool _selectInitialItemIfPossible() { + final initialItemId = widget.initialItemId; + final hub = _activeHub; + if (_hasUserChangedHub || _hasUserChangedItem || initialItemId == null || hub == null) return false; + final initialIndex = hub.items.indexWhere((item) => item.id == initialItemId); + if (initialIndex == -1) return false; + if (initialIndex != _itemIndex) _itemIndex = initialIndex; + return true; + } + + KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { + final key = event.logicalKey; + + if (key.isSelectKey) { + if (event is KeyDownEvent) { + if (!_isSelectKeyDown) { + _isSelectKeyDown = true; + _longPressTriggered = false; + _longPressTimer?.cancel(); + _longPressTimer = Timer(_longPressDuration, () { + if (!mounted || !_isSelectKeyDown) return; + _longPressTriggered = true; + SelectKeyUpSuppressor.suppressSelectUntilKeyUp(); + _showContextMenuForCurrentItem(); + }); + } + return KeyEventResult.handled; + } + if (event is KeyRepeatEvent) return KeyEventResult.handled; + if (event is KeyUpEvent) { + final timerWasActive = _longPressTimer?.isActive ?? false; + _longPressTimer?.cancel(); + if (!_longPressTriggered && timerWasActive && _isSelectKeyDown) _activateCurrentItem(); + _isSelectKeyDown = false; + _longPressTriggered = false; + return KeyEventResult.handled; + } + } + + if (widget.onBack != null) { + final backResult = handleBackKeyAction(event, widget.onBack!); + if (backResult != KeyEventResult.ignored) return backResult; + } + + if (!event.isActionable) return KeyEventResult.ignored; + final hub = _activeHub; + if (hub == null) return KeyEventResult.ignored; + + if (key.isLeftKey) { + if (_itemIndex > 0) { + setState(() { + _itemIndex--; + _hasUserChangedItem = true; + }); + _rememberFocus(hub); + _notifyFocusedItem(); + _scrollToItem(); + } else { + widget.onNavigateToSidebar?.call(); + } + return KeyEventResult.handled; + } + + if (key.isRightKey) { + if (_itemIndex < _totalItemCount(hub) - 1) { + setState(() { + _itemIndex++; + _hasUserChangedItem = true; + }); + _rememberFocus(hub); + _notifyFocusedItem(); + _scrollToItem(); + } + return KeyEventResult.handled; + } + + if (key.isUpKey) { + if (_hubIndex > 0) { + _moveHub(-1); + } else { + widget.onNavigateUp?.call(); + } + return KeyEventResult.handled; + } + + if (key.isDownKey) { + _moveHub(1); + return KeyEventResult.handled; + } + + if (key.isContextMenuKey) { + _showContextMenuForCurrentItem(); + return KeyEventResult.handled; + } + + return KeyEventResult.ignored; + } + + void _moveHub(int delta) { + if (widget.hubs.isEmpty) return; + final next = (_hubIndex + delta).clamp(0, widget.hubs.length - 1); + if (next == _hubIndex) return; + final nextHub = widget.hubs[next]; + final remembered = HubFocusMemory.getForHub(nextHub.id, _totalItemCount(nextHub)); + setState(() { + _hubIndex = next; + _itemIndex = remembered.clamp(0, _totalItemCount(nextHub) == 0 ? 0 : _totalItemCount(nextHub) - 1); + _hasUserChangedHub = true; + }); + _notifyFocusedItem(); + _notifyActiveHubChanged(); + _scrollToItem(animate: false); + } + + void _rememberFocus(MediaHub hub) { + HubFocusMemory.setForHub(hub.id, _itemIndex); + } + + void _scrollToItem({bool animate = true}) { + scrollListToIndex( + _scrollController, + _itemIndex, + itemExtent: _itemExtent, + leadingPadding: _railLeadingPadding, + animate: animate, + ); + } + + GlobalKey _cardKeyFor(MediaHub hub, int itemIndex) { + return _mediaCardKeys.putIfAbsent('${hub.id}:$itemIndex', () => GlobalKey()); + } + + void _showContextMenuForCurrentItem() { + final hub = _activeHub; + if (hub == null || _itemIndex >= hub.items.length) return; + if (_isPersonHub(hub)) return; + _cardKeyFor(hub, _itemIndex).currentState?.showContextMenu(); + } + + Future _activateCurrentItem() async { + final hub = _activeHub; + if (hub == null) return; + if (_itemIndex == hub.items.length && hub.more) { + _navigateToHubDetail(hub); + return; + } + if (_itemIndex >= hub.items.length) return; + final item = hub.items[_itemIndex]; + final handled = await widget.onActivateItem?.call(hub, item); + if (handled == true) return; + if (!mounted) return; + await navigateToMediaItem( + context, + item, + onRefresh: widget.onRefresh, + playDirectly: widget.isContinueWatchingHub?.call(hub) ?? false, + ); + } + + void _navigateToHubDetail(MediaHub hub) { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => HubDetailScreen( + hub: hub, + loadItems: widget.loadMoreItems == null ? null : () => widget.loadMoreItems!(hub), + isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + ), + ), + ); + } + + double _scale(BuildContext context) => TvBrowseRailLayout.scaleForSize(MediaQuery.sizeOf(context)); + + double _horizontalInset(BuildContext context) => TvBrowseRailLayout.horizontalInsetForScale(_scale(context)); + + double _selectorWidth(BuildContext context) => TvBrowseRailLayout.selectorWidthForScale(_scale(context)); + + double _selectorGap(BuildContext context) => TvBrowseRailLayout.selectorGapForScale(_scale(context)); + + List _visibleShelfIndices() { + const visibleCount = 5; + if (widget.hubs.length <= visibleCount) return List.generate(widget.hubs.length, (index) => index); + final start = (_hubIndex - 2).clamp(0, widget.hubs.length - visibleCount); + return List.generate(visibleCount, (index) => start + index); + } + + _ShelfTitleParts _shelfTitleParts(String title) { + final titleWords = _titleWords(title); + if (titleWords.length < 3) return _ShelfTitleParts(title: title); + + var bestPrefixLength = 0; + var bestSupport = 0; + for (var prefixLength = 2; prefixLength < titleWords.length; prefixLength++) { + if (_suffixStartsWithPunctuation(titleWords, prefixLength)) continue; + + final support = _prefixSupport(titleWords, prefixLength); + if (support < 2) continue; + if (support > bestSupport || (support == bestSupport && prefixLength > bestPrefixLength)) { + bestSupport = support; + bestPrefixLength = prefixLength; + } + } + + if (bestPrefixLength < 2) return _ShelfTitleParts(title: title); + bestPrefixLength = _preferConnectorBoundary(titleWords, bestPrefixLength, bestSupport); + return _splitTitleAtWord(title, bestPrefixLength); + } + + List _titleWords(String title) => + title.trim().split(RegExp(r'\s+')).where((word) => word.isNotEmpty).toList(); + + int _prefixSupport(List titleWords, int prefixLength) { + var support = 0; + for (final hub in widget.hubs) { + final otherWords = _titleWords(hub.title); + if (_commonPrefixLength(titleWords, otherWords) >= prefixLength) support++; + } + return support; + } + + int _commonPrefixLength(List a, List b) { + final maxLength = a.length < b.length ? a.length : b.length; + var length = 0; + while (length < maxLength && a[length].toLowerCase() == b[length].toLowerCase()) { + length++; + } + return length; + } + + int _preferConnectorBoundary(List titleWords, int prefixLength, int support) { + for (var candidate = prefixLength; candidate >= 2; candidate--) { + if (_prefixSupport(titleWords, candidate) != support) continue; + if (_looksLikeConnector(titleWords[candidate - 1])) return candidate; + } + return prefixLength; + } + + bool _looksLikeConnector(String word) { + final stripped = word.replaceAll(RegExp(r'[^\p{L}]', unicode: true), ''); + return stripped.length <= 5 && stripped.isNotEmpty && stripped == stripped.toLowerCase(); + } + + bool _suffixStartsWithPunctuation(List words, int prefixLength) { + if (prefixLength >= words.length) return true; + return RegExp(r'^[^\p{L}\p{N}]', unicode: true).hasMatch(words[prefixLength]); + } + + _ShelfTitleParts _splitTitleAtWord(String title, int wordCount) { + final matches = RegExp(r'\S+').allMatches(title).toList(); + if (wordCount <= 0 || wordCount >= matches.length) return _ShelfTitleParts(title: title); + final split = matches[wordCount - 1].end; + return _ShelfTitleParts(eyebrow: title.substring(0, split).trim(), title: title.substring(split).trim()); + } + + @override + Widget build(BuildContext context) { + final hub = _activeHub; + if (hub == null) return const SizedBox.shrink(); + final hasFocus = _focusNode.hasFocus; + final theme = Theme.of(context); + final scale = _scale(context); + final horizontalInset = _horizontalInset(context); + final selectorGap = _selectorGap(context); + + return Focus( + focusNode: _focusNode, + onKeyEvent: _handleKeyEvent, + child: Container( + padding: EdgeInsets.fromLTRB(horizontalInset, 12 * scale, 0, 24 * scale), + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, theme.scaffoldBackgroundColor.withValues(alpha: 0.7)], + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + SizedBox(width: _selectorWidth(context), child: _buildShelfSelector(context, hub)), + SizedBox(width: selectorGap), + Expanded(child: _buildActiveRail(context, hub, hasFocus)), + ], + ), + ), + ); + } + + Widget _buildShelfSelector(BuildContext context, MediaHub activeHub) { + final scale = _scale(context); + final visibleIndices = _visibleShelfIndices(); + final isScrollable = widget.hubs.length > visibleIndices.length; + final hasAbove = isScrollable && visibleIndices.first > 0; + final hasBelow = isScrollable && visibleIndices.last < widget.hubs.length - 1; + final rowHeight = 46 * scale; + final rowGap = 4 * scale; + final viewportHeight = isScrollable + ? (rowHeight * 5) + (rowGap * 4) + : (rowHeight * visibleIndices.length) + (rowGap * (visibleIndices.length - 1).clamp(0, 4)); + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: viewportHeight, + child: Stack( + clipBehavior: Clip.none, + children: [ + _buildFadedShelfRows( + visibleIndices: visibleIndices, + hasAbove: hasAbove, + hasBelow: hasBelow, + rowGap: rowGap, + rowHeight: rowHeight, + scale: scale, + viewportHeight: viewportHeight, + ), + if (hasAbove) _buildSelectorChevron(Symbols.keyboard_arrow_up_rounded, scale, top: 0), + if (hasBelow) _buildSelectorChevron(Symbols.keyboard_arrow_down_rounded, scale, bottom: 0), + ], + ), + ), + ], + ); + } + + Widget _buildFadedShelfRows({ + required List visibleIndices, + required bool hasAbove, + required bool hasBelow, + required double rowGap, + required double rowHeight, + required double scale, + required double viewportHeight, + }) { + final rows = Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (var visibleIndex = 0; visibleIndex < visibleIndices.length; visibleIndex++) + Padding( + padding: EdgeInsets.only(bottom: visibleIndex == visibleIndices.length - 1 ? 0 : rowGap), + child: _buildShelfRow(context, visibleIndices[visibleIndex], scale, rowHeight), + ), + ], + ); + + if (!hasAbove && !hasBelow) return rows; + + final fadeStop = ((68 * scale) / viewportHeight).clamp(0.0, 0.45).toDouble(); + final colors = []; + final stops = []; + if (hasAbove) { + colors.addAll([Colors.transparent, Colors.white]); + stops.addAll([0, fadeStop]); + } else { + colors.add(Colors.white); + stops.add(0); + } + if (hasBelow) { + colors.addAll([Colors.white, Colors.transparent]); + stops.addAll([1 - fadeStop, 1]); + } else { + colors.add(Colors.white); + stops.add(1); + } + + return ShaderMask( + blendMode: BlendMode.dstIn, + shaderCallback: (bounds) => LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: colors, + stops: stops, + ).createShader(bounds), + child: rows, + ); + } + + Widget _buildShelfRow(BuildContext context, int index, double scale, double rowHeight) { + final colorScheme = Theme.of(context).colorScheme; + final isActive = index == _hubIndex; + + return AnimatedContainer( + duration: const Duration(milliseconds: 160), + height: rowHeight, + padding: EdgeInsets.symmetric(horizontal: 12 * scale, vertical: 6 * scale), + decoration: BoxDecoration( + color: isActive ? Colors.white.withValues(alpha: 0.16) : Colors.transparent, + borderRadius: BorderRadius.circular(tokens(context).radiusMd), + ), + child: Row( + children: [ + AppIcon( + widget.iconForHub(widget.hubs[index], index), + fill: 1, + size: 22 * scale, + color: isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.54), + ), + SizedBox(width: 12 * scale), + Expanded(child: _buildShelfTitle(context, widget.hubs[index], isActive, scale)), + ], + ), + ); + } + + Widget _buildSelectorChevron(IconData icon, double scale, {double? top, double? bottom}) { + return Positioned( + left: 0, + right: 0, + top: top, + bottom: bottom, + child: IgnorePointer( + child: Center( + child: AppIcon(icon, fill: 1, size: 18 * scale, color: Colors.white.withValues(alpha: 0.45)), + ), + ), + ); + } + + Widget _buildShelfTitle(BuildContext context, MediaHub hub, bool isActive, double scale) { + final parts = hub.id.startsWith('detail_season_') + ? _ShelfTitleParts(title: hub.title) + : _shelfTitleParts(hub.title); + final colorScheme = Theme.of(context).colorScheme; + final primaryColor = isActive ? Colors.white : colorScheme.onSurface.withValues(alpha: 0.62); + final secondaryColor = isActive + ? Colors.white.withValues(alpha: 0.62) + : colorScheme.onSurface.withValues(alpha: 0.42); + + if (parts.eyebrow == null) { + return Text( + parts.title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: primaryColor, + fontSize: 16 * scale, + height: 1.05, + fontWeight: isActive ? FontWeight.w800 : FontWeight.w600, + ), + ); + } + + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + parts.eyebrow!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: secondaryColor, + fontSize: 10.5 * scale, + height: 0.95, + fontWeight: FontWeight.w700, + letterSpacing: 0.2, + ), + ), + SizedBox(height: scale), + Text( + parts.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: primaryColor, + fontSize: 16 * scale, + height: 1, + fontWeight: isActive ? FontWeight.w800 : FontWeight.w600, + ), + ), + ], + ); + } + + Widget _buildActiveRail(BuildContext context, MediaHub hub, bool hasFocus) { + return SettingsBuilder( + prefs: const [SettingsService.libraryDensity, SettingsService.episodePosterMode], + builder: (context) => LayoutBuilder( + builder: (context, constraints) { + final svc = SettingsService.instanceOrNull!; + final density = svc.read(SettingsService.libraryDensity); + final episodePosterMode = svc.read(SettingsService.episodePosterMode); + final scale = _scale(context); + final metrics = TvBrowseRailLayout.metricsForHub( + hub: hub, + availableWidth: constraints.maxWidth, + density: density, + episodePosterMode: episodePosterMode, + scale: scale, + tallPosterScale: widget.tallPosterScale, + ); + _railLeadingPadding = metrics.railEdgePadding; + _itemExtent = metrics.cardWidth + metrics.itemGap; + + return SizedBox( + height: metrics.height, + child: ClipRect( + clipper: _RailClipper( + rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap, + verticalOverflow: metrics.focusExtra, + ), + child: HorizontalScrollWithArrows( + controller: _scrollController, + builder: (scrollController) => ListView.builder( + controller: scrollController, + scrollDirection: Axis.horizontal, + clipBehavior: Clip.none, + padding: EdgeInsets.symmetric(horizontal: metrics.railEdgePadding, vertical: 6 * scale), + itemCount: _totalItemCount(hub), + itemBuilder: (context, index) { + final isFocused = hasFocus && index == _itemIndex; + if (index == hub.items.length) { + return Padding( + padding: EdgeInsets.only(right: metrics.itemGap), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () { + setState(() { + _itemIndex = index; + _hasUserChangedItem = true; + }); + _navigateToHubDetail(hub); + }, + child: SizedBox( + width: 132 * scale, + height: metrics.containerHeight - metrics.itemGap, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppIcon(Symbols.arrow_forward_rounded, fill: 1, size: 42 * scale, color: Colors.white), + SizedBox(height: 6 * scale), + Text( + t.common.viewAll, + style: const TextStyle(color: Colors.white, fontWeight: FontWeight.w700), + ), + ], + ), + ), + ), + ); + } + + final item = hub.items[index]; + return Padding( + padding: EdgeInsets.only(right: metrics.itemGap), + child: FocusBuilders.buildLockedFocusWrapper( + context: context, + isFocused: isFocused, + onTap: () { + setState(() { + _itemIndex = index; + _hasUserChangedItem = true; + }); + _activateCurrentItem(); + }, + onLongPress: metrics.isPersonHub + ? null + : () => _cardKeyFor(hub, index).currentState?.showContextMenu(), + child: metrics.isPersonHub + ? _buildPersonCard( + context, + item, + cardWidth: metrics.cardWidth, + imageSize: metrics.posterHeight, + scale: scale, + ) + : MediaCard( + key: _cardKeyFor(hub, index), + item: item, + width: metrics.cardWidth, + height: metrics.posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + isInContinueWatching: widget.isContinueWatchingHub?.call(hub) ?? false, + mixedHubContext: metrics.isMixedHub, + ), + ), + ); + }, + ), + ), + ), + ); + }, + ), + ); + } + + Widget _buildPersonCard( + BuildContext context, + MediaItem item, { + required double cardWidth, + required double imageSize, + required double scale, + }) { + final theme = Theme.of(context); + final characterName = item.parentTitle; + + return SizedBox( + width: cardWidth, + child: Padding( + padding: EdgeInsets.fromLTRB(3 * scale, 3 * scale, 3 * scale, scale), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ClipRRect( + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + child: OptimizedMediaImage( + client: context.tryGetMediaClientWithFallback(item.serverId), + imagePath: item.thumbPath, + width: imageSize, + height: imageSize, + fit: BoxFit.cover, + imageType: ImageType.avatar, + fallbackIcon: Symbols.person_rounded, + ), + ), + SizedBox(height: 6 * scale), + Text( + item.displayTitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith( + color: tokens(context).text, + fontSize: 13 * scale, + height: 1.1, + fontWeight: FontWeight.w700, + ), + ), + if (characterName != null && characterName.isNotEmpty) ...[ + SizedBox(height: 2 * scale), + Text( + characterName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodySmall?.copyWith( + color: tokens(context).textMuted, + fontSize: 11 * scale, + height: 1.1, + ), + ), + ], + ], + ), + ), + ); + } +} + +class _RailClipper extends CustomClipper { + final double rightOverflow; + final double verticalOverflow; + + const _RailClipper({required this.rightOverflow, required this.verticalOverflow}); + + @override + Rect getClip(Size size) => + Rect.fromLTRB(0, -verticalOverflow, size.width + rightOverflow, size.height + verticalOverflow); + + @override + bool shouldReclip(covariant _RailClipper oldClipper) { + return oldClipper.rightOverflow != rightOverflow || oldClipper.verticalOverflow != verticalOverflow; + } +} + +class _ShelfTitleParts { + final String? eyebrow; + final String title; + + const _ShelfTitleParts({this.eyebrow, required this.title}); +} diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart new file mode 100644 index 00000000..bbe1a020 --- /dev/null +++ b/lib/widgets/tv_spotlight_background.dart @@ -0,0 +1,325 @@ +import 'package:cached_network_image_ce/cached_network_image.dart'; +import 'package:flutter/material.dart'; +import 'package:material_symbols_icons/symbols.dart'; + +import '../i18n/strings.g.dart'; +import '../media/media_item.dart'; +import '../media/media_item_types.dart'; +import '../media/media_server_client.dart'; +import '../services/image_cache_service.dart'; +import '../utils/content_utils.dart'; +import '../utils/formatters.dart'; +import '../utils/layout_constants.dart'; +import '../utils/media_image_helper.dart'; +import 'app_icon.dart'; +import 'optimized_media_image.dart' show blurArtwork; + +class TvSpotlightBackground extends StatelessWidget { + final MediaItem? item; + final MediaServerClient? client; + final bool hideSpoilers; + final double contentBottom; + final double? contentTop; + final double? contentLeft; + final VoidCallback? onPrimaryAction; + final Widget? actions; + final bool compact; + final bool showPrimaryAction; + final bool showInfo; + + const TvSpotlightBackground({ + super.key, + required this.item, + required this.client, + this.hideSpoilers = false, + this.contentBottom = 360, + this.contentTop, + this.contentLeft, + this.onPrimaryAction, + this.actions, + this.compact = false, + this.showPrimaryAction = true, + this.showInfo = true, + }); + + double _scale(BuildContext context) => TvLayoutConstants.scaleOf(context); + + @override + Widget build(BuildContext context) { + final media = item; + final colorScheme = Theme.of(context).colorScheme; + final bgColor = Theme.of(context).scaffoldBackgroundColor; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 280), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + child: SizedBox.expand( + key: ValueKey(media?.globalKey ?? 'empty_spotlight'), + child: Stack( + 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], + ), + ), + ), + DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.black.withValues(alpha: 0.45), Colors.transparent, bgColor.withValues(alpha: 0.96)], + stops: const [0.0, 0.38, 1.0], + ), + ), + ), + if (media != null && showInfo) + Positioned( + left: contentLeft ?? TvLayoutConstants.horizontalInset, + right: MediaQuery.sizeOf(context).width * 0.43, + top: contentTop, + bottom: contentBottom, + child: LayoutBuilder( + builder: (context, constraints) { + if (!constraints.hasBoundedHeight || constraints.maxHeight <= 0 || constraints.maxWidth <= 0) { + return Align(alignment: Alignment.bottomLeft, child: _buildInfo(context, media, colorScheme)); + } + + return Align( + alignment: Alignment.bottomLeft, + child: FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.bottomLeft, + child: SizedBox(width: constraints.maxWidth, child: _buildInfo(context, media, colorScheme)), + ), + ); + }, + ), + ), + ], + ), + ), + ); + } + + Widget _buildArtwork(BuildContext context, MediaItem media) { + final size = MediaQuery.sizeOf(context); + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final containerAspect = size.width / size.height; + final artPath = + media.heroArt(containerAspectRatio: containerAspect) ?? + media.grandparentArtPath ?? + media.artPath ?? + media.backgroundSquarePath ?? + media.thumbPath; + final imageUrl = MediaImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: artPath, + maxWidth: size.width, + maxHeight: size.height, + devicePixelRatio: dpr, + imageType: ImageType.art, + ); + + if (imageUrl.isEmpty) { + return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest); + } + + final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( + displayWidth: (size.width * dpr).round(), + displayHeight: (size.height * dpr).round(), + imageType: ImageType.art, + ); + + return blurArtwork( + CachedNetworkImage( + imageUrl: imageUrl, + cacheManager: PlexImageCacheManager.instance, + fit: BoxFit.cover, + memCacheHeight: memHeight, + placeholder: (context, url) => ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), + errorBuilder: (context, error, stackTrace) => + ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), + ), + ); + } + + Widget _buildInfo(BuildContext context, MediaItem media, ColorScheme colorScheme) { + final scale = _scale(context); + final shouldHideSpoiler = hideSpoilers && media.shouldHideSpoiler; + final summary = shouldHideSpoiler ? null : media.summary; + final title = media.grandparentTitle ?? media.displayTitle; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + _buildLogoOrTitle(context, media, title), + SizedBox(height: _sectionGap(scale)), + _buildMetadataLine(context, media), + if (summary != null && summary.isNotEmpty) ...[ + SizedBox(height: _sectionGap(scale)), + Text( + _summaryText(media, summary), + maxLines: compact ? 2 : 4, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.white.withValues(alpha: 0.78), + fontSize: _summaryFontSize(scale), + height: compact ? 1.34 : 1.45, + ), + ), + ] else if (shouldHideSpoiler && media.isEpisode) ...[ + SizedBox(height: _sectionGap(scale)), + Text( + _episodePrefix(media) ?? media.title ?? '', + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.white.withValues(alpha: 0.72), + fontSize: _summaryFontSize(scale), + height: compact ? 1.34 : 1.45, + ), + ), + ], + if (showPrimaryAction || actions != null) ...[ + SizedBox(height: (compact ? 18 : 26) * scale), + actions ?? _buildPrimaryAction(context, colorScheme, media), + ], + ], + ); + } + + Widget _buildLogoOrTitle(BuildContext context, MediaItem media, String title) { + final scale = _scale(context); + final logoPath = media.clearLogoPath; + if (logoPath == null || logoPath.isEmpty) return _buildTitle(context, title); + + final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); + final logoWidth = _logoWidth(scale); + final logoHeight = _logoHeight(scale); + final imageUrl = MediaImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: logoPath, + maxWidth: logoWidth, + maxHeight: logoHeight, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); + if (imageUrl.isEmpty) return _buildTitle(context, title); + + return SizedBox( + width: logoWidth, + height: logoHeight, + child: blurArtwork( + CachedNetworkImage( + imageUrl: imageUrl, + cacheManager: PlexImageCacheManager.instance, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + memCacheWidth: (logoWidth * dpr).clamp(200, 1000).round(), + placeholder: (context, url) => const SizedBox.shrink(), + errorBuilder: (context, error, stackTrace) => _buildTitle(context, title), + ), + sigma: 10, + clip: false, + ), + ); + } + + Widget _buildTitle(BuildContext context, String title) { + final scale = _scale(context); + return Text( + title, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Colors.white, + fontSize: _titleFontSize(scale), + fontWeight: FontWeight.w800, + shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 12)], + ), + ); + } + + Widget _buildMetadataLine(BuildContext context, MediaItem media) { + final scale = _scale(context); + final parts = [ + if (media.isMovie) t.discover.movie else if (media.isShow) t.discover.tvShow, + if (media.rating != null) '★ ${formatRating(media.rating!)}', + if (media.contentRating != null) formatContentRating(media.contentRating!), + if (media.durationMs != null) formatDurationTextual(media.durationMs!), + if (media.year != null) media.year.toString(), + ]; + return Text( + parts.join(' • '), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: _metadataFontSize(scale), + fontWeight: FontWeight.w700, + letterSpacing: 0.1, + ), + ); + } + + double _sectionGap(double scale) => (compact ? 10 : 16) * scale; + + double _logoWidth(double scale) => + (compact ? TvLayoutConstants.compactHeroLogoWidth : TvLayoutConstants.heroLogoWidth) * scale; + + double _logoHeight(double scale) => + (compact ? TvLayoutConstants.compactHeroLogoHeight : TvLayoutConstants.heroLogoHeight) * scale; + + double _titleFontSize(double scale) => (compact ? 44 : 54) * scale; + + double _metadataFontSize(double scale) => (compact ? 16 : 18) * scale; + + double _summaryFontSize(double scale) => (compact ? 18 : 20) * scale; + + Widget _buildPrimaryAction(BuildContext context, ColorScheme colorScheme, MediaItem media) { + final scale = _scale(context); + final hasProgress = media.hasActiveProgress; + final minutesLeft = hasProgress && media.durationMs != null && media.viewOffsetMs != null + ? ((media.durationMs! - media.viewOffsetMs!) / 60000).round() + : 0; + + return GestureDetector( + onTap: onPrimaryAction, + child: Container( + padding: EdgeInsets.symmetric(horizontal: (compact ? 24 : 30) * scale, vertical: (compact ? 12 : 15) * scale), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(32 * scale)), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + AppIcon(Symbols.play_arrow_rounded, fill: 1, size: (compact ? 24 : 28) * scale, color: Colors.black), + SizedBox(width: (compact ? 10 : 12) * scale), + Text( + hasProgress ? t.discover.minutesLeft(minutes: minutesLeft) : t.common.play, + style: TextStyle(color: Colors.black, fontSize: (compact ? 16 : 18) * scale, fontWeight: FontWeight.w800), + ), + ], + ), + ), + ); + } + + String _summaryText(MediaItem media, String summary) { + final prefix = _episodePrefix(media); + if (prefix == null) return summary; + return '$prefix: $summary'; + } + + String? _episodePrefix(MediaItem media) { + if (!media.isEpisode || media.parentIndex == null || media.index == null) return null; + return 'S${media.parentIndex}, E${media.index}'; + } +} diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart new file mode 100644 index 00000000..0340e1b0 --- /dev/null +++ b/test/widgets/tv_browse_rail_test.dart @@ -0,0 +1,153 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/media/media_backend.dart'; +import 'package:plezy/media/media_hub.dart'; +import 'package:plezy/media/media_item.dart'; +import 'package:plezy/media/media_kind.dart'; +import 'package:plezy/providers/multi_server_provider.dart'; +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_theme.dart'; +import 'package:plezy/widgets/tv_browse_rail.dart'; +import 'package:provider/provider.dart'; + +import '../test_helpers/prefs.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() async { + resetSharedPreferencesForTest(); + SettingsService.resetForTesting(); + await SettingsService.getInstance(); + }); + + testWidgets('selects preferred hub when hubs are inserted asynchronously', (tester) async { + final activeHubIds = []; + + Widget buildRail(List hubs, {String? initialHubId, String? initialItemId, bool autofocus = false}) { + final serverManager = MultiServerManager(); + return ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + key: const ValueKey('rail'), + hubs: hubs, + initialHubId: initialHubId, + initialItemId: initialItemId, + autofocus: autofocus, + iconForHub: (_, _) => Icons.tv_rounded, + onActiveHubChanged: (hub, _) => activeHubIds.add(hub.id), + ), + ), + ), + ), + ); + } + + const castHub = MediaHub(id: 'detail_actors', title: 'Cast', type: 'person', items: []); + const preferredSeason = MediaHub(id: 'detail_season_1', title: 'Season 2', type: 'episode', items: []); + + await tester.pumpWidget(buildRail(const [castHub])); + await tester.pump(); + + await tester.pumpWidget(buildRail(const [preferredSeason, castHub], initialHubId: preferredSeason.id)); + await tester.pump(); + + expect(activeHubIds, containsAllInOrder(['detail_actors', 'detail_season_1'])); + expect(activeHubIds.last, 'detail_season_1'); + }); + + testWidgets('selects preferred item when active hub items are populated asynchronously', (tester) async { + final focusedItemIds = []; + + Widget buildRail(List hubs, {String? initialItemId}) { + final serverManager = MultiServerManager(); + return ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + key: const ValueKey('rail'), + hubs: hubs, + initialItemId: initialItemId, + iconForHub: (_, _) => Icons.tv_rounded, + onFocusedItemChanged: (item) => focusedItemIds.add(item.id), + ), + ), + ), + ), + ); + } + + final episode1 = MediaItem( + id: 'episode_1', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 1', + ); + final episode2 = MediaItem( + id: 'episode_2', + backend: MediaBackend.plex, + kind: MediaKind.episode, + title: 'Episode 2', + ); + const emptySeason = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: []); + final loadedSeason = MediaHub( + id: emptySeason.id, + title: emptySeason.title, + type: emptySeason.type, + items: [episode1, episode2], + size: 2, + ); + + await tester.pumpWidget(buildRail(const [emptySeason], initialItemId: episode2.id)); + await tester.pump(); + + await tester.pumpWidget(buildRail([loadedSeason], initialItemId: episode2.id)); + await tester.pump(); + + expect(focusedItemIds.last, episode2.id); + }); + + testWidgets('does not autofocus unless requested', (tester) async { + FocusManager.instance.primaryFocus?.unfocus(); + + Widget buildRail({required bool autofocus}) { + final serverManager = MultiServerManager(); + final item = MediaItem(id: 'item_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie'); + final hub = MediaHub(id: 'hub_1', title: 'Hub', type: 'movie', items: [item], size: 1); + return ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail(hubs: [hub], autofocus: autofocus, iconForHub: (_, _) => Icons.tv_rounded), + ), + ), + ), + ); + } + + await tester.pumpWidget(buildRail(autofocus: false)); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, isNot('tv_browse_rail')); + + await tester.pumpWidget(buildRail(autofocus: true)); + await tester.pump(); + expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); + }); +}