perf(tv): tint inactive row artwork
This commit is contained in:
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user