perf(tv): spotlight backdrop cross-fades by paint alpha

The whole-stack AnimatedSwitcher kept two full-screen saveLayers (each
compositing backdrop + two full-screen gradients) blending on every
focus move — with CachedNetworkImage's own fade nested inside, the
dominant GPU load while browsing. Gradients are identical between items
so they never fade now; the incoming artwork fades in via Image.opacity
over the still-opaque previous one (no placeholder flash), and only the
bounded info block keeps a real cross-fade.
This commit is contained in:
edde746
2026-07-02 11:41:25 +02:00
parent 868a1469a3
commit 320bde7f33
+205 -58
View File
@@ -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<double>? 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)),
),
],
);
}
}