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:
@@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user