diff --git a/assets/screenshots/ios-home.png b/assets/screenshots/ios-home.png index 6f882247..1b5e04c8 100644 Binary files a/assets/screenshots/ios-home.png and b/assets/screenshots/ios-home.png differ diff --git a/assets/screenshots/macos-home.png b/assets/screenshots/macos-home.png index 939d0a8b..30af3b69 100644 Binary files a/assets/screenshots/macos-home.png and b/assets/screenshots/macos-home.png differ diff --git a/assets/screenshots/macos-library.png b/assets/screenshots/macos-library.png index e5431c36..4410394e 100644 Binary files a/assets/screenshots/macos-library.png and b/assets/screenshots/macos-library.png differ diff --git a/assets/screenshots/macos-media-card.png b/assets/screenshots/macos-media-card.png index fb2eb99f..b44fee2f 100644 Binary files a/assets/screenshots/macos-media-card.png and b/assets/screenshots/macos-media-card.png differ diff --git a/assets/screenshots/macos-season.png b/assets/screenshots/macos-season.png index 270485ca..2afc2ee5 100644 Binary files a/assets/screenshots/macos-season.png and b/assets/screenshots/macos-season.png differ diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index d4254e2b..60248732 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -774,272 +774,302 @@ class _DiscoverScreenState extends State }); } - @override - Widget build(BuildContext context) { - return Scaffold( - body: SafeArea( - child: CustomScrollView( - controller: _scrollController, - slivers: [ - DesktopSliverAppBar( - title: Text(t.discover.title), - floating: true, - pinned: true, - backgroundColor: Theme.of(context).scaffoldBackgroundColor, - surfaceTintColor: Colors.transparent, - shadowColor: Colors.transparent, - scrolledUnderElevation: 0, - actions: [ - Focus( - focusNode: _refreshButtonFocusNode, - onKeyEvent: _handleRefreshKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isRefreshFocused - ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: BorderRadius.circular(20), - ), - child: IconButton(icon: const AppIcon(Symbols.refresh_rounded, fill: 1), onPressed: _loadContent), + Widget _buildOverlaidAppBar() { + final statusBarHeight = MediaQuery.of(context).padding.top; + return Container( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.black.withValues(alpha: 0.5), + Colors.black.withValues(alpha: 0.3), + Colors.transparent, + ], + stops: const [0.0, 0.3, 0.6, 1.0], + ), + ), + child: Padding( + padding: EdgeInsets.only(top: statusBarHeight, left: 16, right: 16, bottom: 8), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Text( + t.discover.title, + style: Theme.of( + context, + ).textTheme.titleLarge?.copyWith(color: Colors.white, fontWeight: FontWeight.bold), + ), + const Spacer(), + Focus( + focusNode: _refreshButtonFocusNode, + onKeyEvent: _handleRefreshKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isRefreshFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: IconButton( + icon: const AppIcon(Symbols.refresh_rounded, fill: 1, color: Colors.white), + onPressed: _loadContent, ), ), - // Watch Together button - Consumer( - builder: (context, watchTogether, child) { - return Focus( - focusNode: _watchTogetherButtonFocusNode, - onKeyEvent: _handleWatchTogetherKeyEvent, - child: Container( - decoration: BoxDecoration( - color: _isWatchTogetherFocused - ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: BorderRadius.circular(20), - ), - child: Stack( - children: [ - IconButton( - icon: AppIcon( - Symbols.group_rounded, - fill: watchTogether.isInSession ? 1 : 0, - color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : null, - ), - onPressed: () => Navigator.push( - context, - MaterialPageRoute(builder: (_) => const WatchTogetherScreen()), - ), - tooltip: 'Watch Together', - ), - // Badge showing participant count when in session - if (watchTogether.isInSession && watchTogether.participantCount > 1) - Positioned( - top: 6, - right: 6, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.primary, - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '${watchTogether.participantCount}', - style: TextStyle( - color: Theme.of(context).colorScheme.onPrimary, - fontSize: 10, - fontWeight: FontWeight.bold, - ), - ), - ), - ), - ], - ), + ), + // Watch Together button + Consumer( + builder: (context, watchTogether, child) { + return Focus( + focusNode: _watchTogetherButtonFocusNode, + onKeyEvent: _handleWatchTogetherKeyEvent, + child: Container( + decoration: BoxDecoration( + color: _isWatchTogetherFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), ), - ); - }, - ), - Consumer( - builder: (context, userProvider, child) { - return Focus( - focusNode: _userButtonFocusNode, - onKeyEvent: _handleUserKeyEvent, - child: DecoratedBox( - decoration: BoxDecoration( - color: _isUserFocused - ? Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.08) - : Colors.transparent, - borderRadius: BorderRadius.circular(20), - ), - child: PopupMenuButton( - icon: userProvider.currentUser?.thumb != null - ? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false) - : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32), - onSelected: (value) { - if (value == 'switch_profile') { - _handleSwitchProfile(context); - } else if (value == 'logout') { - _handleLogout(); - } - }, - itemBuilder: (context) => [ - // Only show Switch Profile if multiple users available - if (userProvider.hasMultipleUsers) - PopupMenuItem( - value: 'switch_profile', - child: Row( - children: [ - AppIcon(Symbols.people_rounded, fill: 1), - SizedBox(width: 8), - Text(t.discover.switchProfile), - ], + child: Stack( + children: [ + IconButton( + icon: AppIcon( + Symbols.group_rounded, + fill: watchTogether.isInSession ? 1 : 0, + color: watchTogether.isInSession ? Theme.of(context).colorScheme.primary : Colors.white, + ), + onPressed: () => + Navigator.push(context, MaterialPageRoute(builder: (_) => const WatchTogetherScreen())), + tooltip: 'Watch Together', + ), + // Badge showing participant count when in session + if (watchTogether.isInSession && watchTogether.participantCount > 1) + Positioned( + top: 6, + right: 6, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.primary, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + '${watchTogether.participantCount}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimary, + fontSize: 10, + fontWeight: FontWeight.bold, + ), ), ), + ), + ], + ), + ), + ); + }, + ), + Consumer( + builder: (context, userProvider, child) { + return Focus( + focusNode: _userButtonFocusNode, + onKeyEvent: _handleUserKeyEvent, + child: DecoratedBox( + decoration: BoxDecoration( + color: _isUserFocused ? Colors.white.withValues(alpha: 0.2) : Colors.transparent, + borderRadius: BorderRadius.circular(20), + ), + child: PopupMenuButton( + icon: userProvider.currentUser?.thumb != null + ? UserAvatarWidget(user: userProvider.currentUser!, size: 32, showIndicators: false) + : const AppIcon(Symbols.account_circle_rounded, fill: 1, size: 32, color: Colors.white), + onSelected: (value) { + if (value == 'switch_profile') { + _handleSwitchProfile(context); + } else if (value == 'logout') { + _handleLogout(); + } + }, + itemBuilder: (context) => [ + // Only show Switch Profile if multiple users available + if (userProvider.hasMultipleUsers) PopupMenuItem( - value: 'logout', + value: 'switch_profile', child: Row( children: [ - AppIcon(Symbols.logout_rounded, fill: 1), + AppIcon(Symbols.people_rounded, fill: 1), SizedBox(width: 8), - Text(t.discover.logout), + Text(t.discover.switchProfile), ], ), ), - ], - ), - ), - ); - }, - ), - ], - ), - if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())), - if (_errorMessage != null) - SliverFillRemaining( - child: ErrorStateWidget( - message: _errorMessage!, - icon: Symbols.error_outline_rounded, - onRetry: _loadContent, - ), - ), - if (!_isLoading && _errorMessage == null) ...[ - // Hero Section (Continue Watching) - Consumer( - builder: (context, settingsProvider, child) { - if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) { - return _buildHeroSection(); - } - return const SliverToBoxAdapter(child: SizedBox.shrink()); - }, - ), - - // On Deck / Continue Watching - if (_onDeck.isNotEmpty) - SliverToBoxAdapter( - child: HubSection( - key: _continueWatchingHubKey, - hub: PlexHub( - hubKey: 'continue_watching', - title: t.discover.continueWatching, - type: 'mixed', - hubIdentifier: '_continue_watching_', - size: _onDeck.length, - more: false, - items: _onDeck, - ), - icon: Symbols.play_circle_rounded, - onRefresh: updateItem, - onRemoveFromContinueWatching: _refreshContinueWatching, - isInContinueWatching: true, - onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp), - ), - ), - - // Recommendation Hubs (Trending, Top in Genre, etc.) - for (int i = 0; i < _hubs.length; i++) - SliverToBoxAdapter( - child: HubSection( - key: i < _hubKeys.length ? _hubKeys[i] : null, - hub: _hubs[i], - icon: _getHubIcon(_hubs[i].title), - onRefresh: updateItem, - // Hub index is i + 1 if continue watching exists, otherwise i - onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp), - ), - ), - - // Show loading skeleton for hubs while they're loading - if (_areHubsLoading && _hubs.isEmpty) - for (int i = 0; i < 3; i++) - SliverToBoxAdapter( - child: Container( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Hub title skeleton - Container( - width: 200, - height: 24, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(4), - ), - ), - const SizedBox(height: 16), - // Hub items skeleton - SizedBox( - height: 200, - child: ListView.builder( - scrollDirection: Axis.horizontal, - itemCount: 5, - itemBuilder: (context, index) { - return Container( - margin: const EdgeInsets.only(right: 12), - width: 140, - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainerHighest, - borderRadius: BorderRadius.circular(tokens(context).radiusSm), - ), - ); - }, + PopupMenuItem( + value: 'logout', + child: Row( + children: [ + AppIcon(Symbols.logout_rounded, fill: 1), + SizedBox(width: 8), + Text(t.discover.logout), + ], ), ), ], ), ), - ), - - if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading) - SliverFillRemaining( - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), - SizedBox(height: 16), - Text(t.discover.noContentAvailable), - SizedBox(height: 8), - Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)), - ], - ), - ), - ), - - const SliverToBoxAdapter(child: SizedBox(height: 24)), + ); + }, + ), ], - ], + ), ), ), ); } + @override + Widget build(BuildContext context) { + return Scaffold( + body: Stack( + children: [ + SafeArea( + top: false, + child: CustomScrollView( + controller: _scrollController, + slivers: [ + // Hero Section (Continue Watching) - at top of screen + Consumer( + builder: (context, settingsProvider, child) { + if (_onDeck.isNotEmpty && settingsProvider.showHeroSection) { + return _buildHeroSection(); + } + // Add top padding when hero is not shown + return SliverToBoxAdapter( + child: SizedBox(height: kToolbarHeight + MediaQuery.of(context).padding.top + 16), + ); + }, + ), + if (_isLoading) const SliverFillRemaining(child: Center(child: CircularProgressIndicator())), + if (_errorMessage != null) + SliverFillRemaining( + child: ErrorStateWidget( + message: _errorMessage!, + icon: Symbols.error_outline_rounded, + onRetry: _loadContent, + ), + ), + if (!_isLoading && _errorMessage == null) ...[ + // On Deck / Continue Watching + if (_onDeck.isNotEmpty) + SliverToBoxAdapter( + child: HubSection( + key: _continueWatchingHubKey, + hub: PlexHub( + hubKey: 'continue_watching', + title: t.discover.continueWatching, + type: 'mixed', + hubIdentifier: '_continue_watching_', + size: _onDeck.length, + more: false, + items: _onDeck, + ), + icon: Symbols.play_circle_rounded, + onRefresh: updateItem, + onRemoveFromContinueWatching: _refreshContinueWatching, + isInContinueWatching: true, + onVerticalNavigation: (isUp) => _handleVerticalNavigation(0, isUp), + ), + ), + + // Recommendation Hubs (Trending, Top in Genre, etc.) + for (int i = 0; i < _hubs.length; i++) + SliverToBoxAdapter( + child: HubSection( + key: i < _hubKeys.length ? _hubKeys[i] : null, + hub: _hubs[i], + icon: _getHubIcon(_hubs[i].title), + onRefresh: updateItem, + // Hub index is i + 1 if continue watching exists, otherwise i + onVerticalNavigation: (isUp) => _handleVerticalNavigation(_onDeck.isNotEmpty ? i + 1 : i, isUp), + ), + ), + + // Show loading skeleton for hubs while they're loading + if (_areHubsLoading && _hubs.isEmpty) + for (int i = 0; i < 3; i++) + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Hub title skeleton + Container( + width: 200, + height: 24, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(4), + ), + ), + const SizedBox(height: 16), + // Hub items skeleton + SizedBox( + height: 200, + child: ListView.builder( + scrollDirection: Axis.horizontal, + itemCount: 5, + itemBuilder: (context, index) { + return Container( + margin: const EdgeInsets.only(right: 12), + width: 140, + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(tokens(context).radiusSm), + ), + ); + }, + ), + ), + ], + ), + ), + ), + + if (_onDeck.isEmpty && _hubs.isEmpty && !_areHubsLoading) + SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + AppIcon(Symbols.movie_rounded, fill: 1, size: 64, color: Colors.grey), + SizedBox(height: 16), + Text(t.discover.noContentAvailable), + SizedBox(height: 8), + Text(t.discover.addMediaToLibraries, style: TextStyle(color: Colors.grey)), + ], + ), + ), + ), + + const SliverToBoxAdapter(child: SizedBox(height: 24)), + ], + ], + ), + ), + // Overlaid app bar + Positioned(top: 0, left: 0, right: 0, child: _buildOverlaidAppBar()), + ], + ), + ); + } + Widget _buildHeroSection() { + final statusBarHeight = MediaQuery.of(context).padding.top; return SliverToBoxAdapter( child: Focus( focusNode: _heroFocusNode, autofocus: true, onKeyEvent: _handleHeroKeyEvent, child: SizedBox( - height: 500, + height: 500 + statusBarHeight, child: Stack( children: [ PageView.builder( @@ -1078,7 +1108,7 @@ class _DiscoverScreenState extends State child: AppIcon( _isAutoScrollPaused ? Symbols.play_arrow_rounded : Symbols.pause_rounded, fill: 1, - color: Colors.white, + color: Theme.of(context).colorScheme.onSurface, size: 18, semanticLabel: '${_isAutoScrollPaused ? t.discover.play : t.discover.pause} auto-scroll', ), @@ -1101,6 +1131,7 @@ class _DiscoverScreenState extends State // Fill width animates based on dot size final maxWidth = dotSize * 3; // 24px for normal, 15px for small final fillWidth = dotSize + ((maxWidth - dotSize) * _indicatorAnimationController.value); + final onSurface = Theme.of(context).colorScheme.onSurface; return AnimatedContainer( duration: tokens(context).slow, curve: Curves.easeInOut, @@ -1108,7 +1139,7 @@ class _DiscoverScreenState extends State width: maxWidth, height: dotSize, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), + color: onSurface.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(dotSize / 2), ), child: Align( @@ -1117,7 +1148,7 @@ class _DiscoverScreenState extends State width: fillWidth, height: dotSize, decoration: BoxDecoration( - color: Colors.white, + color: onSurface, borderRadius: BorderRadius.circular(dotSize / 2), ), ), @@ -1134,7 +1165,7 @@ class _DiscoverScreenState extends State width: dotSize, height: dotSize, decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.4), + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.4), borderRadius: BorderRadius.circular(dotSize / 2), ), ); @@ -1172,216 +1203,226 @@ class _DiscoverScreenState extends State appLogger.d('Navigating to VideoPlayerScreen for: ${heroItem.title}'); navigateToVideoPlayer(context, metadata: heroItem); }, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.3), blurRadius: 20, offset: const Offset(0, 10)), - ], - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(16), - child: Stack( - fit: StackFit.expand, - children: [ - // Background Image with fade/zoom animation and parallax - if (heroItem.art != null || heroItem.grandparentArt != null) - AnimatedBuilder( - animation: _scrollController, - builder: (context, child) { - final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0; - return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child); + child: Stack( + fit: StackFit.expand, + children: [ + // Background Image with fade/zoom animation and parallax + if (heroItem.art != null || heroItem.grandparentArt != null) + AnimatedBuilder( + animation: _scrollController, + builder: (context, child) { + final scrollOffset = _scrollController.hasClients ? _scrollController.offset : 0.0; + return Transform.translate(offset: Offset(0, scrollOffset * 0.3), child: child); + }, + child: TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: const Duration(milliseconds: 800), + curve: Curves.easeOut, + builder: (context, value, child) { + return Transform.scale( + scale: 1.0 + (0.1 * (1 - value)), + child: Opacity(opacity: value, child: child), + ); + }, + child: Builder( + builder: (context) { + final client = _getClientForItem(heroItem); + final mediaQuery = MediaQuery.of(context); + final imageUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: heroItem.art ?? heroItem.grandparentArt, + maxWidth: mediaQuery.size.width, + maxHeight: mediaQuery.size.height * 0.7, + devicePixelRatio: mediaQuery.devicePixelRatio, + imageType: ImageType.art, + ); + + return CachedNetworkImage( + imageUrl: imageUrl, + fit: BoxFit.cover, + placeholder: (context, url) => + Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), + errorWidget: (context, url, error) => + Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), + ); }, - child: TweenAnimationBuilder( - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 800), - curve: Curves.easeOut, - builder: (context, value, child) { - return Transform.scale( - scale: 1.0 + (0.1 * (1 - value)), - child: Opacity(opacity: value, child: child), - ); - }, - child: Builder( - builder: (context) { - final client = _getClientForItem(heroItem); - final mediaQuery = MediaQuery.of(context); - final imageUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: heroItem.art ?? heroItem.grandparentArt, - maxWidth: mediaQuery.size.width, - maxHeight: mediaQuery.size.height * 0.7, - devicePixelRatio: mediaQuery.devicePixelRatio, - imageType: ImageType.art, - ); + ), + ), + ) + else + Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), - return CachedNetworkImage( - imageUrl: imageUrl, - fit: BoxFit.cover, - placeholder: (context, url) => - Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), - errorWidget: (context, url, error) => - Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), - ); - }, - ), - ), - ) - else - Container(color: Theme.of(context).colorScheme.surfaceContainerHighest), - - // Gradient Overlay - Container( + // Gradient Overlay - blends into scaffold background + Builder( + builder: (context) { + final bgColor = Theme.of(context).scaffoldBackgroundColor; + return Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - Colors.black.withValues(alpha: 0.9), - ], - stops: const [0.0, 0.5, 1.0], + colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], + stops: const [0.5, 0.85, 1.0], ), ), - ), + ); + }, + ), - // Content with responsive alignment - Positioned( - bottom: isLargeScreen ? 80 : 50, - left: 0, - right: isLargeScreen ? 200 : 0, - child: Padding( - padding: EdgeInsets.symmetric(horizontal: isLargeScreen ? 40 : 16), - child: Column( - crossAxisAlignment: isLargeScreen ? CrossAxisAlignment.start : CrossAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - // Show logo or name/title - if (heroItem.clearLogo != null) - SizedBox( - height: 120, - width: 400, - child: Builder( - builder: (context) { - final client = _getClientForItem(heroItem); - final dpr = MediaQuery.of(context).devicePixelRatio; - final logoUrl = PlexImageHelper.getOptimizedImageUrl( - client: client, - thumbPath: heroItem.clearLogo, - maxWidth: 400, - maxHeight: 120, - devicePixelRatio: dpr, - imageType: ImageType.logo, - ); + // Content with responsive alignment + Positioned( + bottom: isLargeScreen ? 80 : 50, + left: 0, + right: 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.clearLogo != null) + SizedBox( + height: 120, + width: 400, + child: Builder( + builder: (context) { + final client = _getClientForItem(heroItem); + final dpr = MediaQuery.of(context).devicePixelRatio; + final logoUrl = PlexImageHelper.getOptimizedImageUrl( + client: client, + thumbPath: heroItem.clearLogo, + maxWidth: 400, + maxHeight: 120, + devicePixelRatio: dpr, + imageType: ImageType.logo, + ); - return CachedNetworkImage( - imageUrl: logoUrl, - filterQuality: FilterQuality.medium, - fit: BoxFit.contain, - memCacheWidth: (400 * dpr).clamp(200, 800).round(), - alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter, - placeholder: (context, url) => Align( - alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center, - child: Text( - showName, - style: Theme.of(context).textTheme.displaySmall?.copyWith( - color: Colors.white.withValues(alpha: 0.3), - fontWeight: FontWeight.bold, - shadows: [Shadow(color: Colors.black.withValues(alpha: 0.5), blurRadius: 8)], + return CachedNetworkImage( + imageUrl: logoUrl, + filterQuality: FilterQuality.medium, + fit: BoxFit.contain, + memCacheWidth: (400 * dpr).clamp(200, 800).round(), + alignment: isLargeScreen ? Alignment.bottomLeft : Alignment.bottomCenter, + placeholder: (context, url) => Align( + alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center, + child: Text( + showName, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.3), + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + blurRadius: 8, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - ), + ], ), - errorWidget: (context, url, error) { - // Fallback to text if logo fails to load - return Align( - alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center, - child: Text( - showName, - style: Theme.of(context).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, + textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, + ), + ), + errorWidget: (context, url, error) { + // Fallback to text if logo fails to load + return Align( + alignment: isLargeScreen ? Alignment.centerLeft : Alignment.center, + child: Text( + showName, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + shadows: [ + Shadow( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.8), + blurRadius: 8, ), - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - ), - ); - }, + ], + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, + ), ); }, - ), - ) - else - Text( - showName, - style: Theme.of(context).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, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, + ); + }, + ), + ) + else + Text( + showName, + style: Theme.of(context).textTheme.displaySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurface, + fontWeight: FontWeight.bold, + shadows: [ + Shadow(color: Theme.of(context).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) '★ ${heroItem.rating!.toStringAsFixed(1)}', + 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) ...[ + 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) + : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.7), + fontSize: 14, + height: 1.4, ), - - // 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) '★ ${heroItem.rating!.toStringAsFixed(1)}', - 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) ...[ - const SizedBox(height: 12), - RichText( - maxLines: 2, - overflow: TextOverflow.ellipsis, - textAlign: isLargeScreen ? TextAlign.left : TextAlign.center, - text: TextSpan( - style: const TextStyle(color: Colors.white70, fontSize: 14, height: 1.4), - children: [ - if (isEpisode && heroItem.parentIndex != null && heroItem.index != null) - TextSpan( - text: 'S${heroItem.parentIndex}, E${heroItem.index}: ', - style: const TextStyle(fontWeight: FontWeight.bold, color: Colors.white), - ), - TextSpan( - text: heroItem.summary?.isNotEmpty == true - ? heroItem.summary! - : 'No description available', + 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 : Theme.of(context).colorScheme.onSurface, ), - ], + ), + TextSpan( + text: heroItem.summary?.isNotEmpty == true + ? heroItem.summary! + : 'No description available', ), - ), - ], + ], + ), + ), + ], - // On large screens: show button after summary - if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], - ], - ), - ), + // On large screens: show button after summary + if (isLargeScreen) ...[const SizedBox(height: 20), _buildSmartPlayButton(heroItem)], + ], ), - ], + ), ), - ), + ], ), ), );