diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index a406d294..f5505b93 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -143,6 +143,9 @@ class MediaCard extends StatefulWidget { final EpisodePosterMode? episodePosterModeOverride; final bool fullBleedImage; + /// Paint-time black tint amount for the artwork, from 0 (clear) to 1 (black). + final Animation? artworkDim; + /// Overrides the silhouette inferred from the item itself. Collection and /// playlist records do not encode the media-library shape, so their owning /// surface supplies this for music libraries. @@ -169,6 +172,7 @@ class MediaCard extends StatefulWidget { this.showServerName = false, this.episodePosterModeOverride, this.fullBleedImage = false, + this.artworkDim, this.cardShapeOverride, }) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching; @@ -424,6 +428,7 @@ class MediaCardState extends State with ContextMenuTapMixin with ContextMenuTapMixin? artworkDim, }) { String? posterUrl; @@ -925,6 +932,7 @@ Widget _buildPosterImage( fallbackIcon: Symbols.playlist_play_rounded, imageType: ImageType.square, localFilePath: localPosterPath, + artworkDim: artworkDim, ); } @@ -936,6 +944,7 @@ Widget _buildPosterImage( fit: BoxFit.cover, placeholder: _buildPosterLoadingPlaceholder, localFilePath: localPosterPath, + artworkDim: artworkDim, ); } else if (item is MediaItem) { final EpisodePosterMode episodePosterMode = @@ -981,10 +990,12 @@ Widget _buildPosterImage( placeholder: _buildPosterLoadingPlaceholder, fallbackIcon: fallbackIcon, imageType: ImageType.square, + artworkDim: artworkDim, ); }, imageType: ImageType.square, localFilePath: localPosterPath, + artworkDim: artworkDim, ); } else if (imageType == ImageType.thumb) { // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) @@ -997,6 +1008,7 @@ Widget _buildPosterImage( placeholder: _buildPosterLoadingPlaceholder, fallbackIcon: fallbackIcon, localFilePath: localPosterPath, + artworkDim: artworkDim, ); } else { image = OptimizedMediaImage.poster( @@ -1019,9 +1031,11 @@ Widget _buildPosterImage( fit: BoxFit.cover, placeholder: _buildPosterLoadingPlaceholder, fallbackIcon: fallbackIcon, + artworkDim: artworkDim, ); }, localFilePath: localPosterPath, + artworkDim: artworkDim, ); } diff --git a/lib/widgets/optimized_media_image.dart b/lib/widgets/optimized_media_image.dart index fd6744ff..a6aa78b9 100644 --- a/lib/widgets/optimized_media_image.dart +++ b/lib/widgets/optimized_media_image.dart @@ -27,6 +27,17 @@ Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) { return clip ? ClipRect(child: filtered) : filtered; } +Widget _withArtworkDim(Animation? dim, Widget Function(Color? tint) builder) { + if (dim == null) return builder(null); + return AnimatedBuilder( + animation: dim, + builder: (context, _) { + final amount = dim.value.clamp(0.0, 1.0); + return builder(amount == 0 ? null : Colors.black.withValues(alpha: amount)); + }, + ); +} + class OptimizedMediaImage extends StatelessWidget { final MediaServerClient? client; final String? imagePath; @@ -45,6 +56,9 @@ class OptimizedMediaImage extends StatelessWidget { final String? localFilePath; final bool cacheMissingLocalFile; + /// Black tint applied at image paint time without an opacity save layer. + final Animation? artworkDim; + const OptimizedMediaImage._({ super.key, this.client, @@ -62,6 +76,7 @@ class OptimizedMediaImage extends StatelessWidget { this.fallbackIcon, this.imageType = ImageType.poster, this.localFilePath, + this.artworkDim, this.cacheMissingLocalFile = false, }); @@ -83,6 +98,7 @@ class OptimizedMediaImage extends StatelessWidget { IconData? fallbackIcon, ImageType imageType, String? localFilePath, + Animation? artworkDim, bool cacheMissingLocalFile, }) = OptimizedMediaImage._; @@ -103,6 +119,7 @@ class OptimizedMediaImage extends StatelessWidget { Alignment alignment = Alignment.center, IconData? fallbackIcon, String? localFilePath, + Animation? artworkDim, }) : this._( key: key, client: client, @@ -120,6 +137,7 @@ class OptimizedMediaImage extends StatelessWidget { fallbackIcon: fallbackIcon ?? Symbols.movie_rounded, imageType: ImageType.poster, localFilePath: localFilePath, + artworkDim: artworkDim, ); /// Named constructor for episode thumbnails. @@ -139,6 +157,7 @@ class OptimizedMediaImage extends StatelessWidget { Alignment alignment = Alignment.center, IconData? fallbackIcon, String? localFilePath, + Animation? artworkDim, }) : this._( key: key, client: client, @@ -156,6 +175,7 @@ class OptimizedMediaImage extends StatelessWidget { fallbackIcon: fallbackIcon ?? Symbols.video_library_rounded, imageType: ImageType.thumb, localFilePath: localFilePath, + artworkDim: artworkDim, ); /// Named constructor for playlist images. @@ -174,6 +194,7 @@ class OptimizedMediaImage extends StatelessWidget { String? cacheKey, Alignment alignment = Alignment.center, String? localFilePath, + Animation? artworkDim, }) : this._( key: key, client: client, @@ -191,6 +212,7 @@ class OptimizedMediaImage extends StatelessWidget { fallbackIcon: Symbols.playlist_play_rounded, imageType: ImageType.poster, localFilePath: localFilePath, + artworkDim: artworkDim, ); /// Whether both width and height are explicitly set to finite positive values, @@ -257,23 +279,28 @@ class OptimizedMediaImage extends StatelessWidget { imageType: imageType, ); - return Image( - image: MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight), - width: width, - height: height, - // Artwork is decorative: the enclosing card exposes one merged node - // with the title, and a per-image node just grows the semantics tree - // the TV a11y services make Flutter rebuild every frame. - excludeFromSemantics: true, - fit: fit, - filterQuality: filterQuality, - alignment: alignment, - errorBuilder: (context, error, stackTrace) { - if (errorWidget != null) { - return errorWidget!(context, file.path, error); - } - return _buildErrorWidget(context, error); - }, + return _withArtworkDim( + artworkDim, + (tint) => Image( + image: MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight), + width: width, + height: height, + // Artwork is decorative: the enclosing card exposes one merged node + // with the title, and a per-image node just grows the semantics tree + // the TV a11y services make Flutter rebuild every frame. + excludeFromSemantics: true, + fit: fit, + filterQuality: filterQuality, + alignment: alignment, + color: tint, + colorBlendMode: tint == null ? null : BlendMode.srcATop, + errorBuilder: (context, error, stackTrace) { + if (errorWidget != null) { + return errorWidget!(context, file.path, error); + } + return _buildErrorWidget(context, error); + }, + ), ); } @@ -330,20 +357,25 @@ class OptimizedMediaImage extends StatelessWidget { // 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 _withArtworkDim( + artworkDim, + (tint) => Image( + image: resizedProvider, + width: width, + height: height, + // Decorative — see the Image.file branch. + excludeFromSemantics: true, + fit: fit, + filterQuality: filterQuality, + alignment: alignment, + color: tint, + colorBlendMode: tint == null ? null : BlendMode.srcATop, + errorBuilder: _networkErrorBuilder(imageUrl), + frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { + if (wasSynchronouslyLoaded || frame != null) return child; + return _buildPlaceholder(context, imageUrl); + }, + ), ); } @@ -357,6 +389,7 @@ class OptimizedMediaImage extends StatelessWidget { duration: fadeInDuration, placeholderBuilder: (context) => _buildPlaceholder(context, imageUrl), errorBuilder: _networkErrorBuilder(imageUrl), + artworkDim: artworkDim, ); } @@ -378,19 +411,47 @@ class OptimizedMediaImage extends StatelessWidget { Widget _surfacePlaceholder(BuildContext context, {IconData? icon, Color? iconColor, bool fillParent = false}) { final theme = Theme.of(context).colorScheme; - return Container( - width: fillParent ? null : width, - height: fillParent ? null : height, - color: theme.surfaceContainerHighest, - child: icon == null - ? null - : Center(child: AppIcon(icon, fill: 1, size: 40, color: iconColor ?? theme.onSurfaceVariant)), + final baseSurfaceColor = theme.surfaceContainerHighest; + final baseIconColor = iconColor ?? theme.onSurfaceVariant; + return _withArtworkDim( + artworkDim, + (tint) => Container( + width: fillParent ? null : width, + height: fillParent ? null : height, + color: tint == null ? baseSurfaceColor : Color.alphaBlend(tint, baseSurfaceColor), + child: icon == null + ? null + : Center( + child: AppIcon( + icon, + fill: 1, + size: 40, + color: tint == null ? baseIconColor : Color.alphaBlend(tint, baseIconColor), + ), + ), + ), ); } Widget _buildPlaceholder(BuildContext context, String imageUrl) { - if (placeholder != null) return placeholder!(context, imageUrl); - return _surfacePlaceholder(context, icon: fallbackIcon, iconColor: Colors.white54); + final customPlaceholder = placeholder?.call(context, imageUrl); + if (customPlaceholder == null) { + return _surfacePlaceholder(context, icon: fallbackIcon, iconColor: Colors.white54); + } + if (artworkDim == null) return customPlaceholder; + return _withArtworkDim( + artworkDim, + (tint) => Stack( + fit: StackFit.passthrough, + children: [ + customPlaceholder, + if (tint != null) + Positioned.fill( + child: IgnorePointer(child: ColoredBox(color: tint)), + ), + ], + ), + ); } Widget _buildErrorWidget(BuildContext context, dynamic _) => _surfacePlaceholder( @@ -484,6 +545,7 @@ class _FadeInNetworkImage extends StatefulWidget { required this.duration, required this.placeholderBuilder, required this.errorBuilder, + required this.artworkDim, }); final ImageProvider image; @@ -495,6 +557,7 @@ class _FadeInNetworkImage extends StatefulWidget { final Duration duration; final WidgetBuilder placeholderBuilder; final ImageErrorWidgetBuilder errorBuilder; + final Animation? artworkDim; @override State<_FadeInNetworkImage> createState() => _FadeInNetworkImageState(); @@ -534,35 +597,40 @@ class _FadeInNetworkImageState extends State<_FadeInNetworkImage> with SingleTic @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], - ); - }, + return _withArtworkDim( + widget.artworkDim, + (tint) => Image( + image: widget.image, + width: widget.width, + height: widget.height, + // Decorative — see OptimizedMediaImage. + excludeFromSemantics: true, + fit: widget.fit, + filterQuality: widget.filterQuality, + alignment: widget.alignment, + color: tint, + colorBlendMode: tint == null ? null : BlendMode.srcATop, + 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( + fit: StackFit.passthrough, + alignment: Alignment.center, + children: [widget.placeholderBuilder(context), child], + ); + }, + ), ); } } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 208b8b8a..5f0c855e 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import '../media/ids.dart'; import 'dart:math' as math; import 'package:flutter/material.dart'; @@ -13,17 +12,18 @@ import '../focus/focus_theme.dart'; import '../focus/key_event_utils.dart'; import '../focus/locked_hub_controller.dart'; import '../i18n/strings.g.dart'; -import '../navigation/main_screen_scope.dart'; +import '../media/ids.dart'; import '../media/media_hub.dart'; import '../media/media_item.dart'; +import '../navigation/main_screen_scope.dart'; import '../screens/hub_detail_screen.dart'; import '../services/device_performance.dart'; import '../services/settings_service.dart'; import '../theme/mono_tokens.dart'; +import '../utils/layout_constants.dart'; import '../utils/media_image_helper.dart'; import '../utils/media_navigation_helper.dart'; import '../utils/provider_extensions.dart'; -import '../utils/layout_constants.dart'; import 'animated_dim_scrim.dart'; import 'app_icon.dart'; import 'clickable_cursor.dart'; @@ -35,6 +35,8 @@ import 'optimized_media_image.dart'; import 'rasterized_gradient.dart'; import 'settings_builder.dart'; +const _inactiveArtworkDimAlpha = 0.3; + class TvBrowseRailLayoutMetrics { final bool isPersonHub; final bool isMixedHub; @@ -379,14 +381,13 @@ class TvBrowseRail extends StatefulWidget { State createState() => TvBrowseRailState(); } -class TvBrowseRailState extends State { +class TvBrowseRailState extends State with TickerProviderStateMixin { static const _navigationScrollDuration = Duration(milliseconds: 130); static const _repeatNavigationScrollDuration = Duration(milliseconds: 65); static const _scrollCatchUpViewportDistance = 2.5; - // Dim strengths as scrim alphas (see AnimatedDimScrim): equivalent to the - // former whole-rail Opacity(0.6) and inactive-row Opacity(0.7) layers. + // Equivalent to the former whole-rail Opacity(0.6) without keeping a + // full-viewport saveLayer alive. static const _unfocusedRailDimAlpha = 0.4; - static const _inactiveHubDimAlpha = 0.3; final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); final Map _scrollControllers = {}; @@ -397,12 +398,13 @@ class TvBrowseRailState extends State { final Map _metricsByHub = {}; final Map _scaleByHub = {}; final Map _lastTrailingByHubKey = {}; + final Map _artworkDims = {}; int _hubIndex = 0; int _itemIndex = 0; /// Mirrors (_hubIndex, _itemIndex) plus the rail's focus state for the - /// per-card/header/dim selectors, so d-pad moves and focus flips repaint + /// per-card/header/artwork-dim selectors, so d-pad moves and focus flips repaint /// only the affected subtrees instead of setState-rebuilding every visible /// row (expensive on low-end TVs). final _RailFocusModel _focusModel = _RailFocusModel(); @@ -442,6 +444,13 @@ class TvBrowseRailState extends State { @override void didUpdateWidget(covariant TvBrowseRail oldWidget) { super.didUpdateWidget(oldWidget); + if (widget.hubs.length < oldWidget.hubs.length) { + _artworkDims.removeWhere((index, dim) { + if (index < widget.hubs.length) return false; + dim.dispose(); + return true; + }); + } final trailingStateChanged = _hasTrailingStateChanged(widget.hubs); final hubStateChanged = trailingStateChanged || !_hasSameHubState(oldWidget.hubs, widget.hubs); final initialSelectionChanged = @@ -556,6 +565,9 @@ class TvBrowseRailState extends State { _selectLongPress.dispose(); _focusNode.removeListener(_handleFocusChange); _focusNode.dispose(); + for (final dim in _artworkDims.values) { + dim.dispose(); + } _focusModel.dispose(); for (final controller in _scrollControllers.values) { controller.dispose(); @@ -570,7 +582,7 @@ class TvBrowseRailState extends State { if (!_focusNode.hasFocus) _resetLongPressState(); if (_focusNode.hasFocus) _notifyFocusedItem(); // No setState: rail focus is observed through _focusModel selectors - // (per-card focus wrappers, headers and the dim layer), so a focus flip + // (per-card focus wrappers, headers and artwork dim), so a focus flip // repaints only those subtrees instead of rebuilding every visible row. _focusModel.setRailFocus(_focusNode.hasFocus); } @@ -631,6 +643,11 @@ class TvBrowseRailState extends State { return true; } + _HubArtworkDim _artworkDimForHub(BuildContext context, int hubIndex) => _artworkDims.putIfAbsent( + hubIndex, + () => _HubArtworkDim(_focusModel, hubIndex, vsync: this, duration: FocusTheme.getAnimationDuration(context)), + ); + KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) { final key = event.logicalKey; @@ -725,7 +742,7 @@ class TvBrowseRailState extends State { final nextHub = widget.hubs[next]; final remembered = widget.focusMemory.getForHubOnly(_hubKey(nextHub), _totalItemCount(nextHub)); // No setState: the active-hub change is observed through _focusModel - // selectors (cards, headers, row dim), so a hub move repaints only the + // selectors (cards, headers, artwork dim), so a hub move repaints only the // two affected rows instead of rebuilding every visible card. Section // extents don't depend on the active hub, so no relayout is needed. _hubIndex = next; @@ -1163,7 +1180,7 @@ class TvBrowseRailState extends State { final metrics = metricsByHub[hubIndex]; final sectionHeight = sectionHeights[hubIndex]; // Active-hub state is observed through _focusModel so a hub move - // repaints only the two affected headers/dim layers; the row content + // repaints only the two affected headers/artwork tints; the row content // below is passed through as a stable child. bool isActiveHub() => _focusModel.hubIndex == hubIndex; @@ -1339,7 +1356,18 @@ class TvBrowseRailState extends State { _metricsByHub[_hubKey(hub)] = metrics; _scaleByHub[_hubKey(hub)] = scale; + final rail = _buildHubRailList( + hub: hub, + hubIndex: hubIndex, + episodePosterMode: episodePosterMode, + metrics: metrics, + scale: scale, + fullCardLayout: fullCardLayout, + scrollController: scrollController, + totalCount: totalCount, + ); final rightOverflow = metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap; + return Transform.translate( offset: Offset(-interactionExpansion, 0), child: SizedBox( @@ -1351,48 +1379,7 @@ class TvBrowseRailState extends State { rightOverflow: rightOverflow, verticalOverflow: metrics.focusExtra, ), - // Inactive-row dim: a scrim quad on top of the row instead of - // AnimatedOpacity, which would keep one saveLayer per visible row - // alive every frame (see AnimatedDimScrim). Sized to the clip - // region so overflow-painted card slivers dim too. - child: Stack( - clipBehavior: Clip.none, - children: [ - _buildHubRailList( - hub: hub, - hubIndex: hubIndex, - episodePosterMode: episodePosterMode, - metrics: metrics, - scale: scale, - fullCardLayout: fullCardLayout, - scrollController: scrollController, - totalCount: totalCount, - ), - Positioned.fill( - left: -leftOverflow, - right: -rightOverflow, - top: -metrics.focusExtra, - bottom: -metrics.focusExtra, - child: ListenableSelector( - listenable: _focusModel, - // Only stripe rows while the rail itself is focused: when - // it isn't, the full-width rail dim covers the band - // uniformly, and per-row scrims (clipped to the rail's - // footprint) would seam against it at the side-nav edge. - selector: () => _focusModel.railHasFocus && _focusModel.hubIndex != hubIndex, - builder: (context, dimmed, _) => AnimatedDimScrim( - dimmed: dimmed, - 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, - ), - ), - ), - ], - ), + child: rail, ), ), ); @@ -1476,31 +1463,17 @@ class TvBrowseRailState extends State { // MergeSemantics: one node per card (MediaCard merges // internally) — the per-frame semantics pass scales with // node count on TV boxes with an accessibility service. - child: metrics.isPersonHub - ? MergeSemantics( - child: _buildPersonCard( - context, - item, - cardWidth: metrics.cardWidth, - imageSize: metrics.posterHeight, - scale: scale, - fullCardLayout: fullCardLayout, - ), - ) - : MediaCard( - key: _cardKeyFor(hub, itemIndex), - item: item, - width: metrics.cardWidth, - height: metrics.posterHeight, - onRefresh: widget.onRefresh, - onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, - forceGridMode: true, - fullBleedImage: fullCardLayout, - isInContinueWatching: _isContinueWatchingHub(hub), - usesContinueWatchingAction: _usesContinueWatchingAction(hub), - mixedHubContext: metrics.isMixedHub, - episodePosterModeOverride: episodePosterMode, - ), + child: _buildHubCard( + context, + hub: hub, + hubIndex: hubIndex, + item: item, + itemIndex: itemIndex, + episodePosterMode: episodePosterMode, + metrics: metrics, + scale: scale, + fullCardLayout: fullCardLayout, + ), ); return Padding( @@ -1515,6 +1488,47 @@ class TvBrowseRailState extends State { ); } + Widget _buildHubCard( + BuildContext context, { + required MediaHub hub, + required int hubIndex, + required MediaItem item, + required int itemIndex, + required EpisodePosterMode episodePosterMode, + required TvBrowseRailLayoutMetrics metrics, + required double scale, + required bool fullCardLayout, + }) { + final artworkDim = _artworkDimForHub(context, hubIndex); + return metrics.isPersonHub + ? MergeSemantics( + child: _buildPersonCard( + context, + item, + cardWidth: metrics.cardWidth, + imageSize: metrics.posterHeight, + scale: scale, + fullCardLayout: fullCardLayout, + artworkDim: artworkDim, + ), + ) + : MediaCard( + key: _cardKeyFor(hub, itemIndex), + item: item, + width: metrics.cardWidth, + height: metrics.posterHeight, + onRefresh: widget.onRefresh, + onRemoveFromContinueWatching: widget.onRemoveFromContinueWatching, + forceGridMode: true, + fullBleedImage: fullCardLayout, + artworkDim: artworkDim, + isInContinueWatching: _isContinueWatchingHub(hub), + usesContinueWatchingAction: _usesContinueWatchingAction(hub), + mixedHubContext: metrics.isMixedHub, + episodePosterModeOverride: episodePosterMode, + ); + } + Widget _buildPersonCard( BuildContext context, MediaItem item, { @@ -1522,6 +1536,7 @@ class TvBrowseRailState extends State { required double imageSize, required double scale, required bool fullCardLayout, + required Animation? artworkDim, }) { final theme = Theme.of(context); final characterName = item.parentTitle; @@ -1545,6 +1560,7 @@ class TvBrowseRailState extends State { fit: BoxFit.cover, imageType: ImageType.square, fallbackIcon: Symbols.person_rounded, + artworkDim: artworkDim, ), RasterizedGradient( gradient: LinearGradient( @@ -1612,6 +1628,7 @@ class TvBrowseRailState extends State { fit: BoxFit.cover, imageType: ImageType.square, fallbackIcon: Symbols.person_rounded, + artworkDim: artworkDim, ), ), ), @@ -1852,7 +1869,7 @@ class _RailClipper extends CustomClipper { /// Hot rail focus state — (hubIndex, itemIndex) position and whether the rail /// itself holds focus — observed through [ListenableSelector]s so d-pad moves -/// and rail focus flips repaint only the affected cards/headers/dim scrims +/// and rail focus flips repaint only the affected cards/headers/dim effects /// instead of setState-rebuilding every visible row (expensive on low-end /// TVs). `notify: false` covers build-phase syncs (initState/didUpdateWidget), /// where notifying would call setState on descendants mid-build and the @@ -1878,3 +1895,58 @@ class _RailFocusModel extends ChangeNotifier { notifyListeners(); } } + +/// Paint-time dim animation shared by every artwork image in one hub. +/// +/// A single controller avoids one ticker per card while each image repaints +/// through its own render object, without a row-sized overlay or save layer. +class _HubArtworkDim extends Animation { + _HubArtworkDim(this._focusModel, this._hubIndex, {required TickerProvider vsync, required Duration duration}) + : _duration = duration { + _target = _resolveTarget(); + _controller = AnimationController(vsync: vsync, duration: duration, value: _target); + _focusModel.addListener(_handleFocusChange); + } + + final _RailFocusModel _focusModel; + final int _hubIndex; + final Duration _duration; + late final AnimationController _controller; + late double _target; + + double _resolveTarget() => _focusModel.railHasFocus && _focusModel.hubIndex != _hubIndex ? 1 : 0; + + void _handleFocusChange() { + final next = _resolveTarget(); + if (next == _target) return; + _target = next; + if (_duration == Duration.zero) { + _controller.value = next; + } else { + unawaited(_controller.animateTo(next, duration: _duration, curve: Curves.easeOutCubic)); + } + } + + @override + double get value => _controller.value * _inactiveArtworkDimAlpha; + + @override + AnimationStatus get status => _controller.status; + + @override + void addListener(VoidCallback listener) => _controller.addListener(listener); + + @override + void removeListener(VoidCallback listener) => _controller.removeListener(listener); + + @override + void addStatusListener(AnimationStatusListener listener) => _controller.addStatusListener(listener); + + @override + void removeStatusListener(AnimationStatusListener listener) => _controller.removeStatusListener(listener); + + void dispose() { + _focusModel.removeListener(_handleFocusChange); + _controller.dispose(); + } +} diff --git a/test/widgets/optimized_media_image_test.dart b/test/widgets/optimized_media_image_test.dart index e0bf2d2f..bf94a655 100644 --- a/test/widgets/optimized_media_image_test.dart +++ b/test/widgets/optimized_media_image_test.dart @@ -55,6 +55,62 @@ void main() { expect(widgetCached.maxHeight, isNull); }); + testWidgets('artwork dim tints image and fallback paint', (tester) async { + final artworkDim = AnimationController(vsync: tester); + addTearDown(artworkDim.dispose); + + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 160, + height: 90, + child: OptimizedMediaImage.thumb( + imagePath: 'https://example.invalid/dimmed-thumb.jpg', + width: 160, + height: 90, + artworkDim: artworkDim, + ), + ), + ), + ); + + expect(tester.widget(find.byType(Image)).color, isNull); + + artworkDim.value = 0.3; + await tester.pump(); + + final dimmedImage = tester.widget(find.byType(Image)); + expect(dimmedImage.color, Colors.black.withValues(alpha: 0.3)); + expect(dimmedImage.colorBlendMode, BlendMode.srcATop); + + artworkDim.value = 0; + await tester.pump(); + + final restoredImage = tester.widget(find.byType(Image)); + expect(restoredImage.color, isNull); + expect(restoredImage.colorBlendMode, isNull); + + await tester.pumpWidget( + MaterialApp( + home: SizedBox( + width: 160, + height: 90, + child: OptimizedMediaImage.thumb(imagePath: null, width: 160, height: 90, artworkDim: artworkDim), + ), + ), + ); + final placeholderFinder = find.descendant(of: find.byType(OptimizedMediaImage), matching: find.byType(Container)); + final baseColor = tester.widget(placeholderFinder).color!; + + artworkDim.value = 0.3; + await tester.pump(); + + expect( + tester.widget(placeholderFinder).color, + Color.alphaBlend(Colors.black.withValues(alpha: 0.3), baseColor), + ); + }); + testWidgets('failed image placeholders keep explicit dimensions in loose layouts', (tester) async { await tester.pumpWidget( MaterialApp( diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index fd1eac83..b4408505 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -987,7 +987,7 @@ void main() { expect(activations, 1); }); - testWidgets('inactive hub contents render at reduced opacity', (tester) async { + testWidgets('focused rail dims only inactive hub artwork', (tester) async { final serverManager = MultiServerManager(); final firstItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); final secondItem = testMediaItem( @@ -1026,11 +1026,25 @@ void main() { expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); - final scrims = tester.widgetList(find.byType(AnimatedDimScrim)); - expect( - scrims, - contains(predicate((scrim) => scrim.dimmed && scrim.alpha == 0.3, 'inactive hub dim scrim')), - ); + final scrims = tester.widgetList(find.byType(AnimatedDimScrim)).toList(); + expect(scrims, hasLength(1)); + expect(scrims.single.alpha, 0.4); + expect(scrims.single.dimmed, isFalse); + + List cards() => tester.widgetList(find.byType(MediaCard)).toList(); + MediaCard cardFor(MediaItem item) => cards().singleWhere((card) => card.item == item); + + final firstArtworkDim = cardFor(firstItem).artworkDim!; + final secondArtworkDim = cardFor(secondItem).artworkDim!; + expect(firstArtworkDim.value, 0); + expect(secondArtworkDim.value, closeTo(0.3, 0.001)); + + await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown); + await tester.pumpAndSettle(); + await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown); + + expect(firstArtworkDim.value, closeTo(0.3, 0.001)); + expect(secondArtworkDim.value, 0); }); testWidgets('selects preferred hub when hubs are inserted asynchronously', (tester) async {