fix(tv): symmetric full-card focus glow via overlay

close #1231
This commit is contained in:
edde746
2026-06-06 05:09:07 +02:00
parent 6b3e49d410
commit d90cc61541
7 changed files with 759 additions and 129 deletions
+165
View File
@@ -0,0 +1,165 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'focus_theme.dart';
/// Renders the focus glow for a focused card in the root [Overlay] so it paints
/// ABOVE sibling cards on all four sides.
///
/// The glow is an outward blur ([FocusTheme.focusGlowShadows]). When drawn
/// in-tree behind a packed rail/grid it is occluded by later-painted neighbours
/// on the trailing edges and only escapes on the leading (left) edge — producing
/// a one-sided halo (issue #1231). Lifting it into an [OverlayPortal] that
/// follows the card via [LayerLink] makes it render above every sibling, so the
/// glow is symmetric on all sides. The crisp focus border stays in-card; only
/// the glow moves to the overlay.
///
/// Only mounts the overlay/leader while the card is focused (or fading out), so
/// there is at most one [LeaderLayer] on screen regardless of how many cards a
/// grid builds.
class FocusGlowOverlay extends StatefulWidget {
const FocusGlowOverlay({
super.key,
required this.isFocused,
required this.borderRadius,
required this.color,
required this.child,
this.glowSize,
});
/// Whether the wrapped card currently shows focus. Drives the glow.
final bool isFocused;
/// Border radius of the card, used for the glow's rounded rect.
final double borderRadius;
/// Glow colour, resolved from the card's theme at the call site (the overlay
/// builds in the root Overlay's context, which may not carry a nested theme).
final Color color;
/// Explicit card size. When null, falls back to [LayerLink.leaderSize] (one
/// frame late on first show, hidden under the opacity-0 fade-in).
final Size? glowSize;
final Widget child;
@override
State<FocusGlowOverlay> createState() => _FocusGlowOverlayState();
}
class _FocusGlowOverlayState extends State<FocusGlowOverlay> {
final OverlayPortalController _controller = OverlayPortalController();
final LayerLink _link = LayerLink();
/// Drives the [AnimatedOpacity] target. Set false on focus loss so the glow
/// fades out before the portal is hidden in [_handleFadeEnd].
bool _visible = false;
@override
void initState() {
super.initState();
if (widget.isFocused) {
_visible = true;
_controller.show();
}
}
@override
void didUpdateWidget(FocusGlowOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isFocused == oldWidget.isFocused) return;
if (widget.isFocused) {
_controller.show();
// Start hidden, then fade in next frame.
_visible = false;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && widget.isFocused) setState(() => _visible = true);
});
} else {
// Fade out; _handleFadeEnd hides the portal once opacity reaches 0.
setState(() => _visible = false);
}
}
void _handleFadeEnd() {
if (!_visible && mounted && _controller.isShowing) {
_controller.hide();
setState(() {}); // drop the OverlayPortal/leader from the tree
}
}
@override
Widget build(BuildContext context) {
// Gate the LeaderLayer to the focused card only: when not focused and not
// mid-fade, return the bare child (no OverlayPortal, no leader).
if (!widget.isFocused && !_controller.isShowing) {
return widget.child;
}
final duration = FocusTheme.getAnimationDuration(context);
return OverlayPortal(
controller: _controller,
overlayChildBuilder: (overlayContext) {
final size = widget.glowSize ?? _link.leaderSize;
final extent = FocusTheme.focusGlowExtent;
return CompositedTransformFollower(
link: _link,
targetAnchor: Alignment.topLeft,
followerAnchor: Alignment.topLeft,
offset: Offset(-extent, -extent),
child: IgnorePointer(
child: AnimatedOpacity(
opacity: _visible ? 1.0 : 0.0,
duration: duration,
curve: Curves.easeOutCubic,
onEnd: _handleFadeEnd,
child: size == null
? const SizedBox.shrink()
: CustomPaint(
size: Size(size.width + extent * 2, size.height + extent * 2),
painter: _FocusGlowPainter(
rect: Offset(extent, extent) & size,
borderRadius: widget.borderRadius,
shadows: FocusTheme.focusGlowShadows(widget.color),
),
),
),
),
);
},
child: CompositedTransformTarget(link: _link, child: widget.child),
);
}
}
/// Paints [shadows] around [rect] with the rect interior clipped out, so only
/// the outer glow shows — matching the original look where the opaque card hid
/// the inner part of the shadow.
class _FocusGlowPainter extends CustomPainter {
const _FocusGlowPainter({required this.rect, required this.borderRadius, required this.shadows});
final Rect rect;
final double borderRadius;
final List<BoxShadow> shadows;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(borderRadius));
final clip = Path.combine(PathOperation.difference, Path()..addRect(Offset.zero & size), Path()..addRRect(rrect));
canvas.save();
canvas.clipPath(clip);
for (final shadow in shadows) {
canvas.drawRRect(rrect.shift(shadow.offset).inflate(shadow.spreadRadius), shadow.toPaint());
}
canvas.restore();
}
@override
bool shouldRepaint(_FocusGlowPainter oldDelegate) {
return oldDelegate.rect != rect ||
oldDelegate.borderRadius != borderRadius ||
!listEquals(oldDelegate.shadows, shadows);
}
}
+18 -22
View File
@@ -39,30 +39,26 @@ class FocusTheme {
);
}
static BoxDecoration focusGlowDecoration(
BuildContext context, {
required bool isFocused,
double borderRadius = defaultBorderRadius,
Color? color,
}) {
final focusColor = color ?? getFocusBorderColor(context);
return BoxDecoration(
borderRadius: BorderRadius.circular(borderRadius),
boxShadow: [
BoxShadow(
color: isFocused ? focusColor.withValues(alpha: 0.34) : Colors.transparent,
blurRadius: focusGlowInnerBlurRadius,
spreadRadius: focusGlowSpreadRadius,
),
BoxShadow(
color: isFocused ? focusColor.withValues(alpha: 0.2) : Colors.transparent,
blurRadius: focusGlowOuterBlurRadius,
),
],
);
/// The focus glow as a list of [BoxShadow]s.
///
/// Rendered by [FocusGlowOverlay] in the root overlay so the glow paints
/// above sibling cards on all four sides (an in-tree background shadow is
/// occluded by later-painted neighbours, which produced the one-sided halo).
static List<BoxShadow> focusGlowShadows(Color color) {
return [
BoxShadow(
color: color.withValues(alpha: 0.34),
blurRadius: focusGlowInnerBlurRadius,
spreadRadius: focusGlowSpreadRadius,
),
BoxShadow(color: color.withValues(alpha: 0.2), blurRadius: focusGlowOuterBlurRadius),
];
}
/// How far the focus glow visibly reaches beyond the card edge. Used to size
/// the overlay paint area so the blur is not clipped.
static double get focusGlowExtent => focusGlowOuterBlurRadius * 2 + focusGlowSpreadRadius;
/// Build focus decoration with background color instead of border.
/// Useful for video controls where it should match the native hover style.
static BoxDecoration focusBackgroundDecoration({required bool isFocused, double borderRadius = defaultBorderRadius}) {
+18 -18
View File
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
import '../widgets/clickable_cursor.dart';
import '../utils/text_input_diagnostics.dart';
import 'dpad_navigator.dart';
import 'focus_glow_overlay.dart';
import 'focus_theme.dart';
import 'input_mode_tracker.dart';
import 'key_event_utils.dart';
@@ -480,15 +481,6 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
color: widget.focusColor,
borderStrokeAlign: widget.focusBorderStrokeAlign,
);
final glowDecoration = widget.useFocusGlow
? FocusTheme.focusGlowDecoration(
context,
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor,
)
: null;
Widget result = Focus(
focusNode: _focusNode,
autofocus: widget.autofocus,
@@ -499,16 +491,24 @@ class _FocusableWrapperState extends State<FocusableWrapper> with SingleTickerPr
animation: _scaleAnimation,
builder: (context, child) {
final shouldScale = showFocus && !widget.disableScale;
return Transform.scale(
scale: shouldScale ? _scaleAnimation.value : 1.0,
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: widget.useForegroundFocusDecoration ? glowDecoration : focusDecoration,
foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null,
child: widget.child,
),
// The glow (full-bleed cards) is drawn in an overlay above siblings so
// it stays symmetric; the in-card decoration only carries the border.
Widget card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: widget.useForegroundFocusDecoration ? null : focusDecoration,
foregroundDecoration: widget.useForegroundFocusDecoration ? focusDecoration : null,
child: widget.child,
);
if (widget.useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
borderRadius: widget.borderRadius,
color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context),
child: card,
);
}
return Transform.scale(scale: shouldScale ? _scaleAnimation.value : 1.0, child: card);
},
),
);
+23 -10
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../focus/focus_glow_overlay.dart';
import '../focus/focus_theme.dart';
import '../focus/input_mode_tracker.dart';
import 'clickable_cursor.dart';
@@ -77,6 +78,7 @@ class FocusBuilders {
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
Size? glowSize,
required Widget child,
}) {
final isKeyboardMode = InputModeTracker.isKeyboardMode(context);
@@ -103,21 +105,30 @@ class FocusBuilders {
borderRadius: borderRadius,
borderStrokeAlign: focusBorderStrokeAlign,
);
final glowDecoration = useFocusGlow
? FocusTheme.focusGlowDecoration(context, isFocused: showFocus, borderRadius: borderRadius)
: null;
// Glow (full-bleed cards) renders in an overlay above siblings so it stays
// symmetric; the in-card decoration only carries the border.
Widget card = AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: useForegroundFocusDecoration ? null : focusDecoration,
foregroundDecoration: useForegroundFocusDecoration ? focusDecoration : null,
child: child,
);
if (useFocusGlow) {
card = FocusGlowOverlay(
isFocused: showFocus,
borderRadius: borderRadius,
color: FocusTheme.getFocusBorderColor(context),
glowSize: glowSize,
child: card,
);
}
final focusedWidget = AnimatedScale(
scale: showFocus ? focusScale : 1.0,
duration: duration,
curve: Curves.easeOutCubic,
child: AnimatedContainer(
duration: duration,
curve: Curves.easeOutCubic,
decoration: useForegroundFocusDecoration ? glowDecoration : focusDecoration,
foregroundDecoration: useForegroundFocusDecoration ? focusDecoration : null,
child: child,
),
child: card,
);
// Wrap in GestureDetector if tap/long press handlers provided
@@ -156,6 +167,7 @@ class FocusBuilders {
double focusBorderStrokeAlign = BorderSide.strokeAlignInside,
bool useFocusGlow = false,
bool useForegroundFocusDecoration = false,
Size? glowSize,
required Widget child,
}) {
return buildFocusableCard(
@@ -170,6 +182,7 @@ class FocusBuilders {
focusBorderStrokeAlign: focusBorderStrokeAlign,
useFocusGlow: useFocusGlow,
useForegroundFocusDecoration: useForegroundFocusDecoration,
glowSize: glowSize,
child: child,
);
}
+4 -23
View File
@@ -79,15 +79,6 @@ class TvBrowseRailLayout {
static double viewAllPillHeightForScale(double scale) => (44 * scale).clamp(36, 54).toDouble();
static double fullCardFocusPaintOverflowForScale(double scale) {
return (FocusTheme.focusGlowOuterBlurRadius +
FocusTheme.focusGlowSpreadRadius +
FocusTheme.focusBorderWidth +
(10 * scale))
.clamp(42, 64)
.toDouble();
}
static double hubStripHeightForScale(double scale) => 36 * scale;
static double hubStripGapForScale(double _) => 0;
@@ -979,9 +970,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
TvBrowseRailLayout.railTopPaddingForScale(scale) +
viewportHeight +
TvBrowseRailLayout.railBottomPaddingForScale(scale);
final paintOverflow = fullCardLayout && hasFocus
? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale)
: 0.0;
return Focus(
focusNode: _focusNode,
onKeyEvent: _handleKeyEvent,
@@ -1010,12 +998,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
duration: FocusTheme.getAnimationDuration(context),
curve: Curves.easeOutCubic,
child: ClipRect(
clipper: _RailClipper(
leftOverflow: horizontalInset,
rightOverflow: paintOverflow,
topOverflow: 0,
bottomOverflow: paintOverflow,
),
clipper: _RailClipper(leftOverflow: horizontalInset, rightOverflow: 0),
child: SizedBox(
height: viewportHeight,
child: _buildHubSectionList(
@@ -1164,9 +1147,6 @@ class TvBrowseRailState extends State<TvBrowseRail> {
final inactiveIndex = HubFocusMemory.getForHubOnly(hub.id, totalCount);
final focusedIndex = isActiveHub ? _itemIndex : inactiveIndex;
final scrollController = _scrollControllerForHub(hub, metrics, railViewportWidth, scale, focusedIndex);
final paintOverflow = fullCardLayout && hasFocus && isActiveHub
? TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale)
: 0.0;
_metricsByHub[hub.id] = metrics;
_scaleByHub[hub.id] = scale;
@@ -1178,8 +1158,8 @@ class TvBrowseRailState extends State<TvBrowseRail> {
child: ClipRect(
clipper: _RailClipper(
leftOverflow: leftOverflow,
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap + paintOverflow,
verticalOverflow: fullCardLayout ? math.max(metrics.focusExtra, paintOverflow) : metrics.focusExtra,
rightOverflow: metrics.railEdgePadding + metrics.cardWidth + metrics.itemGap,
verticalOverflow: metrics.focusExtra,
),
child: HorizontalScrollWithArrows(
controller: scrollController,
@@ -1217,6 +1197,7 @@ class TvBrowseRailState extends State<TvBrowseRail> {
focusBorderStrokeAlign: fullCardLayout ? BorderSide.strokeAlignOutside : BorderSide.strokeAlignInside,
useFocusGlow: fullCardLayout,
useForegroundFocusDecoration: fullCardLayout,
glowSize: fullCardLayout ? Size(metrics.cardWidth, metrics.posterHeight) : null,
onTap: () {
_selectHubItem(hub, hubIndex, itemIndex);
unawaited(_activateCurrentItem());
+92 -33
View File
@@ -1,5 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:plezy/focus/focus_glow_overlay.dart';
import 'package:plezy/focus/focus_theme.dart';
import 'package:plezy/focus/input_mode_tracker.dart';
import 'package:plezy/media/media_backend.dart';
import 'package:plezy/media/media_item.dart';
@@ -118,50 +120,107 @@ void main() {
expect(find.text('List Movie'), findsOneWidget);
});
testWidgets('full bleed focusable media card uses outside ring and local glow', (tester) async {
testWidgets('full bleed focusable media card lifts the glow into an overlay above siblings', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final focusNode = FocusNode(debugLabel: 'full_bleed_card');
addTearDown(focusNode.dispose);
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Focused Movie');
await tester.pumpWidget(
InputModeTracker(
child: _TestApp(
child: SizedBox(
width: 200,
height: 300,
child: FocusableMediaCard(
item: item,
forceGridMode: true,
fullBleedImage: true,
focusNode: focusNode,
isOffline: true,
),
),
),
),
);
await tester.pumpWidget(_fullCardHarness(focusNode: focusNode, fullBleed: true));
// Unfocused: the overlay glow (and its leader) is not mounted.
expect(find.byType(CompositedTransformTarget), findsNothing);
expect(find.byType(CompositedTransformFollower), findsNothing);
focusNode.requestFocus();
await tester.pump();
await tester.pump(); // focus change mounts the overlay portal + leader
await tester.pump(); // leaderSize resolves, glow fades in
await tester.pump(const Duration(milliseconds: 200));
final focusDecoration = find.byWidgetPredicate(
(widget) =>
widget is AnimatedContainer &&
widget.decoration is BoxDecoration &&
widget.foregroundDecoration is BoxDecoration,
// The glow now follows the card from the overlay, so it paints above siblings.
expect(find.byType(FocusGlowOverlay), findsOneWidget);
expect(find.byType(CompositedTransformTarget), findsOneWidget);
expect(find.byType(CompositedTransformFollower), findsOneWidget);
expect(
find.descendant(of: find.byType(CompositedTransformFollower), matching: find.byType(CustomPaint)),
findsOneWidget,
);
expect(focusDecoration, findsOneWidget);
final focusedContainer = tester.widget<AnimatedContainer>(focusDecoration);
final glowDecoration = focusedContainer.decoration as BoxDecoration;
final foregroundDecoration = focusedContainer.foregroundDecoration as BoxDecoration;
final border = foregroundDecoration.border as Border;
expect(glowDecoration.boxShadow, hasLength(2));
expect(glowDecoration.boxShadow!.first.color, isNot(Colors.transparent));
// The crisp focus border stays in-card as a foreground decoration; the glow
// is no longer in the background decoration.
final borderContainer = tester.widget<AnimatedContainer>(
find.descendant(of: find.byType(FocusGlowOverlay), matching: find.byType(AnimatedContainer)).first,
);
expect(borderContainer.decoration, isNull);
final border = (borderContainer.foregroundDecoration as BoxDecoration).border as Border;
expect(border.top.strokeAlign, BorderSide.strokeAlignOutside);
// The glow itself is two shadows.
expect(FocusTheme.focusGlowShadows(const Color(0xFFFFFFFF)), hasLength(2));
});
testWidgets('non full bleed card does not use the overlay glow', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(_fullCardHarness(focusNode: focusNode, fullBleed: false));
focusNode.requestFocus();
await tester.pump();
await tester.pump();
expect(find.byType(FocusGlowOverlay), findsNothing);
expect(find.byType(CompositedTransformFollower), findsNothing);
});
testWidgets('overlay glow fades out and unmounts when focus is lost', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(_fullCardHarness(focusNode: focusNode, fullBleed: true));
focusNode.requestFocus();
await tester.pump();
await tester.pump();
expect(find.byType(CompositedTransformFollower), findsOneWidget);
focusNode.unfocus();
await tester.pump(); // begin fade-out
await tester.pump(const Duration(milliseconds: 300)); // fade completes -> hide
await tester.pump(); // rebuild drops the gated-out leader/portal
expect(find.byType(CompositedTransformFollower), findsNothing);
});
testWidgets('touch mode shows no overlay glow on a full bleed card', (tester) async {
TvDetectionService.debugSetAppleTVOverride(false); // pointer mode
final focusNode = FocusNode();
addTearDown(focusNode.dispose);
await tester.pumpWidget(_fullCardHarness(focusNode: focusNode, fullBleed: true));
focusNode.requestFocus();
await tester.pump();
await tester.pump();
expect(find.byType(CompositedTransformFollower), findsNothing);
});
}
Widget _fullCardHarness({required FocusNode focusNode, required bool fullBleed}) {
final item = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Focused Movie');
return InputModeTracker(
child: _TestApp(
child: SizedBox(
width: 200,
height: 300,
child: FocusableMediaCard(
item: item,
forceGridMode: true,
fullBleedImage: fullBleed,
focusNode: focusNode,
isOffline: true,
),
),
),
);
}
class _TestApp extends StatelessWidget {
+439 -23
View File
@@ -178,6 +178,44 @@ void main() {
expect(compactHeight, lessThan(defaultHeight));
});
test('empty episode thumbnail hubs reserve thumbnail row height', () {
final episode = MediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode-thumb',
);
const emptyHub = MediaHub(id: 'detail_season_0', title: 'Season 1', type: 'episode', items: <MediaItem>[]);
final loadedHub = MediaHub(id: emptyHub.id, title: emptyHub.title, type: emptyHub.type, items: [episode]);
const size = Size(1280, 720);
final scale = TvBrowseRailLayout.scaleForSize(size);
final availableWidth = size.width - TvBrowseRailLayout.horizontalInsetForScale(scale);
final emptyMetrics = TvBrowseRailLayout.metricsForHub(
hub: emptyHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
final loadedMetrics = TvBrowseRailLayout.metricsForHub(
hub: loadedHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
tallPosterScale: TvBrowseRailLayout.compactTallPosterScale,
widePosterScale: TvBrowseRailLayout.compactEpisodeThumbnailScale,
);
expect(emptyMetrics.useWideLayout, isTrue);
expect(emptyMetrics.posterHeight, closeTo(loadedMetrics.posterHeight, 0.001));
expect(emptyMetrics.height, closeTo(loadedMetrics.height, 0.001));
});
test('full card layout removes label reserve and preserves episode poster mode', () {
final episode = MediaItem(
id: 'episode_1',
@@ -387,12 +425,6 @@ void main() {
);
await tester.pump();
final focusDecoration = find.byWidgetPredicate(
(widget) =>
widget is AnimatedContainer &&
widget.decoration is BoxDecoration &&
widget.foregroundDecoration is BoxDecoration,
);
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final metrics = TvBrowseRailLayout.metricsForHub(
hub: hub,
@@ -403,25 +435,32 @@ void main() {
fullCardLayout: true,
);
expect(focusDecoration, findsOneWidget);
final focusDecorationWidget = tester.widget<AnimatedContainer>(focusDecoration);
final glowDecoration = focusDecorationWidget.decoration as BoxDecoration;
final foregroundDecoration = focusDecorationWidget.foregroundDecoration as BoxDecoration;
final border = foregroundDecoration.border as Border;
final focusDecorationSize = tester.getSize(focusDecoration);
// The focused card mounts a leader (CompositedTransformTarget); scope to it
// so we measure the focused card's in-card border container.
final cardFinder = find
.descendant(of: find.byType(CompositedTransformTarget), matching: find.byType(AnimatedContainer))
.first;
final borderContainer = tester.widget<AnimatedContainer>(cardFinder);
final border = (borderContainer.foregroundDecoration as BoxDecoration).border as Border;
final cardSize = tester.getSize(cardFinder);
final focusScale = tester.widget<AnimatedScale>(
find.ancestor(of: focusDecoration, matching: find.byType(AnimatedScale)).first,
find.ancestor(of: cardFinder, matching: find.byType(AnimatedScale)).first,
);
// The border stays in-card; the glow now renders in an overlay that follows
// the focused card so it paints above siblings on all sides.
expect(borderContainer.decoration, isNull);
expect(border.top.strokeAlign, BorderSide.strokeAlignOutside);
expect(find.byType(ShaderMask), findsNothing);
expect(find.byType(CompositedTransformFollower), findsNothing);
expect(find.byType(CompositedTransformTarget), findsNothing);
expect(glowDecoration.boxShadow, hasLength(2));
expect(glowDecoration.boxShadow!.first.color, isNot(Colors.transparent));
expect(find.byType(CompositedTransformTarget), findsOneWidget);
expect(find.byType(CompositedTransformFollower), findsOneWidget);
expect(
find.descendant(of: find.byType(CompositedTransformFollower), matching: find.byType(CustomPaint)),
findsOneWidget,
);
expect(focusScale.scale, closeTo(1.03, 0.0001));
expect(focusDecorationSize.width, closeTo(metrics.cardWidth, 0.001));
expect(focusDecorationSize.height, closeTo(metrics.posterHeight, 0.001));
expect(cardSize.width, closeTo(metrics.cardWidth, 0.001));
expect(cardSize.height, closeTo(metrics.posterHeight, 0.001));
});
testWidgets('vertical hub viewport keeps top clipping while switching hubs', (tester) async {
@@ -470,7 +509,6 @@ void main() {
await tester.pump(const Duration(milliseconds: 16));
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final expectedPaintOverflow = TvBrowseRailLayout.fullCardFocusPaintOverflowForScale(scale);
final expectedLeftOverflow = TvBrowseRailLayout.horizontalInsetForScale(scale);
final verticalViewportClip = tester
.widgetList<ClipRect>(
@@ -480,15 +518,18 @@ void main() {
final clipRectSize = tester.getSize(find.byWidget(verticalViewportClip));
final clip = verticalViewportClip.clipper!.getClip(clipRectSize);
// The vertical viewport keeps a tight top/bottom clip (the glow is no longer
// clipped here — it renders in the overlay); only the left background bleed
// extends beyond the viewport.
expect(clip.left, closeTo(-expectedLeftOverflow, 0.001));
expect(clip.left, greaterThan(-expectedPaintOverflow));
expect(clip.top, 0);
expect(clip.bottom, greaterThanOrEqualTo(clipRectSize.height + expectedPaintOverflow));
expect(clip.bottom, closeTo(clipRectSize.height, 0.001));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
expect(find.byType(CompositedTransformFollower), findsNothing);
// The newly focused hub's card still carries the overlay glow.
expect(find.byType(CompositedTransformFollower), findsOneWidget);
});
testWidgets('detailed card layout can still show media text', (tester) async {
@@ -631,6 +672,123 @@ void main() {
expect(pillSize.height, closeTo(TvBrowseRailLayout.viewAllPillHeightForScale(scale), 0.001));
});
testWidgets('loading trailing item keeps visible focus style', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 2);
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
trailingForHub: (_) => TvRailTrailing.loading,
),
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump(const Duration(milliseconds: 150));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
final spinner = find.byType(CircularProgressIndicator);
final pill = find.ancestor(of: spinner, matching: find.byType(AnimatedContainer));
final decoration = tester.widget<AnimatedContainer>(pill).decoration as BoxDecoration;
expect(spinner, findsOneWidget);
expect(decoration.boxShadow, isNotNull);
});
testWidgets('clamps focused trailing item when trailing state disappears', (tester) async {
TvDetectionService.debugSetAppleTVOverride(true);
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
TvDetectionService.debugSetAppleTVOverride(null);
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final movie = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie');
final hub = MediaHub(id: 'movies', title: 'Movies', type: 'movie', items: [movie], size: 1);
var trailing = TvRailTrailing.loading;
var activations = 0;
late StateSetter setParentState;
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setState) {
setParentState = setState;
return SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
hubs: [hub],
autofocus: true,
iconForHub: (_, _) => Icons.movie_rounded,
trailingForHub: (_) => trailing,
onActivateItem: (_, _) {
activations++;
return Future.value(true);
},
),
);
},
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowRight);
await tester.pump(const Duration(milliseconds: 150));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowRight);
await tester.pump();
setParentState(() => trailing = TvRailTrailing.none);
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.enter);
await tester.pump();
await tester.sendKeyUpEvent(LogicalKeyboardKey.enter);
await tester.pump();
expect(activations, 1);
});
testWidgets('inactive hub contents render at reduced opacity', (tester) async {
final serverManager = MultiServerManager();
final firstItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
@@ -908,6 +1066,264 @@ void main() {
expect(verticalPosition.pixels, closeTo(expectedVerticalOffset, 0.1));
});
testWidgets('realigns active hub after preceding hub height changes', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
final serverManager = MultiServerManager();
final tallItem = MediaItem(id: 'movie_1', backend: MediaBackend.plex, kind: MediaKind.movie, title: 'Movie 1');
final wideItem = MediaItem(
id: 'episode_1',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 1',
thumbPath: '/episode_1',
);
final activeItem = MediaItem(
id: 'episode_2',
backend: MediaBackend.plex,
kind: MediaKind.episode,
title: 'Episode 2',
thumbPath: '/episode_2',
);
final firstHubTall = MediaHub(id: 'dynamic', title: 'Dynamic', type: 'movie', items: [tallItem], size: 1);
final firstHubWide = MediaHub(id: 'dynamic', title: 'Dynamic', type: 'episode', items: [wideItem], size: 1);
final activeHub = MediaHub(id: 'active', title: 'Active', type: 'episode', items: [activeItem], size: 1);
Widget buildRail(List<MediaHub> hubs) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
key: const ValueKey('rail'),
hubs: hubs,
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
);
}
await tester.pumpWidget(buildRail([firstHubTall, activeHub]));
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
await tester.pumpWidget(buildRail([firstHubWide, activeHub]));
await tester.pumpAndSettle();
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final availableWidth = 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final firstWideMetrics = TvBrowseRailLayout.metricsForHub(
hub: firstHubWide,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final expectedVerticalOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: firstWideMetrics.height,
);
expect(_verticalRailPosition(tester).pixels, closeTo(expectedVerticalOffset, 0.1));
});
testWidgets('does not realign active hub when a background hub updates', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
MediaItem episode(String id) {
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id');
}
final serverManager = MultiServerManager();
final firstHub = MediaHub(id: 'first', title: 'First', type: 'episode', items: [episode('episode_1')], size: 1);
final activeHub = MediaHub(id: 'active', title: 'Active', type: 'episode', items: [episode('episode_2')], size: 1);
final backgroundInitialHub = MediaHub(
id: 'background',
title: 'Background',
type: 'episode',
items: [episode('episode_3')],
size: 1,
);
final backgroundUpdatedHub = MediaHub(
id: backgroundInitialHub.id,
title: backgroundInitialHub.title,
type: backgroundInitialHub.type,
items: [episode('episode_3'), episode('episode_4')],
size: 2,
);
Widget buildRail({required bool backgroundLoaded}) {
return ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
key: const ValueKey('rail'),
hubs: [firstHub, activeHub, backgroundLoaded ? backgroundUpdatedHub : backgroundInitialHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
),
),
),
),
);
}
await tester.pumpWidget(buildRail(backgroundLoaded: false));
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
expect(_verticalRailPosition(tester).pixels, greaterThan(0));
_verticalRailPosition(tester).jumpTo(0);
await tester.pump();
await tester.pumpWidget(buildRail(backgroundLoaded: true));
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
expect(_verticalRailPosition(tester).pixels, 0);
});
testWidgets('keeps vertical navigation smooth when active hub updates during scroll', (tester) async {
tester.view.devicePixelRatio = 1.0;
tester.view.physicalSize = const Size(1280, 720);
addTearDown(() {
tester.view.resetDevicePixelRatio();
tester.view.resetPhysicalSize();
});
MediaItem episode(String id) {
return MediaItem(id: id, backend: MediaBackend.plex, kind: MediaKind.episode, title: id, thumbPath: '/$id');
}
final serverManager = MultiServerManager();
final firstHub = MediaHub(id: 'first', title: 'First', type: 'episode', items: [episode('episode_1')], size: 1);
final middleInitialHub = MediaHub(
id: 'middle',
title: 'Middle',
type: 'episode',
items: [episode('episode_2')],
size: 1,
);
final middleUpdatedHub = MediaHub(
id: middleInitialHub.id,
title: middleInitialHub.title,
type: middleInitialHub.type,
items: [episode('episode_2'), episode('episode_3')],
size: 2,
);
final lastHub = MediaHub(id: 'last', title: 'Last', type: 'episode', items: [episode('episode_4')], size: 1);
var updateMiddleOnFocus = false;
var middleLoaded = false;
await tester.pumpWidget(
ChangeNotifierProvider<MultiServerProvider>(
create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)),
child: MaterialApp(
theme: monoTheme(dark: true),
home: Scaffold(
body: StatefulBuilder(
builder: (context, setParentState) {
return SizedBox(
width: 1280,
height: 720,
child: TvBrowseRail(
hubs: [firstHub, middleLoaded ? middleUpdatedHub : middleInitialHub, lastHub],
autofocus: true,
iconForHub: (_, _) => Icons.tv_rounded,
episodePosterModeForHub: (_) => EpisodePosterMode.episodeThumbnail,
onActiveHubChanged: (hub, _) {
if (updateMiddleOnFocus && hub.id == middleInitialHub.id && !middleLoaded) {
setParentState(() => middleLoaded = true);
}
},
),
);
},
),
),
),
),
);
await tester.pump();
tester.state<TvBrowseRailState>(find.byType(TvBrowseRail)).requestFocus();
await tester.pump();
for (var i = 0; i < 2; i++) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowDown);
await tester.pumpAndSettle();
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowDown);
await tester.pump();
}
final scale = TvBrowseRailLayout.scaleForSize(tester.view.physicalSize / tester.view.devicePixelRatio);
final availableWidth = 1280 - TvBrowseRailLayout.horizontalInsetForScale(scale);
final firstMetrics = TvBrowseRailLayout.metricsForHub(
hub: firstHub,
availableWidth: availableWidth,
density: LibraryDensity.defaultValue,
episodePosterMode: EpisodePosterMode.episodeThumbnail,
scale: scale,
);
final middleTargetOffset = TvBrowseRailLayout.hubSectionHeightFor(
scale: scale,
activeRailHeight: firstMetrics.height,
);
final startOffset = _verticalRailPosition(tester).pixels;
expect(startOffset, greaterThan(middleTargetOffset));
updateMiddleOnFocus = true;
await tester.sendKeyDownEvent(LogicalKeyboardKey.arrowUp);
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 80));
await tester.pump(const Duration(milliseconds: 80));
expect(middleLoaded, isTrue);
final midAnimationOffset = _verticalRailPosition(tester).pixels;
expect(midAnimationOffset, greaterThan(middleTargetOffset + 0.5));
expect(midAnimationOffset, lessThan(startOffset - 0.5));
await tester.sendKeyUpEvent(LogicalKeyboardKey.arrowUp);
await tester.pumpAndSettle();
expect(_verticalRailPosition(tester).pixels, closeTo(middleTargetOffset, 0.1));
});
testWidgets('uses per-hub item focus instead of global column hint', (tester) async {
List<MediaItem> movieItems() => List.generate(
8,