perf(tv): tint inactive row artwork
This commit is contained in:
@@ -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<double>? 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<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
cardShapeOverride: widget.cardShapeOverride,
|
||||
knownWidth: width,
|
||||
knownHeight: height,
|
||||
artworkDim: widget.artworkDim,
|
||||
),
|
||||
if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item),
|
||||
],
|
||||
@@ -460,6 +465,7 @@ class MediaCardState extends State<MediaCard> with ContextMenuTapMixin<MediaCard
|
||||
cardShapeOverride: widget.cardShapeOverride,
|
||||
knownWidth: posterHeight != null ? posterWidth : null,
|
||||
knownHeight: posterHeight,
|
||||
artworkDim: widget.artworkDim,
|
||||
),
|
||||
),
|
||||
if (item is MediaItem && _showsWatchedIndicator(item)) WatchedIndicator(item: item),
|
||||
@@ -908,6 +914,7 @@ Widget _buildPosterImage(
|
||||
CardShape? cardShapeOverride,
|
||||
double? knownWidth,
|
||||
double? knownHeight,
|
||||
Animation<double>? 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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,17 @@ Widget blurArtwork(Widget child, {double sigma = 30, bool clip = true}) {
|
||||
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 {
|
||||
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<double>? 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<double>? artworkDim,
|
||||
bool cacheMissingLocalFile,
|
||||
}) = OptimizedMediaImage._;
|
||||
|
||||
@@ -103,6 +119,7 @@ class OptimizedMediaImage extends StatelessWidget {
|
||||
Alignment alignment = Alignment.center,
|
||||
IconData? fallbackIcon,
|
||||
String? localFilePath,
|
||||
Animation<double>? 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<double>? 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<double>? 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<double>? 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],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+151
-79
@@ -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<TvBrowseRail> createState() => TvBrowseRailState();
|
||||
}
|
||||
|
||||
class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
class TvBrowseRailState extends State<TvBrowseRail> 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<String, ScrollController> _scrollControllers = {};
|
||||
@@ -397,12 +398,13 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
final Map<String, TvBrowseRailLayoutMetrics> _metricsByHub = {};
|
||||
final Map<String, double> _scaleByHub = {};
|
||||
final Map<String, TvRailTrailing> _lastTrailingByHubKey = {};
|
||||
final Map<int, _HubArtworkDim> _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<TvBrowseRail> {
|
||||
@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<TvBrowseRail> {
|
||||
_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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
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<TvBrowseRail> {
|
||||
_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<TvBrowseRail> {
|
||||
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<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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: rail,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1476,31 +1463,17 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
// 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<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(
|
||||
BuildContext context,
|
||||
MediaItem item, {
|
||||
@@ -1522,6 +1536,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
required double imageSize,
|
||||
required double scale,
|
||||
required bool fullCardLayout,
|
||||
required Animation<double>? artworkDim,
|
||||
}) {
|
||||
final theme = Theme.of(context);
|
||||
final characterName = item.parentTitle;
|
||||
@@ -1545,6 +1560,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
fit: BoxFit.cover,
|
||||
imageType: ImageType.square,
|
||||
fallbackIcon: Symbols.person_rounded,
|
||||
artworkDim: artworkDim,
|
||||
),
|
||||
RasterizedGradient(
|
||||
gradient: LinearGradient(
|
||||
@@ -1612,6 +1628,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
|
||||
fit: BoxFit.cover,
|
||||
imageType: ImageType.square,
|
||||
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
|
||||
/// 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<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);
|
||||
});
|
||||
|
||||
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 {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
|
||||
@@ -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<AnimatedDimScrim>(find.byType(AnimatedDimScrim));
|
||||
expect(
|
||||
scrims,
|
||||
contains(predicate<AnimatedDimScrim>((scrim) => scrim.dimmed && scrim.alpha == 0.3, 'inactive hub dim scrim')),
|
||||
);
|
||||
final scrims = tester.widgetList<AnimatedDimScrim>(find.byType(AnimatedDimScrim)).toList();
|
||||
expect(scrims, hasLength(1));
|
||||
expect(scrims.single.alpha, 0.4);
|
||||
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 {
|
||||
|
||||
Reference in New Issue
Block a user