perf(tv): tint inactive row artwork

This commit is contained in:
edde746
2026-07-26 14:58:21 +02:00
parent 829d3745a1
commit f5661c766e
5 changed files with 378 additions and 154 deletions
+14
View File
@@ -143,6 +143,9 @@ class MediaCard extends StatefulWidget {
final EpisodePosterMode? episodePosterModeOverride; final EpisodePosterMode? episodePosterModeOverride;
final bool fullBleedImage; final bool fullBleedImage;
/// Paint-time black tint amount for the artwork, from 0 (clear) to 1 (black).
final Animation<double>? artworkDim;
/// Overrides the silhouette inferred from the item itself. Collection and /// Overrides the silhouette inferred from the item itself. Collection and
/// playlist records do not encode the media-library shape, so their owning /// playlist records do not encode the media-library shape, so their owning
/// surface supplies this for music libraries. /// surface supplies this for music libraries.
@@ -169,6 +172,7 @@ class MediaCard extends StatefulWidget {
this.showServerName = false, this.showServerName = false,
this.episodePosterModeOverride, this.episodePosterModeOverride,
this.fullBleedImage = false, this.fullBleedImage = false,
this.artworkDim,
this.cardShapeOverride, this.cardShapeOverride,
}) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching; }) : usesContinueWatchingAction = usesContinueWatchingAction ?? isInContinueWatching;
@@ -424,6 +428,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
cardShapeOverride: widget.cardShapeOverride, cardShapeOverride: widget.cardShapeOverride,
knownWidth: width, knownWidth: width,
knownHeight: height, knownHeight: height,
artworkDim: widget.artworkDim,
), ),
if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item), if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item),
], ],
@@ -460,6 +465,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
cardShapeOverride: widget.cardShapeOverride, cardShapeOverride: widget.cardShapeOverride,
knownWidth: posterHeight != null ? posterWidth : null, knownWidth: posterHeight != null ? posterWidth : null,
knownHeight: posterHeight, knownHeight: posterHeight,
artworkDim: widget.artworkDim,
), ),
), ),
if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item), if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item),
@@ -908,6 +914,7 @@ Widget _buildPosterImage(
CardShape? cardShapeOverride, CardShape? cardShapeOverride,
double? knownWidth, double? knownWidth,
double? knownHeight, double? knownHeight,
Animation<double>? artworkDim,
}) { }) {
String? posterUrl; String? posterUrl;
@@ -925,6 +932,7 @@ Widget _buildPosterImage(
fallbackIcon: Symbols.playlist_play_rounded, fallbackIcon: Symbols.playlist_play_rounded,
imageType: ImageType.square, imageType: ImageType.square,
localFilePath: localPosterPath, localFilePath: localPosterPath,
artworkDim: artworkDim,
); );
} }
@@ -936,6 +944,7 @@ Widget _buildPosterImage(
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder, placeholder: _buildPosterLoadingPlaceholder,
localFilePath: localPosterPath, localFilePath: localPosterPath,
artworkDim: artworkDim,
); );
} else if (item is MediaItem) { } else if (item is MediaItem) {
final EpisodePosterMode episodePosterMode = final EpisodePosterMode episodePosterMode =
@@ -981,10 +990,12 @@ Widget _buildPosterImage(
placeholder: _buildPosterLoadingPlaceholder, placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon, fallbackIcon: fallbackIcon,
imageType: ImageType.square, imageType: ImageType.square,
artworkDim: artworkDim,
); );
}, },
imageType: ImageType.square, imageType: ImageType.square,
localFilePath: localPosterPath, localFilePath: localPosterPath,
artworkDim: artworkDim,
); );
} else if (imageType == ImageType.thumb) { } else if (imageType == ImageType.thumb) {
// Use thumb image type for 16:9 content (episodes, or movies in mixed hubs) // Use thumb image type for 16:9 content (episodes, or movies in mixed hubs)
@@ -997,6 +1008,7 @@ Widget _buildPosterImage(
placeholder: _buildPosterLoadingPlaceholder, placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon, fallbackIcon: fallbackIcon,
localFilePath: localPosterPath, localFilePath: localPosterPath,
artworkDim: artworkDim,
); );
} else { } else {
image = OptimizedMediaImage.poster( image = OptimizedMediaImage.poster(
@@ -1019,9 +1031,11 @@ Widget _buildPosterImage(
fit: BoxFit.cover, fit: BoxFit.cover,
placeholder: _buildPosterLoadingPlaceholder, placeholder: _buildPosterLoadingPlaceholder,
fallbackIcon: fallbackIcon, fallbackIcon: fallbackIcon,
artworkDim: artworkDim,
); );
}, },
localFilePath: localPosterPath, localFilePath: localPosterPath,
artworkDim: artworkDim,
); );
} }
+76 -8
View File
@@ -27,6 +27,17 @@ Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) {
return clip ? ClipRect(child: filtered) : filtered; return clip ? ClipRect(child: filtered) : filtered;
} }
Widget _withArtworkDim(Animation<double>? 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 { class OptimizedMediaImage extends StatelessWidget {
final MediaServerClient? client; final MediaServerClient? client;
final String? imagePath; final String? imagePath;
@@ -45,6 +56,9 @@ class OptimizedMediaImage extends StatelessWidget {
final String? localFilePath; final String? localFilePath;
final bool cacheMissingLocalFile; final bool cacheMissingLocalFile;
/// Black tint applied at image paint time without an opacity save layer.
final Animation<double>? artworkDim;
const OptimizedMediaImage._({ const OptimizedMediaImage._({
super.key, super.key,
this.client, this.client,
@@ -62,6 +76,7 @@ class OptimizedMediaImage extends StatelessWidget {
this.fallbackIcon, this.fallbackIcon,
this.imageType = ImageType.poster, this.imageType = ImageType.poster,
this.localFilePath, this.localFilePath,
this.artworkDim,
this.cacheMissingLocalFile = false, this.cacheMissingLocalFile = false,
}); });
@@ -83,6 +98,7 @@ class OptimizedMediaImage extends StatelessWidget {
IconData? fallbackIcon, IconData? fallbackIcon,
ImageType imageType, ImageType imageType,
String? localFilePath, String? localFilePath,
Animation<double>? artworkDim,
bool cacheMissingLocalFile, bool cacheMissingLocalFile,
}) = OptimizedMediaImage._; }) = OptimizedMediaImage._;
@@ -103,6 +119,7 @@ class OptimizedMediaImage extends StatelessWidget {
Alignment alignment = Alignment.center, Alignment alignment = Alignment.center,
IconData? fallbackIcon, IconData? fallbackIcon,
String? localFilePath, String? localFilePath,
Animation<double>? artworkDim,
}) : this._( }) : this._(
key: key, key: key,
client: client, client: client,
@@ -120,6 +137,7 @@ class OptimizedMediaImage extends StatelessWidget {
fallbackIcon: fallbackIcon ?? Symbols.movie_rounded, fallbackIcon: fallbackIcon ?? Symbols.movie_rounded,
imageType: ImageType.poster, imageType: ImageType.poster,
localFilePath: localFilePath, localFilePath: localFilePath,
artworkDim: artworkDim,
); );
/// Named constructor for episode thumbnails. /// Named constructor for episode thumbnails.
@@ -139,6 +157,7 @@ class OptimizedMediaImage extends StatelessWidget {
Alignment alignment = Alignment.center, Alignment alignment = Alignment.center,
IconData? fallbackIcon, IconData? fallbackIcon,
String? localFilePath, String? localFilePath,
Animation<double>? artworkDim,
}) : this._( }) : this._(
key: key, key: key,
client: client, client: client,
@@ -156,6 +175,7 @@ class OptimizedMediaImage extends StatelessWidget {
fallbackIcon: fallbackIcon ?? Symbols.video_library_rounded, fallbackIcon: fallbackIcon ?? Symbols.video_library_rounded,
imageType: ImageType.thumb, imageType: ImageType.thumb,
localFilePath: localFilePath, localFilePath: localFilePath,
artworkDim: artworkDim,
); );
/// Named constructor for playlist images. /// Named constructor for playlist images.
@@ -174,6 +194,7 @@ class OptimizedMediaImage extends StatelessWidget {
String? cacheKey, String? cacheKey,
Alignment alignment = Alignment.center, Alignment alignment = Alignment.center,
String? localFilePath, String? localFilePath,
Animation<double>? artworkDim,
}) : this._( }) : this._(
key: key, key: key,
client: client, client: client,
@@ -191,6 +212,7 @@ class OptimizedMediaImage extends StatelessWidget {
fallbackIcon: Symbols.playlist_play_rounded, fallbackIcon: Symbols.playlist_play_rounded,
imageType: ImageType.poster, imageType: ImageType.poster,
localFilePath: localFilePath, localFilePath: localFilePath,
artworkDim: artworkDim,
); );
/// Whether both width and height are explicitly set to finite positive values, /// Whether both width and height are explicitly set to finite positive values,
@@ -257,7 +279,9 @@ class OptimizedMediaImage extends StatelessWidget {
imageType: imageType, imageType: imageType,
); );
return Image( return _withArtworkDim(
artworkDim,
(tint) => Image(
image: MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight), image: MediaImageHelper.boundedDecode(FileImage(file), memWidth: memWidth, memHeight: memHeight),
width: width, width: width,
height: height, height: height,
@@ -268,12 +292,15 @@ class OptimizedMediaImage extends StatelessWidget {
fit: fit, fit: fit,
filterQuality: filterQuality, filterQuality: filterQuality,
alignment: alignment, alignment: alignment,
color: tint,
colorBlendMode: tint == null ? null : BlendMode.srcATop,
errorBuilder: (context, error, stackTrace) { errorBuilder: (context, error, stackTrace) {
if (errorWidget != null) { if (errorWidget != null) {
return errorWidget!(context, file.path, error); return errorWidget!(context, file.path, error);
} }
return _buildErrorWidget(context, error); return _buildErrorWidget(context, error);
}, },
),
); );
} }
@@ -330,7 +357,9 @@ class OptimizedMediaImage extends StatelessWidget {
// Reduced tier: swap in directly, no fade machinery at all. // Reduced tier: swap in directly, no fade machinery at all.
if (DevicePerformance.isReduced) { if (DevicePerformance.isReduced) {
return Image( return _withArtworkDim(
artworkDim,
(tint) => Image(
image: resizedProvider, image: resizedProvider,
width: width, width: width,
height: height, height: height,
@@ -339,11 +368,14 @@ class OptimizedMediaImage extends StatelessWidget {
fit: fit, fit: fit,
filterQuality: filterQuality, filterQuality: filterQuality,
alignment: alignment, alignment: alignment,
color: tint,
colorBlendMode: tint == null ? null : BlendMode.srcATop,
errorBuilder: _networkErrorBuilder(imageUrl), errorBuilder: _networkErrorBuilder(imageUrl),
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) return child; if (wasSynchronouslyLoaded || frame != null) return child;
return _buildPlaceholder(context, imageUrl); return _buildPlaceholder(context, imageUrl);
}, },
),
); );
} }
@@ -357,6 +389,7 @@ class OptimizedMediaImage extends StatelessWidget {
duration: fadeInDuration, duration: fadeInDuration,
placeholderBuilder: (context) => _buildPlaceholder(context, imageUrl), placeholderBuilder: (context) => _buildPlaceholder(context, imageUrl),
errorBuilder: _networkErrorBuilder(imageUrl), errorBuilder: _networkErrorBuilder(imageUrl),
artworkDim: artworkDim,
); );
} }
@@ -378,20 +411,48 @@ class OptimizedMediaImage extends StatelessWidget {
Widget _surfacePlaceholder(BuildContext context, {IconData? icon, Color? iconColor, bool fillParent = false}) { Widget _surfacePlaceholder(BuildContext context, {IconData? icon, Color? iconColor, bool fillParent = false}) {
final theme = Theme.of(context).colorScheme; final theme = Theme.of(context).colorScheme;
return Container( final baseSurfaceColor = theme.surfaceContainerHighest;
final baseIconColor = iconColor ?? theme.onSurfaceVariant;
return _withArtworkDim(
artworkDim,
(tint) => Container(
width: fillParent ? null : width, width: fillParent ? null : width,
height: fillParent ? null : height, height: fillParent ? null : height,
color: theme.surfaceContainerHighest, color: tint == null ? baseSurfaceColor : Color.alphaBlend(tint, baseSurfaceColor),
child: icon == null child: icon == null
? null ? null
: Center(child: AppIcon(icon, fill: 1, size: 40, color: iconColor ?? theme.onSurfaceVariant)), : Center(
child: AppIcon(
icon,
fill: 1,
size: 40,
color: tint == null ? baseIconColor : Color.alphaBlend(tint, baseIconColor),
),
),
),
); );
} }
Widget _buildPlaceholder(BuildContext context, String imageUrl) { Widget _buildPlaceholder(BuildContext context, String imageUrl) {
if (placeholder != null) return placeholder!(context, imageUrl); final customPlaceholder = placeholder?.call(context, imageUrl);
if (customPlaceholder == null) {
return _surfacePlaceholder(context, icon: fallbackIcon, iconColor: Colors.white54); 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( Widget _buildErrorWidget(BuildContext context, dynamic _) => _surfacePlaceholder(
context, context,
@@ -484,6 +545,7 @@ class _FadeInNetworkImage extends StatefulWidget {
required this.duration, required this.duration,
required this.placeholderBuilder, required this.placeholderBuilder,
required this.errorBuilder, required this.errorBuilder,
required this.artworkDim,
}); });
final ImageProvider image; final ImageProvider image;
@@ -495,6 +557,7 @@ class _FadeInNetworkImage extends StatefulWidget {
final Duration duration; final Duration duration;
final WidgetBuilder placeholderBuilder; final WidgetBuilder placeholderBuilder;
final ImageErrorWidgetBuilder errorBuilder; final ImageErrorWidgetBuilder errorBuilder;
final Animation<double>? artworkDim;
@override @override
State<_FadeInNetworkImage> createState() => _FadeInNetworkImageState(); State<_FadeInNetworkImage> createState() => _FadeInNetworkImageState();
@@ -534,7 +597,9 @@ class _FadeInNetworkImageState extends State<_FadeInNetworkImage> with SingleTic
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Image( return _withArtworkDim(
widget.artworkDim,
(tint) => Image(
image: widget.image, image: widget.image,
width: widget.width, width: widget.width,
height: widget.height, height: widget.height,
@@ -543,6 +608,8 @@ class _FadeInNetworkImageState extends State<_FadeInNetworkImage> with SingleTic
fit: widget.fit, fit: widget.fit,
filterQuality: widget.filterQuality, filterQuality: widget.filterQuality,
alignment: widget.alignment, alignment: widget.alignment,
color: tint,
colorBlendMode: tint == null ? null : BlendMode.srcATop,
opacity: _opacity, opacity: _opacity,
errorBuilder: widget.errorBuilder, errorBuilder: widget.errorBuilder,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
@@ -558,11 +625,12 @@ class _FadeInNetworkImageState extends State<_FadeInNetworkImage> with SingleTic
} }
if (!_placeholderVisible) return child; if (!_placeholderVisible) return child;
return Stack( return Stack(
alignment: Alignment.center,
fit: StackFit.passthrough, fit: StackFit.passthrough,
alignment: Alignment.center,
children: [widget.placeholderBuilder(context), child], children: [widget.placeholderBuilder(context), child],
); );
}, },
),
); );
} }
} }
+147 -75
View File
@@ -1,5 +1,4 @@
import 'dart:async'; import 'dart:async';
import '../media/ids.dart';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
@@ -13,17 +12,18 @@ import '../focus/focus_theme.dart';
import '../focus/key_event_utils.dart'; import '../focus/key_event_utils.dart';
import '../focus/locked_hub_controller.dart'; import '../focus/locked_hub_controller.dart';
import '../i18n/strings.g.dart'; import '../i18n/strings.g.dart';
import '../navigation/main_screen_scope.dart'; import '../media/ids.dart';
import '../media/media_hub.dart'; import '../media/media_hub.dart';
import '../media/media_item.dart'; import '../media/media_item.dart';
import '../navigation/main_screen_scope.dart';
import '../screens/hub_detail_screen.dart'; import '../screens/hub_detail_screen.dart';
import '../services/device_performance.dart'; import '../services/device_performance.dart';
import '../services/settings_service.dart'; import '../services/settings_service.dart';
import '../theme/mono_tokens.dart'; import '../theme/mono_tokens.dart';
import '../utils/layout_constants.dart';
import '../utils/media_image_helper.dart'; import '../utils/media_image_helper.dart';
import '../utils/media_navigation_helper.dart'; import '../utils/media_navigation_helper.dart';
import '../utils/provider_extensions.dart'; import '../utils/provider_extensions.dart';
import '../utils/layout_constants.dart';
import 'animated_dim_scrim.dart'; import 'animated_dim_scrim.dart';
import 'app_icon.dart'; import 'app_icon.dart';
import 'clickable_cursor.dart'; import 'clickable_cursor.dart';
@@ -35,6 +35,8 @@ import 'optimized_media_image.dart';
import 'rasterized_gradient.dart'; import 'rasterized_gradient.dart';
import 'settings_builder.dart'; import 'settings_builder.dart';
const _inactiveArtworkDimAlpha = 0.3;
class TvBrowseRailLayoutMetrics { class TvBrowseRailLayoutMetrics {
final bool isPersonHub; final bool isPersonHub;
final bool isMixedHub; final bool isMixedHub;
@@ -379,14 +381,13 @@ class TvBrowseRail extends StatefulWidget {
State<TvBrowseRail> createState() => TvBrowseRailState(); State<TvBrowseRail> createState() => TvBrowseRailState();
} }
class TvBrowseRailState extends State<TvBrowseRail> { class TvBrowseRailState extends State<TvBrowseRail> with TickerProviderStateMixin {
static const _navigationScrollDuration = Duration(milliseconds: 130); static const _navigationScrollDuration = Duration(milliseconds: 130);
static const _repeatNavigationScrollDuration = Duration(milliseconds: 65); static const _repeatNavigationScrollDuration = Duration(milliseconds: 65);
static const _scrollCatchUpViewportDistance = 2.5; static const _scrollCatchUpViewportDistance = 2.5;
// Dim strengths as scrim alphas (see AnimatedDimScrim): equivalent to the // Equivalent to the former whole-rail Opacity(0.6) without keeping a
// former whole-rail Opacity(0.6) and inactive-row Opacity(0.7) layers. // full-viewport saveLayer alive.
static const _unfocusedRailDimAlpha = 0.4; static const _unfocusedRailDimAlpha = 0.4;
static const _inactiveHubDimAlpha = 0.3;
final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail');
final Map<String, ScrollController> _scrollControllers = {}; final Map<String, ScrollController> _scrollControllers = {};
@@ -397,12 +398,13 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final Map<String, TvBrowseRailLayoutMetrics> _metricsByHub = {}; final Map<String, TvBrowseRailLayoutMetrics> _metricsByHub = {};
final Map<String, double> _scaleByHub = {}; final Map<String, double> _scaleByHub = {};
final Map<String, TvRailTrailing> _lastTrailingByHubKey = {}; final Map<String, TvRailTrailing> _lastTrailingByHubKey = {};
final Map<int, _HubArtworkDim> _artworkDims = {};
int _hubIndex = 0; int _hubIndex = 0;
int _itemIndex = 0; int _itemIndex = 0;
/// Mirrors (_hubIndex, _itemIndex) plus the rail's focus state for the /// 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 /// only the affected subtrees instead of setState-rebuilding every visible
/// row (expensive on low-end TVs). /// row (expensive on low-end TVs).
final _RailFocusModel _focusModel = _RailFocusModel(); final _RailFocusModel _focusModel = _RailFocusModel();
@@ -442,6 +444,13 @@ class TvBrowseRailState extends State<TvBrowseRail> {
@override @override
void didUpdateWidget(covariant TvBrowseRail oldWidget) { void didUpdateWidget(covariant TvBrowseRail oldWidget) {
super.didUpdateWidget(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 trailingStateChanged = _hasTrailingStateChanged(widget.hubs);
final hubStateChanged = trailingStateChanged || !_hasSameHubState(oldWidget.hubs, widget.hubs); final hubStateChanged = trailingStateChanged || !_hasSameHubState(oldWidget.hubs, widget.hubs);
final initialSelectionChanged = final initialSelectionChanged =
@@ -556,6 +565,9 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_selectLongPress.dispose(); _selectLongPress.dispose();
_focusNode.removeListener(_handleFocusChange); _focusNode.removeListener(_handleFocusChange);
_focusNode.dispose(); _focusNode.dispose();
for (final dim in _artworkDims.values) {
dim.dispose();
}
_focusModel.dispose(); _focusModel.dispose();
for (final controller in _scrollControllers.values) { for (final controller in _scrollControllers.values) {
controller.dispose(); controller.dispose();
@@ -570,7 +582,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
if (!_focusNode.hasFocus) _resetLongPressState(); if (!_focusNode.hasFocus) _resetLongPressState();
if (_focusNode.hasFocus) _notifyFocusedItem(); if (_focusNode.hasFocus) _notifyFocusedItem();
// No setState: rail focus is observed through _focusModel selectors // 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. // repaints only those subtrees instead of rebuilding every visible row.
_focusModel.setRailFocus(_focusNode.hasFocus); _focusModel.setRailFocus(_focusNode.hasFocus);
} }
@@ -631,6 +643,11 @@ class TvBrowseRailState extends State<TvBrowseRail> {
return true; 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) { KeyEventResult _handleKeyEvent(FocusNode _, KeyEvent event) {
final key = event.logicalKey; final key = event.logicalKey;
@@ -725,7 +742,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final nextHub = widget.hubs[next]; final nextHub = widget.hubs[next];
final remembered = widget.focusMemory.getForHubOnly(_hubKey(nextHub), _totalItemCount(nextHub)); final remembered = widget.focusMemory.getForHubOnly(_hubKey(nextHub), _totalItemCount(nextHub));
// No setState: the active-hub change is observed through _focusModel // 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 // two affected rows instead of rebuilding every visible card. Section
// extents don't depend on the active hub, so no relayout is needed. // extents don't depend on the active hub, so no relayout is needed.
_hubIndex = next; _hubIndex = next;
@@ -1163,7 +1180,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final metrics = metricsByHub[hubIndex]; final metrics = metricsByHub[hubIndex];
final sectionHeight = sectionHeights[hubIndex]; final sectionHeight = sectionHeights[hubIndex];
// Active-hub state is observed through _focusModel so a hub move // 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. // below is passed through as a stable child.
bool isActiveHub() => _focusModel.hubIndex == hubIndex; bool isActiveHub() => _focusModel.hubIndex == hubIndex;
@@ -1339,7 +1356,18 @@ class TvBrowseRailState extends State<TvBrowseRail> {
_metricsByHub[_hubKey(hub)] = metrics; _metricsByHub[_hubKey(hub)] = metrics;
_scaleByHub[_hubKey(hub)] = scale; _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; final rightOverflow = metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap;
return Transform.translate( return Transform.translate(
offset: Offset(-interactionExpansion, 0), offset: Offset(-interactionExpansion, 0),
child: SizedBox( child: SizedBox(
@@ -1351,48 +1379,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
rightOverflow: rightOverflow, rightOverflow: rightOverflow,
verticalOverflow: metrics.focusExtra, verticalOverflow: metrics.focusExtra,
), ),
// Inactive-row dim: a scrim quad on top of the row instead of child: rail,
// 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<bool>(
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,
),
),
),
],
),
), ),
), ),
); );
@@ -1476,31 +1463,17 @@ class TvBrowseRailState extends State<TvBrowseRail> {
// MergeSemantics: one node per card (MediaCard merges // MergeSemantics: one node per card (MediaCard merges
// internally) — the per-frame semantics pass scales with // internally) — the per-frame semantics pass scales with
// node count on TV boxes with an accessibility service. // node count on TV boxes with an accessibility service.
child: metrics.isPersonHub child: _buildHubCard(
? MergeSemantics(
child: _buildPersonCard(
context, context,
item, hub: hub,
cardWidth: metrics.cardWidth, hubIndex: hubIndex,
imageSize: metrics.posterHeight, item: item,
itemIndex: itemIndex,
episodePosterMode: episodePosterMode,
metrics: metrics,
scale: scale, scale: scale,
fullCardLayout: fullCardLayout, 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,
),
); );
return Padding( return Padding(
@@ -1515,6 +1488,47 @@ class TvBrowseRailState extends State<TvBrowseRail> {
); );
} }
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( Widget _buildPersonCard(
BuildContext context, BuildContext context,
MediaItem item, { MediaItem item, {
@@ -1522,6 +1536,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
required double imageSize, required double imageSize,
required double scale, required double scale,
required bool fullCardLayout, required bool fullCardLayout,
required Animation<double>? artworkDim,
}) { }) {
final theme = Theme.of(context); final theme = Theme.of(context);
final characterName = item.parentTitle; final characterName = item.parentTitle;
@@ -1545,6 +1560,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
fit: BoxFit.cover, fit: BoxFit.cover,
imageType: ImageType.square, imageType: ImageType.square,
fallbackIcon: Symbols.person_rounded, fallbackIcon: Symbols.person_rounded,
artworkDim: artworkDim,
), ),
RasterizedGradient( RasterizedGradient(
gradient: LinearGradient( gradient: LinearGradient(
@@ -1612,6 +1628,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
fit: BoxFit.cover, fit: BoxFit.cover,
imageType: ImageType.square, imageType: ImageType.square,
fallbackIcon: Symbols.person_rounded, fallbackIcon: Symbols.person_rounded,
artworkDim: artworkDim,
), ),
), ),
), ),
@@ -1852,7 +1869,7 @@ class _RailClipper extends CustomClipper<Rect> {
/// Hot rail focus state — (hubIndex, itemIndex) position and whether the rail /// Hot rail focus state — (hubIndex, itemIndex) position and whether the rail
/// itself holds focus — observed through [ListenableSelector]s so d-pad moves /// 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 /// instead of setState-rebuilding every visible row (expensive on low-end
/// TVs). `notify: false` covers build-phase syncs (initState/didUpdateWidget), /// TVs). `notify: false` covers build-phase syncs (initState/didUpdateWidget),
/// where notifying would call setState on descendants mid-build and the /// where notifying would call setState on descendants mid-build and the
@@ -1878,3 +1895,58 @@ class _RailFocusModel extends ChangeNotifier {
notifyListeners(); 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<double> {
_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();
}
}
@@ -55,6 +55,62 @@ void main() {
expect(widgetCached.maxHeight, isNull); 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<Image>(find.byType(Image)).color, isNull);
artworkDim.value = 0.3;
await tester.pump();
final dimmedImage = tester.widget<Image>(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<Image>(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<Container>(placeholderFinder).color!;
artworkDim.value = 0.3;
await tester.pump();
expect(
tester.widget<Container>(placeholderFinder).color,
Color.alphaBlend(Colors.black.withValues(alpha: 0.3), baseColor),
);
});
testWidgets('failed image placeholders keep explicit dimensions in loose layouts', (tester) async { testWidgets('failed image placeholders keep explicit dimensions in loose layouts', (tester) async {
await tester.pumpWidget( await tester.pumpWidget(
MaterialApp( MaterialApp(
+20 -6
View File
@@ -987,7 +987,7 @@ void main() {
expect(activations, 1); 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 serverManager = MultiServerManager();
final firstItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1'); final firstItem = testMediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
final secondItem = testMediaItem( final secondItem = testMediaItem(
@@ -1026,11 +1026,25 @@ void main() {
expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail'); expect(FocusManager.instance.primaryFocus?.debugLabel, 'tv_browse_rail');
final scrims = tester.widgetList<AnimatedDimScrim>(find.byType(AnimatedDimScrim)); final scrims = tester.widgetList<AnimatedDimScrim>(find.byType(AnimatedDimScrim)).toList();
expect( expect(scrims, hasLength(1));
scrims, expect(scrims.single.alpha, 0.4);
contains(predicate<AnimatedDimScrim>((scrim) => scrim.dimmed && scrim.alpha == 0.3, 'inactive hub dim scrim')), expect(scrims.single.dimmed, isFalse);
);
List<MediaCard> cards() => tester.widgetList<MediaCard>(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 { testWidgets('selects preferred hub when hubs are inserted asynchronously', (tester) async {