From 6274facc3c51f22cb1930fac0525627efc49da2a Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Jul 2026 09:32:50 +0200 Subject: [PATCH] fix(tv): fade dim-scrim edges instead of hard rect boundaries Unlike the content opacity it replaced, the dim quad also darkens the backdrop showing through around the rows, so its rectangle edge read as a hard line across the spotlight artwork. Ramp the dim in over narrow top/bottom gradient bands (flat quad interior, non-AA to avoid seams); band area keeps the gradient-shader cost negligible on weak TV GPUs. Browse pass on the Mali-G31 box unchanged: draw p50 7.2ms, 2% janky. --- lib/widgets/animated_dim_scrim.dart | 88 ++++++++++++++++++++++++++++- lib/widgets/tv_browse_rail.dart | 7 +++ 2 files changed, 92 insertions(+), 3 deletions(-) diff --git a/lib/widgets/animated_dim_scrim.dart b/lib/widgets/animated_dim_scrim.dart index 5ffc8c6a..69cad73e 100644 --- a/lib/widgets/animated_dim_scrim.dart +++ b/lib/widgets/animated_dim_scrim.dart @@ -1,3 +1,5 @@ +import 'dart:ui' as ui; + import 'package:flutter/widgets.dart'; import '../focus/focus_theme.dart'; @@ -11,12 +13,26 @@ import '../focus/focus_theme.dart'; /// `Opacity(opacity: 1 - alpha)`; over artwork it darkens toward [color] /// instead of turning translucent. /// +/// Unlike content opacity, the quad also darkens whatever shows through +/// gaps in the content, so its rectangle boundary can read as a hard line +/// across backdrop artwork. [fadeTop]/[fadeBottom] soften that by ramping +/// the dim in over a band of pixels. The bands are gradient-shaded, which +/// costs GPU per covered pixel on low-end TVs — keep them narrow; the +/// interior stays a flat quad. +/// /// Stack this above the content (with `IgnorePointer` built in so taps pass /// through). Animates with [FocusTheme.getAnimationDuration], so the full /// tier fades and the reduced tier snaps, matching the [AnimatedOpacity] /// behavior it replaces. class AnimatedDimScrim extends StatelessWidget { - const AnimatedDimScrim({super.key, required this.dimmed, required this.color, required this.alpha}); + const AnimatedDimScrim({ + super.key, + required this.dimmed, + required this.color, + required this.alpha, + this.fadeTop = 0, + this.fadeBottom = 0, + }); final bool dimmed; final Color color; @@ -25,6 +41,13 @@ class AnimatedDimScrim extends StatelessWidget { /// `Opacity(opacity: 1 - alpha)` over a [color] underlay. final double alpha; + /// Height of the ramp from transparent to full strength at the top edge. + final double fadeTop; + + /// Height of the ramp from full strength back to transparent at the + /// bottom edge. + final double fadeBottom; + @override Widget build(BuildContext context) { return IgnorePointer( @@ -32,9 +55,68 @@ class AnimatedDimScrim extends StatelessWidget { tween: Tween(end: dimmed ? alpha : 0.0), duration: FocusTheme.getAnimationDuration(context), curve: Curves.easeOutCubic, - builder: (context, value, _) => - value == 0 ? const SizedBox.shrink() : ColoredBox(color: color.withValues(alpha: value)), + builder: (context, value, _) { + if (value == 0) return const SizedBox.shrink(); + if (fadeTop == 0 && fadeBottom == 0) { + return ColoredBox(color: color.withValues(alpha: value)); + } + return CustomPaint( + painter: _DimScrimPainter(color: color, alpha: value, fadeTop: fadeTop, fadeBottom: fadeBottom), + ); + }, ), ); } } + +class _DimScrimPainter extends CustomPainter { + const _DimScrimPainter({required this.color, required this.alpha, required this.fadeTop, required this.fadeBottom}); + + final Color color; + final double alpha; + final double fadeTop; + final double fadeBottom; + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final full = color.withValues(alpha: alpha); + final none = color.withValues(alpha: 0.0); + final top = fadeTop.clamp(0.0, size.height); + final bottom = fadeBottom.clamp(0.0, size.height - top); + + // Anti-aliasing off: adjacent translucent rects sharing an edge would + // otherwise double-blend along the seam. + canvas.drawRect( + Rect.fromLTRB(0, top, size.width, size.height - bottom), + Paint() + ..color = full + ..isAntiAlias = false, + ); + if (top > 0) { + final band = Rect.fromLTRB(0, 0, size.width, top); + canvas.drawRect( + band, + Paint() + ..shader = ui.Gradient.linear(band.topLeft, band.bottomLeft, [none, full]) + ..isAntiAlias = false, + ); + } + if (bottom > 0) { + final band = Rect.fromLTRB(0, size.height - bottom, size.width, size.height); + canvas.drawRect( + band, + Paint() + ..shader = ui.Gradient.linear(band.topLeft, band.bottomLeft, [full, none]) + ..isAntiAlias = false, + ); + } + } + + @override + bool shouldRepaint(_DimScrimPainter oldDelegate) => + color != oldDelegate.color || + alpha != oldDelegate.alpha || + fadeTop != oldDelegate.fadeTop || + fadeBottom != oldDelegate.fadeBottom; +} diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index fe80dc73..abbeccbd 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -1135,6 +1135,9 @@ class TvBrowseRailState extends State { dimmed: !railHasFocus, color: theme.scaffoldBackgroundColor, alpha: _unfocusedRailDimAlpha, + // The viewport's top edge cuts across the + // spotlight artwork; ramp the dim in instead. + fadeTop: 36 * scale, ), ), ), @@ -1348,6 +1351,10 @@ class TvBrowseRailState extends State { dimmed: !isActive, color: Theme.of(context).scaffoldBackgroundColor, alpha: _inactiveHubDimAlpha, + // Soften the quad's boundary so it doesn't draw a hard + // line across the artwork showing through around the row. + fadeTop: 20 * scale, + fadeBottom: 20 * scale, ), ), ),