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}'; } }