diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 5c4f38c7..88db7c15 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -57,35 +57,54 @@ class TvSpotlightBackground extends StatelessWidget { final media = item; final bgColor = Theme.of(context).scaffoldBackgroundColor; - return AnimatedSwitcher( - // Reduced tier swaps instantly: the cross-fade keeps two full-screen - // stacks (backdrop + two full-screen gradients each) blending per frame. - duration: DevicePerformance.reducedDuration(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), - _buildHorizontalScrim(bgColor), - 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], - ), - ), + // The gradients never differ between spotlight items, so only the artwork + // cross-fades — by image paint alpha, not widget opacity. The former + // whole-stack AnimatedSwitcher kept two full-screen saveLayers (each with + // a backdrop + two full-screen gradient fills) blending per frame on + // every focus move, which alone saturated low-end TV GPUs while browsing. + return Stack( + fit: StackFit.expand, + children: [ + RepaintBoundary( + child: blurArtwork( + _SpotlightArtworkCrossfade( + mediaKey: media?.globalKey, + image: media == null ? null : _artworkProvider(context, media), + duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)), + fallbackColor: Theme.of(context).colorScheme.surfaceContainerHighest, + emptyColor: bgColor, ), - if (media != null && showInfo) - Positioned( - left: contentLeft ?? TvLayoutConstants.horizontalInset, - right: MediaQuery.sizeOf(context).width * 0.43, - top: contentTop, - bottom: contentBottom, + ), + ), + _buildHorizontalScrim(bgColor), + 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, + // The info block still cross-fades via AnimatedSwitcher, but its + // saveLayers are bounded to the text region, not the screen. + child: AnimatedSwitcher( + duration: DevicePerformance.reducedDuration(const Duration(milliseconds: 280)), + switchInCurve: Curves.easeOutCubic, + switchOutCurve: Curves.easeOutCubic, + // Expand instead of the default loose centered Stack so the + // info keeps filling the region and bottom-left aligning. + layoutBuilder: (currentChild, previousChildren) => + Stack(fit: StackFit.expand, children: [...previousChildren, if (currentChild != null) currentChild]), + child: KeyedSubtree( + key: ValueKey(media.globalKey), child: LayoutBuilder( builder: (context, constraints) { if (!constraints.hasBoundedHeight || constraints.maxHeight <= 0 || constraints.maxWidth <= 0) { @@ -103,13 +122,15 @@ class TvSpotlightBackground extends StatelessWidget { }, ), ), - ], - ), - ), + ), + ), + ], ); } - Widget _buildArtwork(BuildContext context, MediaItem media) { + /// Resolves the backdrop image provider for [media]; null means "no art" + /// (the crossfade shows [_SpotlightArtworkCrossfade.fallbackColor]). + ImageProvider? _artworkProvider(BuildContext context, MediaItem media) { final size = MediaQuery.sizeOf(context); final dpr = MediaImageHelper.effectiveDevicePixelRatio(context); final containerAspect = size.width / size.height; @@ -127,14 +148,7 @@ class TvSpotlightBackground extends StatelessWidget { for (final candidate in artCandidates) { final localPath = localArtworkPathResolver?.call(candidate); if (localPath != null && File(localPath).existsSync()) { - return blurArtwork( - Image.file( - File(localPath), - fit: BoxFit.cover, - errorBuilder: (context, error, stackTrace) => - ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), - ), - ); + return FileImage(File(localPath)); } } @@ -149,9 +163,7 @@ class TvSpotlightBackground extends StatelessWidget { imageType: ImageType.art, ); - if (imageUrl.isEmpty) { - return ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest); - } + if (imageUrl.isEmpty) return null; final (_, memHeight) = MediaImageHelper.getMemCacheDimensions( displayWidth: (size.width * dpr).round(), @@ -159,21 +171,8 @@ class TvSpotlightBackground extends StatelessWidget { imageType: ImageType.art, ); - return blurArtwork( - CachedNetworkImage( - imageUrl: imageUrl, - cacheManager: PlexImageCacheManager.instance, - fit: BoxFit.cover, - memCacheHeight: memHeight, - // Explicit fades: the package defaults (500ms in / 1000ms out) double - // up with the AnimatedSwitcher cross-fade above on every swap. - fadeInDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)), - fadeOutDuration: DevicePerformance.reducedDuration(const Duration(milliseconds: 200)), - placeholder: (context, url) => ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), - errorBuilder: (context, error, stackTrace) => - ColoredBox(color: Theme.of(context).colorScheme.surfaceContainerHighest), - ), - ); + final provider = CachedNetworkImageProvider(imageUrl, cacheManager: PlexImageCacheManager.instance); + return ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider); } Widget _buildHorizontalScrim(Color bgColor) { @@ -410,3 +409,151 @@ class TvSpotlightBackground extends StatelessWidget { ); } } + +/// Cross-fades full-screen backdrop art without saveLayers: the previous +/// image stays fully opaque underneath while the incoming one fades in via +/// `Image.opacity` (paint alpha in RawImage). The fade only starts once the +/// incoming image has a frame, so swaps never flash a placeholder — the old +/// backdrop simply stays until the new one is ready. +class _SpotlightArtworkCrossfade extends StatefulWidget { + const _SpotlightArtworkCrossfade({ + required this.mediaKey, + required this.image, + required this.duration, + required this.fallbackColor, + required this.emptyColor, + }); + + /// Identity of the current spotlight item; fades trigger on changes. + final String? mediaKey; + + /// null with a non-null [mediaKey] means "item without art" (fallback box); + /// null with a null [mediaKey] means "no item" (empty box). + final ImageProvider? image; + final Duration duration; + final Color fallbackColor; + final Color emptyColor; + + @override + State<_SpotlightArtworkCrossfade> createState() => _SpotlightArtworkCrossfadeState(); +} + +class _SpotlightArtworkCrossfadeState extends State<_SpotlightArtworkCrossfade> with SingleTickerProviderStateMixin { + late final AnimationController _fade = AnimationController(vsync: this, duration: widget.duration); + late String? _currentKey = widget.mediaKey; + late ImageProvider? _base = widget.image; + late Color _baseColor = widget.mediaKey == null ? widget.emptyColor : widget.fallbackColor; + ImageProvider? _incoming; + bool _incomingIsColor = false; + Color _incomingColor = Colors.transparent; + bool _fadeStarted = false; + + @override + void didUpdateWidget(covariant _SpotlightArtworkCrossfade oldWidget) { + super.didUpdateWidget(oldWidget); + _fade.duration = widget.duration; + if (widget.mediaKey == _currentKey) { + // Same item, possibly a re-resolved provider (size change): update the + // settled base silently — gaplessPlayback covers the swap. + if (widget.image != null && widget.image != _base && _incoming == null && !_incomingIsColor) { + _base = widget.image; + } + return; + } + _currentKey = widget.mediaKey; + final incomingColor = widget.mediaKey == null ? widget.emptyColor : widget.fallbackColor; + if (widget.image != null && widget.image == _base) { + // Same artwork (e.g. episodes sharing show art): nothing to fade. + _dropIncoming(); + return; + } + setState(() { + _fade.stop(); + _fade.value = 0; + _fadeStarted = false; + if (widget.image != null) { + _incoming = widget.image; + _incomingIsColor = false; + } else { + _incoming = null; + _incomingIsColor = true; + _incomingColor = incomingColor; + _startFade(); // no frame to wait for + } + }); + } + + @override + void dispose() { + _fade.dispose(); + super.dispose(); + } + + void _startFade() { + if (_fadeStarted) return; + _fadeStarted = true; + _fade.forward().whenComplete(_promoteIncoming); + } + + void _promoteIncoming() { + if (!mounted || (_incoming == null && !_incomingIsColor)) return; + setState(() { + if (_incomingIsColor) { + _base = null; + _baseColor = _incomingColor; + } else { + _base = _incoming; + } + _dropIncoming(); + }); + } + + void _dropIncoming() { + _incoming = null; + _incomingIsColor = false; + _fadeStarted = false; + _fade.value = 0; + } + + Widget _image(ImageProvider provider, {Animation? opacity}) { + return Image( + image: provider, + fit: BoxFit.cover, + excludeFromSemantics: true, + // Keeps the previous frame on provider promotion instead of flashing. + gaplessPlayback: true, + opacity: opacity, + frameBuilder: opacity == null + ? null + : (context, child, frame, wasSynchronouslyLoaded) { + if (frame != null || wasSynchronouslyLoaded) _startFade(); + return child; + }, + errorBuilder: (context, error, stackTrace) { + // Broken art: fade a plain box in instead (bounded to the error case). + _startFade(); + return FadeTransition( + opacity: _fade, + child: ColoredBox(color: widget.fallbackColor), + ); + }, + ); + } + + @override + Widget build(BuildContext context) { + return Stack( + fit: StackFit.expand, + children: [ + if (_base != null) _image(_base!) else ColoredBox(color: _baseColor), + if (_incoming != null) _image(_incoming!, opacity: _fade), + if (_incomingIsColor) + AnimatedBuilder( + animation: _fade, + builder: (context, _) => + ColoredBox(color: _incomingColor.withValues(alpha: _incomingColor.a * _fade.value)), + ), + ], + ); + } +}