diff --git a/lib/focus/focus_glow_overlay.dart b/lib/focus/focus_glow_overlay.dart new file mode 100644 index 00000000..9f609004 --- /dev/null +++ b/lib/focus/focus_glow_overlay.dart @@ -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 createState() => _FocusGlowOverlayState(); +} + +class _FocusGlowOverlayState extends State { + 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 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); + } +} diff --git a/lib/focus/focus_theme.dart b/lib/focus/focus_theme.dart index f378da1d..94500f0f 100644 --- a/lib/focus/focus_theme.dart +++ b/lib/focus/focus_theme.dart @@ -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 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}) { diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index bf9c85be..fd180520 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -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 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 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); }, ), ); diff --git a/lib/widgets/focus_builders.dart b/lib/widgets/focus_builders.dart index 848cdf59..58d81dea 100644 --- a/lib/widgets/focus_builders.dart +++ b/lib/widgets/focus_builders.dart @@ -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, ); } diff --git a/lib/widgets/tv_browse_rail.dart b/lib/widgets/tv_browse_rail.dart index 5a23ed12..6e404c11 100644 --- a/lib/widgets/tv_browse_rail.dart +++ b/lib/widgets/tv_browse_rail.dart @@ -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 { 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 { 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 { 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 { 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 { 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()); diff --git a/test/widgets/media_card_full_card_test.dart b/test/widgets/media_card_full_card_test.dart index 75e9d3b6..cbd0ecea 100644 --- a/test/widgets/media_card_full_card_test.dart +++ b/test/widgets/media_card_full_card_test.dart @@ -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(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( + 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 { diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index fe41bfa1..e745c213 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -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: []); + 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(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(cardFinder); + final border = (borderContainer.foregroundDecoration as BoxDecoration).border as Border; + final cardSize = tester.getSize(cardFinder); final focusScale = tester.widget( - 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( @@ -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( + 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(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(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( + 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(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 hubs) { + return ChangeNotifierProvider( + 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(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( + 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(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( + 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(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 movieItems() => List.generate( 8,