@@ -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
@@ -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}) {
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user