From 868a1469a3e818d47dc4296b12d9a877c487971e Mon Sep 17 00:00:00 2001 From: edde746 <86283021+edde746@users.noreply.github.com> Date: Thu, 2 Jul 2026 00:37:54 +0200 Subject: [PATCH] perf: slim per-card build cost Memoize the per-image SHA-1 disk-cache key (ran on the UI thread on every image build), fade images in via paint alpha instead of an AnimatedSwitcher saveLayer per in-flight tile, create SettingsBuilder's merged listenable once instead of per build, and drop the pointer chrome (InkWell ink machinery, cursor MouseRegions) from cards on TV where no pointer exists. --- lib/widgets/clickable_cursor.dart | 5 + lib/widgets/media_card.dart | 64 +++++++-- lib/widgets/optimized_media_image.dart | 190 +++++++++++++++++++++---- lib/widgets/settings_builder.dart | 31 +++- 4 files changed, 245 insertions(+), 45 deletions(-) diff --git a/lib/widgets/clickable_cursor.dart b/lib/widgets/clickable_cursor.dart index 0308c496..1f5ed9ac 100644 --- a/lib/widgets/clickable_cursor.dart +++ b/lib/widgets/clickable_cursor.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; +import '../utils/platform_detector.dart'; + class ClickableCursor extends StatelessWidget { final Widget child; final bool enabled; @@ -8,6 +10,9 @@ class ClickableCursor extends StatelessWidget { @override Widget build(BuildContext context) { + // No pointer on TV: skip the MouseRegion entirely — one exists per card + // and they add up on low-end devices. + if (PlatformDetector.isTV()) return child; return MouseRegion(cursor: enabled ? SystemMouseCursors.click : MouseCursor.defer, child: child); } } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 5275d501..f0f4fa3c 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -19,6 +19,7 @@ import '../services/settings_service.dart'; import 'settings_builder.dart'; import 'watched_indicator.dart'; import '../utils/content_utils.dart'; +import '../utils/platform_detector.dart'; import '../utils/provider_extensions.dart'; import '../utils/formatters.dart'; import '../utils/media_navigation_helper.dart'; @@ -293,9 +294,7 @@ class MediaCardState extends State with ContextMenuTapMixin _handleTap(context, item), onTapDown: storeTapPosition, onLongPress: showContextMenuFromTap, @@ -359,9 +358,7 @@ class MediaCardState extends State with ContextMenuTapMixin _handleTap(context, item), onTapDown: storeTapPosition, onLongPress: showContextMenuFromTap, @@ -589,9 +586,7 @@ class _MediaCardList extends StatelessWidget { return CardFocusBorder( borderRadius: tokens(context).radiusSm, strokeAlign: BorderSide.strokeAlignInside, - child: InkWell( - mouseCursor: SystemMouseCursors.click, - canRequestFocus: false, // Keyboard handled by FocusableMediaCard + child: _CardTapRegion( onTap: onTap, onTapDown: onTapDown, onLongPress: onLongPress, @@ -980,3 +975,54 @@ class SkeletonLoader extends StatelessWidget { ); } } + +/// Tap surface for a card: a full [InkWell] (ripple, hover, cursor) where a +/// pointer exists, a bare [GestureDetector] on TV where the d-pad drives +/// activation and the per-card ink/hover/focus machinery is dead weight. +/// Keyboard focus is handled by the focus wrappers either way +/// (canRequestFocus stays false on the InkWell). +class _CardTapRegion extends StatelessWidget { + const _CardTapRegion({ + required this.onTap, + this.onTapDown, + this.onLongPress, + this.onSecondaryTap, + this.onSecondaryTapDown, + this.borderRadius, + required this.child, + }); + + final VoidCallback onTap; + final GestureTapDownCallback? onTapDown; + final VoidCallback? onLongPress; + final VoidCallback? onSecondaryTap; + final GestureTapDownCallback? onSecondaryTapDown; + final BorderRadius? borderRadius; + final Widget child; + + @override + Widget build(BuildContext context) { + if (PlatformDetector.isTV()) { + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onTapDown: onTapDown, + onLongPress: onLongPress, + onSecondaryTap: onSecondaryTap, + onSecondaryTapDown: onSecondaryTapDown, + child: child, + ); + } + return InkWell( + mouseCursor: SystemMouseCursors.click, + canRequestFocus: false, + onTap: onTap, + onTapDown: onTapDown, + onLongPress: onLongPress, + onSecondaryTapDown: onSecondaryTapDown, + onSecondaryTap: onSecondaryTap, + borderRadius: borderRadius, + child: child, + ); + } +} diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index e1be2567..642328c9 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -311,43 +311,56 @@ class OptimizedMediaImage extends StatelessWidget { headers: const {'User-Agent': 'Plezy'}, ); - return Image( - image: ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider), + final resizedProvider = ResizeImage.resizeIfNeeded(null, memHeight > 0 ? memHeight : null, provider); + + // Reduced tier: swap in directly, no fade machinery at all. + if (DevicePerformance.isReduced) { + return Image( + image: resizedProvider, + width: width, + height: height, + // Decorative — see the Image.file branch. + excludeFromSemantics: true, + fit: fit, + filterQuality: filterQuality, + alignment: alignment, + errorBuilder: _networkErrorBuilder(imageUrl), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return _buildPlaceholder(context, imageUrl); + }, + ); + } + + return _FadeInNetworkImage( + image: resizedProvider, width: width, height: height, - // Decorative — see the Image.file branch. - excludeFromSemantics: true, fit: fit, filterQuality: filterQuality, alignment: alignment, - errorBuilder: (context, error, stackTrace) { - _imageFailureCount++; - final now = DateTime.now(); - if (now.difference(_lastFailureLog) >= _logInterval) { - appLogger.w('Image load failed ($_imageFailureCount since last log): $error'); - _imageFailureCount = 0; - _lastFailureLog = now; - } - if (errorWidget != null) { - return errorWidget!(context, imageUrl, error); - } - return _buildErrorWidget(context, error); - }, - frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { - if (wasSynchronouslyLoaded) return child; - // Reduced tier: swap in directly — each in-flight fade is a tile-sized - // saveLayer, and grid scrolling runs many of them concurrently. - if (DevicePerformance.isReduced) { - return frame != null ? child : _buildPlaceholder(context, imageUrl); - } - return AnimatedSwitcher( - duration: const Duration(milliseconds: 300), - child: frame != null ? child : _buildPlaceholder(context, imageUrl), - ); - }, + duration: fadeInDuration, + placeholderBuilder: (context) => _buildPlaceholder(context, imageUrl), + errorBuilder: _networkErrorBuilder(imageUrl), ); } + ImageErrorWidgetBuilder _networkErrorBuilder(String imageUrl) { + return (context, error, stackTrace) { + _imageFailureCount++; + final now = DateTime.now(); + if (now.difference(_lastFailureLog) >= _logInterval) { + appLogger.w('Image load failed ($_imageFailureCount since last log): $error'); + _imageFailureCount = 0; + _lastFailureLog = now; + } + if (errorWidget != null) { + return errorWidget!(context, imageUrl, error); + } + return _buildErrorWidget(context, error); + }; + } + Widget _surfacePlaceholder(BuildContext context, {IconData? icon, Color? iconColor, bool fillParent = false}) { final theme = Theme.of(context).colorScheme; return Container( @@ -374,11 +387,128 @@ class OptimizedMediaImage extends StatelessWidget { Widget _buildFallback(BuildContext context) => _surfacePlaceholder(context, icon: fallbackIcon ?? Symbols.image_not_supported_rounded); + /// url → disk-cache key. The SHA-1 is not free on the UI thread and this + /// runs per image per build, so memoize it (bounded LRU, same eviction + /// style as MediaCard's failed-poster set). + static final _cacheKeyByUrl = {}; + static const _cacheKeyCap = 512; + String _generateCacheKey(String imageUrl) { + final cached = _cacheKeyByUrl.remove(imageUrl); + if (cached != null) { + _cacheKeyByUrl[imageUrl] = cached; // re-insert as most recently used + return cached; + } // URL already encodes bucketed transcode dimensions via roundDimensions, // so the URL hash alone uniquely identifies the bytes on disk. Including // mem-cache dimensions here would re-introduce churn on every pixel of // window resize and defeat getMemCacheDimensions' bucketing. - return 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}'; + final key = 'plex_optimized_${sha1.convert(utf8.encode(imageUrl))}'; + if (_cacheKeyByUrl.length >= _cacheKeyCap) { + _cacheKeyByUrl.remove(_cacheKeyByUrl.keys.first); + } + _cacheKeyByUrl[imageUrl] = key; + return key; + } +} + +/// Fades a network image in by animating the image paint's alpha +/// (`Image.opacity` → `RawImage`), not by wrapping it in an opacity widget: +/// a widget-opacity fade is a tile-sized saveLayer per in-flight image, and +/// grid scrolling runs many of them concurrently — a real GPU cost on the +/// weak GLES devices most Android TVs are. The opaque placeholder sits below +/// in a Stack until the fade completes, so the visual result matches the +/// AnimatedSwitcher cross-fade this replaces. +class _FadeInNetworkImage extends StatefulWidget { + const _FadeInNetworkImage({ + required this.image, + required this.width, + required this.height, + required this.fit, + required this.filterQuality, + required this.alignment, + required this.duration, + required this.placeholderBuilder, + required this.errorBuilder, + }); + + final ImageProvider image; + final double? width; + final double? height; + final BoxFit fit; + final FilterQuality filterQuality; + final Alignment alignment; + final Duration duration; + final WidgetBuilder placeholderBuilder; + final ImageErrorWidgetBuilder errorBuilder; + + @override + State<_FadeInNetworkImage> createState() => _FadeInNetworkImageState(); +} + +class _FadeInNetworkImageState extends State<_FadeInNetworkImage> with SingleTickerProviderStateMixin { + // Starts fully visible so synchronously-available (memory-cached) images + // paint immediately; dropped to 0 only once we know the load is async. + late final AnimationController _opacity = AnimationController(vsync: this, duration: widget.duration, value: 1); + bool _sawFirstFrame = false; + bool _placeholderVisible = false; + + @override + void didUpdateWidget(covariant _FadeInNetworkImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.image != oldWidget.image) { + // New stream: rearm so an async swap fades again (a memory-cached swap + // stays at full opacity via the wasSynchronouslyLoaded path). + _sawFirstFrame = false; + _placeholderVisible = false; + _opacity.value = 1; + } + } + + @override + void dispose() { + _opacity.dispose(); + super.dispose(); + } + + void _startFade() { + _sawFirstFrame = true; + _opacity.forward().whenComplete(() { + if (mounted && _placeholderVisible) setState(() => _placeholderVisible = false); + }); + } + + @override + Widget build(BuildContext context) { + return Image( + image: widget.image, + width: widget.width, + height: widget.height, + // Decorative — see OptimizedMediaImage. + excludeFromSemantics: true, + fit: widget.fit, + filterQuality: widget.filterQuality, + alignment: widget.alignment, + opacity: _opacity, + errorBuilder: widget.errorBuilder, + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded) return child; + if (frame == null && !_sawFirstFrame) { + // Async load in progress: hide the image and show the placeholder + // beneath until the first frame arrives. Mutating outside setState + // is fine here — we're inside build. + _opacity.value = 0; + _placeholderVisible = true; + } else if (frame != null && !_sawFirstFrame) { + _startFade(); + } + if (!_placeholderVisible) return child; + return Stack( + alignment: Alignment.center, + fit: StackFit.passthrough, + children: [widget.placeholderBuilder(context), child], + ); + }, + ); } } diff --git a/lib/widgets/settings_builder.dart b/lib/widgets/settings_builder.dart index 2a962e23..8a66aea8 100644 --- a/lib/widgets/settings_builder.dart +++ b/lib/widgets/settings_builder.dart @@ -1,3 +1,4 @@ +import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import '../services/settings_service.dart'; @@ -15,19 +16,37 @@ extension SettingsContextRead on BuildContext { /// depends on multiple settings (conditional visibility, derived values). /// Inside [builder], read with [SettingsService.read] directly — the rebuild /// is already wired through. -class SettingsBuilder extends StatelessWidget { +/// +/// Stateful so the merged listenable is created once, not per build: cards +/// rebuild this widget constantly and re-merging would unsubscribe/resubscribe +/// every pref listener each time. +class SettingsBuilder extends StatefulWidget { final List> prefs; final WidgetBuilder builder; const SettingsBuilder({super.key, required this.prefs, required this.builder}); @override - Widget build(BuildContext context) { + State createState() => _SettingsBuilderState(); +} + +class _SettingsBuilderState extends State { + late Listenable _merged = _merge(); + + Listenable _merge() { final svc = SettingsService.instance; - return ListenableBuilder( - listenable: Listenable.merge(prefs.map(svc.listenableOf).toList(growable: false)), - builder: (context, _) => builder(context), - ); + return Listenable.merge(widget.prefs.map(svc.listenableOf).toList(growable: false)); + } + + @override + void didUpdateWidget(covariant SettingsBuilder oldWidget) { + super.didUpdateWidget(oldWidget); + if (!listEquals(widget.prefs, oldWidget.prefs)) _merged = _merge(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder(listenable: _merged, builder: (context, _) => widget.builder(context)); } }