diff --git a/lib/screens/discover_screen.dart b/lib/screens/discover_screen.dart index c6e2fa42..6e71f0b2 100644 --- a/lib/screens/discover_screen.dart +++ b/lib/screens/discover_screen.dart @@ -22,6 +22,7 @@ import '../media/media_hub.dart'; import '../utils/media_image_helper.dart'; import '../utils/content_utils.dart'; import '../widgets/optimized_media_image.dart' show blurArtwork; +import '../widgets/rasterized_gradient.dart'; import '../providers/discover_provider.dart'; import '../providers/multi_server_provider.dart'; import '../providers/watch_state_store.dart'; @@ -882,19 +883,17 @@ class _DiscoverScreenState extends State final colorScheme = Theme.of(context).colorScheme; final overlayColor = colorScheme.brightness == Brightness.dark ? Colors.black : colorScheme.surface; final foregroundColor = colorScheme.onSurface; - return DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - overlayColor.withValues(alpha: 0.7), - overlayColor.withValues(alpha: 0.5), - overlayColor.withValues(alpha: 0.3), - Colors.transparent, - ], - stops: const [0.0, 0.3, 0.6, 1.0], - ), + return RasterizedGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + overlayColor.withValues(alpha: 0.7), + overlayColor.withValues(alpha: 0.5), + overlayColor.withValues(alpha: 0.3), + Colors.transparent, + ], + stops: const [0.0, 0.3, 0.6, 1.0], ), child: Padding( padding: .only(top: statusBarHeight, left: 16, right: 16, bottom: 8), diff --git a/lib/screens/media_detail_screen.dart b/lib/screens/media_detail_screen.dart index ed45d114..c2048d36 100644 --- a/lib/screens/media_detail_screen.dart +++ b/lib/screens/media_detail_screen.dart @@ -83,6 +83,7 @@ import '../widgets/focusable_tab_chip.dart'; import '../widgets/hub_section.dart'; import '../widgets/ios_status_bar_tap_scroll_to_top.dart'; import '../widgets/loading_indicator_box.dart'; +import '../widgets/rasterized_gradient.dart'; import '../widgets/tv_browse_rail.dart'; import '../widgets/tv_spotlight_background.dart'; @@ -3309,9 +3310,9 @@ class _MediaDetailScreenState extends State child: child!, ), ), - child: Container( + child: SizedBox( height: MediaQuery.paddingOf(context).top + 58, - decoration: BoxDecoration( + child: RasterizedGradient( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, @@ -4130,14 +4131,12 @@ class _MediaDetailScreenState extends State child: Builder( builder: (context) { final bgColor = Theme.of(context).scaffoldBackgroundColor; - return Container( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], - stops: const [0.3, 0.8, 1.0], - ), + return RasterizedGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, bgColor.withValues(alpha: 0.9), bgColor], + stops: const [0.3, 0.8, 1.0], ), ); }, diff --git a/lib/widgets/rasterized_gradient.dart b/lib/widgets/rasterized_gradient.dart new file mode 100644 index 00000000..74ba83a4 --- /dev/null +++ b/lib/widgets/rasterized_gradient.dart @@ -0,0 +1,214 @@ +import 'dart:async' show unawaited; +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; + +/// Paints an axis-aligned [LinearGradient] as a pre-rasterized 1-D strip +/// texture stretched across the box instead of evaluating Skia's gradient +/// shader per pixel. +/// +/// Skia's dithered gradient shaders cost ~10ms per full-screen pass on +/// low-end TV GPUs (measured on a Mali-G31 box: the two spotlight scrims +/// alone were ~20ms of a 27ms raster frame, while flat blended quads on the +/// same coverage were ~free). A strip texture blends at flat-quad cost: the +/// strip stays resident in the GPU texture cache, so per-pixel bandwidth is +/// framebuffer-only. +/// +/// The strip quantizes the ramp to 8-bit without dithering, which is +/// indistinguishable for scrims with large alpha ranges (steps land every +/// few pixels). Don't use it for subtle low-contrast ramps spanning huge +/// areas — those are where undithered gradients band visibly. +/// +/// Gradients that aren't pure-horizontal/vertical (or that carry a +/// transform or non-clamp tile mode) paint through the regular shader path +/// unchanged, as does the first frame while the strip decodes. +class RasterizedGradient extends StatefulWidget { + final LinearGradient gradient; + final Widget? child; + + const RasterizedGradient({super.key, required this.gradient, this.child}); + + /// Bakes [gradient]'s strip ahead of time so the first build paints it + /// instead of the shader fallback. Tests use this to guarantee they + /// exercise the strip path. + @visibleForTesting + static Future prebake(LinearGradient gradient) => _GradientStripCache.bake(gradient); + + @override + State createState() => _RasterizedGradientState(); +} + +class _RasterizedGradientState extends State { + ui.Image? _strip; + + @override + void initState() { + super.initState(); + _resolveStrip(); + } + + @override + void didUpdateWidget(RasterizedGradient oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.gradient != oldWidget.gradient) { + _strip = null; + _resolveStrip(); + } + } + + void _resolveStrip() { + final gradient = widget.gradient; + final cached = _GradientStripCache.get(gradient); + if (cached != null) { + _strip = cached; + return; + } + _GradientStripCache.bake(gradient).then((image) { + if (!mounted || image == null || widget.gradient != gradient) return; + setState(() => _strip = image); + }); + } + + @override + Widget build(BuildContext context) { + return CustomPaint( + painter: _GradientStripPainter(strip: _strip, gradient: widget.gradient), + child: widget.child, + ); + } +} + +class _GradientStripPainter extends CustomPainter { + final ui.Image? strip; + final LinearGradient gradient; + + const _GradientStripPainter({required this.strip, required this.gradient}); + + @override + void paint(Canvas canvas, Size size) { + if (size.isEmpty) return; + final rect = Offset.zero & size; + final strip = this.strip; + if (strip == null) { + canvas.drawRect(rect, Paint()..shader = gradient.createShader(rect)); + return; + } + canvas.drawImageRect( + strip, + Rect.fromLTWH(0, 0, strip.width.toDouble(), strip.height.toDouble()), + rect, + Paint()..filterQuality = FilterQuality.low, + ); + } + + @override + bool shouldRepaint(_GradientStripPainter oldDelegate) => + strip != oldDelegate.strip || gradient != oldDelegate.gradient; +} + +abstract final class _GradientStripCache { + static const _stripLength = 1024; + static const _maxEntries = 32; + + // Strips are ~4KB each and may be referenced by in-flight frames, so the + // cache never disposes entries; the cap bounds it to ~128KB worst case. + static final _strips = {}; + static final _pending = >{}; + + static ui.Image? get(LinearGradient gradient) => _strips[gradient]; + + static Future bake(LinearGradient gradient) { + final cached = _strips[gradient]; + if (cached != null) return Future.value(cached); + return _pending[gradient] ??= _bake(gradient); + } + + static Future _bake(LinearGradient gradient) async { + final bytes = _stripBytes(gradient); + if (bytes == null) return null; // Unsupported shape: shader fallback. + + try { + final image = await _decode(bytes, _isVertical(gradient)); + if (_strips.length >= _maxEntries) _strips.remove(_strips.keys.first); + _strips[gradient] = image; + return image; + } catch (_) { + return null; // Keep painting through the shader path. + } finally { + unawaited(_pending.remove(gradient)); + } + } + + static Future _decode(Uint8List bytes, bool vertical) async { + final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); + final descriptor = ui.ImageDescriptor.raw( + buffer, + width: vertical ? 1 : _stripLength, + height: vertical ? _stripLength : 1, + pixelFormat: ui.PixelFormat.rgba8888, + ); + final codec = await descriptor.instantiateCodec(); + final frame = await codec.getNextFrame(); + codec.dispose(); + descriptor.dispose(); + buffer.dispose(); + return frame.image; + } + + static bool _isVertical(LinearGradient gradient) { + final begin = gradient.begin as Alignment; + final end = gradient.end as Alignment; + return begin.x == end.x; + } + + /// Premultiplied RGBA texels along the gradient axis ([ui.PixelFormat.rgba8888] + /// is consumed as premultiplied), or null when the gradient can't be + /// represented as an axis-aligned clamped strip. + static Uint8List? _stripBytes(LinearGradient gradient) { + if (gradient.transform != null || gradient.tileMode != TileMode.clamp) return null; + final begin = gradient.begin; + final end = gradient.end; + if (begin is! Alignment || end is! Alignment) return null; + final vertical = begin.x == end.x && begin.y != end.y; + final horizontal = begin.y == end.y && begin.x != end.x; + if (!vertical && !horizontal) return null; + + // Box-relative fractions of the ramp's endpoints along the axis. + final b = vertical ? (begin.y + 1) / 2 : (begin.x + 1) / 2; + final e = vertical ? (end.y + 1) / 2 : (end.x + 1) / 2; + + final colors = gradient.colors; + if (colors.isEmpty) return null; + final stops = + gradient.stops ?? [for (var i = 0; i < colors.length; i++) colors.length == 1 ? 0.0 : i / (colors.length - 1)]; + if (stops.length != colors.length) return null; + + final bytes = Uint8List(_stripLength * 4); + for (var i = 0; i < _stripLength; i++) { + final f = i / (_stripLength - 1); + final t = ((f - b) / (e - b)).clamp(0.0, 1.0); + final color = _colorAt(colors, stops, t); + // Skia gradients interpolate straight-alpha, then premultiply at + // shading — lerp first, premultiply last replicates that exactly. + final a = color.a; + bytes[i * 4] = (color.r * a * 255.0).round().clamp(0, 255); + bytes[i * 4 + 1] = (color.g * a * 255.0).round().clamp(0, 255); + bytes[i * 4 + 2] = (color.b * a * 255.0).round().clamp(0, 255); + bytes[i * 4 + 3] = (a * 255.0).round().clamp(0, 255); + } + return bytes; + } + + static Color _colorAt(List colors, List stops, double t) { + if (t <= stops.first) return colors.first; + if (t >= stops.last) return colors.last; + for (var i = 0; i < stops.length - 1; i++) { + if (t > stops[i + 1]) continue; + final span = stops[i + 1] - stops[i]; + final local = span <= 0 ? 0.0 : (t - stops[i]) / span; + return Color.lerp(colors[i], colors[i + 1], local)!; + } + return colors.last; + } +} diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 835aeb42..fe80dc73 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -31,6 +31,7 @@ import 'horizontal_scroll_with_arrows.dart'; import 'listenable_selector.dart'; import 'media_card.dart'; import 'optimized_media_image.dart'; +import 'rasterized_gradient.dart'; import 'settings_builder.dart'; class TvBrowseRailLayoutMetrics { @@ -1505,14 +1506,12 @@ class TvBrowseRailState extends State { imageType: ImageType.avatar, fallbackIcon: Symbols.person_rounded, ), - DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, Colors.black.withValues(alpha: 0.78)], - stops: const [0.45, 1.0], - ), + RasterizedGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, Colors.black.withValues(alpha: 0.78)], + stops: const [0.45, 1.0], ), ), Positioned( @@ -1769,13 +1768,11 @@ class _RailBackgroundBleed extends StatelessWidget { tween: Tween(end: target), duration: FocusTheme.getAnimationDuration(context), curve: Curves.easeOutCubic, - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Colors.transparent, backgroundColor.withValues(alpha: 0.7)], - ), + child: RasterizedGradient( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [Colors.transparent, backgroundColor.withValues(alpha: 0.7)], ), ), builder: (context, bleedLeft, child) { diff --git a/lib/widgets/tv_spotlight_background.dart b/lib/widgets/tv_spotlight_background.dart index 78e87598..ec0f4792 100644 --- a/lib/widgets/tv_spotlight_background.dart +++ b/lib/widgets/tv_spotlight_background.dart @@ -19,6 +19,7 @@ import 'app_icon.dart'; import 'fitting_title_text.dart'; import 'media_rating_badge.dart'; import 'optimized_media_image.dart' show blurArtwork; +import 'rasterized_gradient.dart'; class TvSpotlightBackground extends StatelessWidget { final MediaItem? item; @@ -77,14 +78,12 @@ class TvSpotlightBackground extends StatelessWidget { ), ), _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], - ), + RasterizedGradient( + 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) @@ -176,14 +175,12 @@ class TvSpotlightBackground extends StatelessWidget { } Widget _buildHorizontalScrim(Color bgColor) { - return 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], - ), + return RasterizedGradient( + 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], ), ); } diff --git a/lib/widgets/video_controls/video_controls.dart b/lib/widgets/video_controls/video_controls.dart index e5c8fbdf..3b2efb3a 100644 --- a/lib/widgets/video_controls/video_controls.dart +++ b/lib/widgets/video_controls/video_controls.dart @@ -76,6 +76,7 @@ import 'widgets/mobile_skip_zones.dart'; import 'widgets/skip_marker_button.dart'; import 'widgets/track_chapter_controls.dart'; import 'widgets/performance_overlay/performance_overlay.dart'; +import '../rasterized_gradient.dart'; import 'mobile_video_controls.dart'; import 'desktop_video_controls.dart'; import 'package:provider/provider.dart'; @@ -804,24 +805,25 @@ class _PlexVideoControlsState extends State child: ValueListenableBuilder( valueListenable: widget.hasFirstFrame ?? _fallbackHasFirstFrame, builder: (context, hasFrame, child) { - return Container( - decoration: BoxDecoration( - // Use solid black when loading, gradient when loaded - color: hasFrame ? null : Colors.black, - gradient: hasFrame - ? LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.black.withValues(alpha: 0.7), - Colors.transparent, - Colors.transparent, - Colors.black.withValues(alpha: 0.7), - ], - stops: const [0.0, 0.2, 0.8, 1.0], - ) - : null, - ), + // Solid black while loading, scrim once frames flow. + // Both states share one widget type: hasFrame flips + // on every in-place episode switch / live-TV zap, and + // a runtimeType change here would re-inflate the whole + // controls subtree and drop its state. + return RasterizedGradient( + gradient: hasFrame + ? LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.black.withValues(alpha: 0.7), + Colors.transparent, + Colors.transparent, + Colors.black.withValues(alpha: 0.7), + ], + stops: const [0.0, 0.2, 0.8, 1.0], + ) + : const LinearGradient(colors: [Colors.black, Colors.black]), child: child, ); }, diff --git a/test/widgets/rasterized_gradient_test.dart b/test/widgets/rasterized_gradient_test.dart new file mode 100644 index 00000000..57568014 --- /dev/null +++ b/test/widgets/rasterized_gradient_test.dart @@ -0,0 +1,96 @@ +import 'dart:typed_data'; +import 'dart:ui' as ui; + +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/widgets/rasterized_gradient.dart'; + +/// The strip texture must composite identically to Skia's gradient shader. +/// Translucent non-black ramps are the regression case: rgba8888 strips are +/// consumed premultiplied, so straight-alpha baking renders them far too +/// bright (a white fade-to-transparent scrim becomes an opaque sheet). +void main() { + Future capture(WidgetTester tester, Key key) async { + final boundary = tester.renderObject(find.byKey(key)); + late ui.Image image; + await tester.runAsync(() async => image = await boundary.toImage()); + return image; + } + + Future> centerColumn(WidgetTester tester, ui.Image image) async { + late ByteData bytes; + await tester.runAsync(() async => bytes = (await image.toByteData())!); + final x = image.width ~/ 2; + final column = []; + for (var y = 0; y < image.height; y += 8) { + final offset = (y * image.width + x) * 4; + column.addAll([bytes.getUint8(offset), bytes.getUint8(offset + 1), bytes.getUint8(offset + 2)]); + } + return column; + } + + testWidgets('strip path matches the gradient shader over a colored background', (tester) async { + final gradient = LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white.withValues(alpha: 0.8), + Colors.white.withValues(alpha: 0.4), + Colors.white.withValues(alpha: 0.0), + ], + stops: const [0.0, 0.4, 1.0], + ); + + // Bake ahead of the build so RasterizedGradient paints the strip from + // its first frame instead of the (trivially identical) shader fallback. + late ui.Image? strip; + await tester.runAsync(() async => strip = await RasterizedGradient.prebake(gradient)); + expect(strip, isNotNull, reason: 'axis-aligned gradient must bake to a strip'); + + const rasterizedKey = Key('rasterized'); + const shaderKey = Key('shader'); + Widget sample(Key key, Widget gradientBox) => RepaintBoundary( + key: key, + child: SizedBox( + width: 64, + height: 256, + child: Stack( + fit: StackFit.expand, + children: [ + const ColoredBox(color: Color(0xFF406080)), + gradientBox, + ], + ), + ), + ); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Center( + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + sample(rasterizedKey, RasterizedGradient(gradient: gradient)), + sample(shaderKey, DecoratedBox(decoration: BoxDecoration(gradient: gradient))), + ], + ), + ), + ), + ); + + final rasterized = await centerColumn(tester, await capture(tester, rasterizedKey)); + final shader = await centerColumn(tester, await capture(tester, shaderKey)); + + for (var i = 0; i < shader.length; i++) { + // Skia dithers its gradient shader; the strip doesn't. Allow a couple + // of levels of noise — the premul bug is off by 50+ on this ramp. + expect( + (rasterized[i] - shader[i]).abs(), + lessThanOrEqualTo(3), + reason: 'channel ${i % 3} at sample ${i ~/ 3}: strip=${rasterized[i]} shader=${shader[i]}', + ); + } + }); +}