diff --git a/lib/focus/focusable_wrapper.dart b/lib/focus/focusable_wrapper.dart index c107295a..88050c25 100644 --- a/lib/focus/focusable_wrapper.dart +++ b/lib/focus/focusable_wrapper.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:flutter/rendering.dart'; import '../widgets/clickable_cursor.dart'; import '../utils/text_input_diagnostics.dart'; @@ -21,6 +22,62 @@ void _logFocusableWrapper(String message) { TextInputDiagnostics.log('FocusableWrapper', message); } +/// Applies a visual scale without changing hit-test or semantics geometry. +/// +/// Focus scale is paint-only: animating a [Transform] marks the transformed +/// subtree's semantics dirty on every frame, which is costly for dense TV +/// grids. Keeping layout and semantics static preserves the same visible +/// motion without rebuilding the accessibility tree. +class _PaintScale extends SingleChildRenderObjectWidget { + const _PaintScale({required this.scale, required super.child}); + + final double scale; + + @override + RenderObject createRenderObject(BuildContext context) => _RenderPaintScale(scale); + + @override + void updateRenderObject(BuildContext context, _RenderPaintScale renderObject) { + renderObject.scale = scale; + } +} + +class _RenderPaintScale extends RenderProxyBox { + _RenderPaintScale(double scale) : _scale = scale; + + final Matrix4 _transform = Matrix4.identity(); + double _scale; + + set scale(double value) { + if (_scale == value) return; + _scale = value; + markNeedsPaint(); + } + + @override + void paint(PaintingContext context, Offset offset) { + if (child == null) return; + if (_scale == 1) { + layer = null; + super.paint(context, offset); + return; + } + + _transform + ..setIdentity() + ..setEntry(0, 0, _scale) + ..setEntry(1, 1, _scale) + ..setTranslationRaw((1 - _scale) * size.width / 2, (1 - _scale) * size.height / 2, 0); + layer = context.pushTransform( + needsCompositing, + offset, + _transform, + super.paint, + oldLayer: layer is TransformLayer ? layer as TransformLayer? : null, + ); + } +} + /// A wrapper widget that makes its child focusable with D-pad navigation support. /// /// Provides: @@ -475,46 +532,46 @@ class _FocusableWrapperState extends State with SingleTickerPr controller.duration = duration; } + final shouldScale = showFocus && !widget.disableScale; + // Keep the card subtree outside the scale builder. Rebuilding media-card + // semantics on every animation tick is substantially more expensive than + // changing the paint transform alone on dense TV grids. + Widget card; + if (widget.delegateFocusBorder) { + card = CardFocusScope(showFocus: showFocus, child: widget.child); + } else { + final focusDecoration = widget.useBackgroundFocus + ? FocusTheme.focusBackgroundDecoration( + isFocused: showFocus, + borderRadius: widget.borderRadius, + radii: widget.borderRadii, + ) + : FocusTheme.focusDecoration( + context, + isFocused: showFocus, + borderRadius: widget.borderRadius, + radii: widget.borderRadii, + color: widget.focusColor, + ); + card = AnimatedContainer( + duration: duration, + curve: Curves.easeOutCubic, + decoration: focusDecoration, + child: widget.child, + ); + } + if (widget.useFocusGlow) { + card = FocusGlowOverlay( + isFocused: showFocus, + borderRadius: widget.borderRadius, + color: widget.focusColor ?? FocusTheme.getFocusBorderColor(context), + child: card, + ); + } inner = AnimatedBuilder( animation: _scaleAnimation!, - builder: (context, child) { - final shouldScale = showFocus && !widget.disableScale; - // 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; - if (widget.delegateFocusBorder) { - card = CardFocusScope(showFocus: showFocus, child: widget.child); - } else { - final focusDecoration = widget.useBackgroundFocus - ? FocusTheme.focusBackgroundDecoration( - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - ) - : FocusTheme.focusDecoration( - context, - isFocused: showFocus, - borderRadius: widget.borderRadius, - radii: widget.borderRadii, - color: widget.focusColor, - ); - card = AnimatedContainer( - duration: duration, - curve: Curves.easeOutCubic, - decoration: focusDecoration, - 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); - }, + child: card, + builder: (context, child) => _PaintScale(scale: shouldScale ? _scaleAnimation!.value : 1.0, child: child!), ); } diff --git a/lib/widgets/media_card.dart b/lib/widgets/media_card.dart index 8274395a..a406d294 100644 --- a/lib/widgets/media_card.dart +++ b/lib/widgets/media_card.dart @@ -303,7 +303,9 @@ class MediaCardState extends State with ContextMenuTapMixin { final FocusNode _focusNode = FocusNode(debugLabel: 'tv_browse_rail'); final Map _scrollControllers = {}; final ScrollController _verticalController = ScrollController(); + final SnapshotController _verticalScrollSnapshotController = SnapshotController(); final Map _hubSectionKeys = {}; final Map> _mediaCardKeys = {}; final Map _metricsByHub = {}; @@ -429,6 +431,7 @@ class TvBrowseRailState extends State { VoidCallback? _gestureSignalListener; bool _suppressSelectUntilKeyUp = false; bool _hasUserChangedHub = false; + int _verticalScrollGeneration = 0; bool _hasUserChangedItem = false; MediaHub? get _activeHub => widget.hubs.isEmpty ? null : widget.hubs[_hubIndex.clamp(0, widget.hubs.length - 1)]; @@ -610,6 +613,8 @@ class TvBrowseRailState extends State { controller.dispose(); } _verticalController.dispose(); + _verticalScrollGeneration++; + _verticalScrollSnapshotController.dispose(); super.dispose(); } @@ -797,14 +802,16 @@ class TvBrowseRailState extends State { if (_verticalController.hasClients && _hubIndex >= 0 && _hubIndex < _sectionOffsets.length) { final target = _sectionOffsets[_hubIndex].clamp(0.0, _sectionMaxScrollExtent).toDouble(); if (animate) { - unawaited( - _verticalController.animateTo( + _startVerticalScrollAnimation( + () => _verticalController.animateTo( target, duration: const Duration(milliseconds: 250), curve: Curves.easeOutCubic, ), ); } else { + _verticalScrollGeneration++; + _verticalScrollSnapshotController.allowSnapshotting = false; _verticalController.jumpTo(target); } return; @@ -813,18 +820,39 @@ class TvBrowseRailState extends State { final key = _hubSectionKeys[_hubIndex]; final context = key?.currentContext; if (context == null) return; - - unawaited( - Scrollable.ensureVisible( - context, - alignment: 0, - duration: animate ? const Duration(milliseconds: 250) : Duration.zero, - curve: Curves.easeOutCubic, - ), - ); + if (animate) { + _startVerticalScrollAnimation( + () => Scrollable.ensureVisible( + context, + alignment: 0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOutCubic, + ), + ); + } else { + _verticalScrollGeneration++; + _verticalScrollSnapshotController.allowSnapshotting = false; + unawaited(Scrollable.ensureVisible(context, alignment: 0, duration: Duration.zero)); + } }); } + void _startVerticalScrollAnimation(Future Function() animate) { + final generation = ++_verticalScrollGeneration; + // Full-tier row effects must stay live. The reduced tier has already + // resolved those short effects to their final state before this snapshot. + final useSnapshots = DevicePerformance.isReduced; + if (useSnapshots) { + _verticalScrollSnapshotController.allowSnapshotting = true; + } + unawaited( + animate().whenComplete(() { + if (!mounted || generation != _verticalScrollGeneration) return; + _verticalScrollSnapshotController.allowSnapshotting = false; + }), + ); + } + void _setHoveredItem(MediaHub hub, int index) { final active = _activeHub; if (active == null || _hubKey(active) != _hubKey(hub) || index >= hub.items.length || _itemIndex == index) { @@ -1195,31 +1223,36 @@ class TvBrowseRailState extends State { // below is passed through as a stable child. bool isActiveHub() => _focusModel.hubIndex == hubIndex; - return SizedBox( - key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()), - height: sectionHeight, - child: Column( - crossAxisAlignment: .stretch, - children: [ - ListenableSelector( - listenable: _focusModel, - selector: isActiveHub, - builder: (context, isActive, _) => - _buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale), - ), - SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)), - _buildHubRail( - hub: hub, - hubIndex: hubIndex, - episodePosterMode: modes[hubIndex], - metrics: metrics, - scale: scale, - fullCardLayout: fullCardLayout, - leftOverflow: leftOverflow, - interactionExpansion: interactionExpansion, - railViewportWidth: railViewportWidth, - ), - ], + return SnapshotWidget( + controller: _verticalScrollSnapshotController, + mode: SnapshotMode.permissive, + autoresize: true, + child: SizedBox( + key: _hubSectionKeys.putIfAbsent(hubIndex, () => GlobalKey()), + height: sectionHeight, + child: Column( + crossAxisAlignment: .stretch, + children: [ + ListenableSelector( + listenable: _focusModel, + selector: isActiveHub, + builder: (context, isActive, _) => + _buildHubHeader(context, hub: hub, hubIndex: hubIndex, isActive: isActive, scale: scale), + ), + SizedBox(height: TvBrowseRailLayout.hubStripGapForScale(scale)), + _buildHubRail( + hub: hub, + hubIndex: hubIndex, + episodePosterMode: modes[hubIndex], + metrics: metrics, + scale: scale, + fullCardLayout: fullCardLayout, + leftOverflow: leftOverflow, + interactionExpansion: interactionExpansion, + railViewportWidth: railViewportWidth, + ), + ], + ), ), ); }, diff --git a/test/focus/focusable_wrapper_test.dart b/test/focus/focusable_wrapper_test.dart index 10a22fd7..60ca133b 100644 --- a/test/focus/focusable_wrapper_test.dart +++ b/test/focus/focusable_wrapper_test.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:plezy/focus/card_focus_scope.dart'; import 'package:plezy/focus/focusable_wrapper.dart'; import 'package:plezy/focus/input_mode_tracker.dart'; @@ -25,7 +26,7 @@ void main() { await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); await tester.pump(); - expect(chromeIn(Transform), findsOneWidget); + expect(chromeIn(AnimatedBuilder), findsOneWidget); expect(chromeIn(AnimatedContainer), findsOneWidget); }); @@ -78,4 +79,46 @@ void main() { expect(longPressed, 1); expect(selected, 1); }); + + testWidgets('focus scale animation keeps child semantics geometry stable', (tester) async { + final semantics = tester.ensureSemantics(); + final node = FocusNode(debugLabel: 'card'); + addTearDown(node.dispose); + + await tester.pumpWidget( + InputModeTracker( + child: MaterialApp( + home: Scaffold( + body: Center( + child: FocusableWrapper( + focusNode: node, + focusScale: 1.2, + delegateFocusBorder: true, + child: CardFocusBorder( + child: Semantics(label: 'card content', child: SizedBox(width: 100, height: 100)), + ), + ), + ), + ), + ), + ), + ); + await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown); + node.unfocus(); + await tester.pumpAndSettle(); + node.requestFocus(); + await tester.pump(); + final semanticsOwner = tester.binding.rootPipelineOwner.semanticsOwner!; + var semanticsUpdates = 0; + void countSemanticsUpdate() => semanticsUpdates++; + semanticsOwner.addListener(countSemanticsUpdate); + + await tester.pump(const Duration(milliseconds: 16)); + semanticsUpdates = 0; + await tester.pump(const Duration(milliseconds: 16)); + + expect(semanticsUpdates, 0); + semanticsOwner.removeListener(countSemanticsUpdate); + semantics.dispose(); + }); } diff --git a/test/widgets/media_card_full_card_test.dart b/test/widgets/media_card_full_card_test.dart index a1137f84..906a84f4 100644 --- a/test/widgets/media_card_full_card_test.dart +++ b/test/widgets/media_card_full_card_test.dart @@ -316,6 +316,50 @@ void main() { semantics.dispose(); }); + testWidgets('TV cards collapse pointer-only detail semantics without a screen reader', (tester) async { + final semantics = tester.ensureSemantics(); + TvDetectionService.debugSetAppleTVOverride(true); + final item = testMediaItem( + id: 'tv_semantic_movie', + kind: MediaKind.movie, + title: 'TV Semantic Movie', + summary: 'TV decorative summary', + ); + + await tester.pumpWidget( + _TestApp( + child: SizedBox(width: 200, height: 330, child: MediaCard(item: item, forceGridMode: true, isOffline: true)), + ), + ); + + final card = tester.getSemantics(find.bySemanticsLabel(mediaCardSemanticLabel(item))).getSemanticsData(); + expect(card.flagsCollection.isButton, isTrue); + expect(card.hasAction(ui.SemanticsAction.tap), isTrue); + expect(find.bySemanticsLabel('TV Semantic Movie'), findsNothing); + expect(find.bySemanticsLabel(RegExp('TV decorative summary')), findsNothing); + semantics.dispose(); + }); + + testWidgets('TV cards preserve detail semantics for accessible navigation', (tester) async { + final semantics = tester.ensureSemantics(); + TvDetectionService.debugSetAppleTVOverride(true); + final item = testMediaItem(id: 'tv_accessible_movie', kind: MediaKind.movie, title: 'Accessible TV Movie'); + + await tester.pumpWidget( + _TestApp( + child: MediaQuery( + data: const MediaQueryData(accessibleNavigation: true), + child: SizedBox(width: 200, height: 330, child: MediaCard(item: item, forceGridMode: true, isOffline: true)), + ), + ), + ); + + final detail = tester.getSemantics(find.bySemanticsLabel('Accessible TV Movie')).getSemanticsData(); + expect(detail.flagsCollection.isButton, isTrue); + expect(detail.hasAction(ui.SemanticsAction.tap), isTrue); + semantics.dispose(); + }); + testWidgets('custom card actions keep detail-link semantics disabled in grid and list modes', (tester) async { final semantics = tester.ensureSemantics(); final item = testMediaItem( diff --git a/test/widgets/tv_browse_rail_test.dart b/test/widgets/tv_browse_rail_test.dart index 3a2702be..00901b5d 100644 --- a/test/widgets/tv_browse_rail_test.dart +++ b/test/widgets/tv_browse_rail_test.dart @@ -11,6 +11,7 @@ import 'package:plezy/media/media_item.dart'; import 'package:plezy/media/media_kind.dart'; import 'package:plezy/providers/multi_server_provider.dart'; import 'package:plezy/services/data_aggregation_service.dart'; +import 'package:plezy/services/device_performance.dart'; import 'package:plezy/services/multi_server_manager.dart'; import 'package:plezy/services/settings_service.dart'; import 'package:plezy/theme/mono_theme.dart'; @@ -396,6 +397,69 @@ void main() { semantics.dispose(); }); + testWidgets('low-end snapshot optimization preserves vertical scroll animation', (tester) async { + DevicePerformance.debugReset(autoReduced: true); + addTearDown(DevicePerformance.debugReset); + tester.view.devicePixelRatio = 1; + tester.view.physicalSize = const Size(1280, 720); + addTearDown(tester.view.resetDevicePixelRatio); + addTearDown(tester.view.resetPhysicalSize); + + final serverManager = MultiServerManager(); + final hubs = List.generate(6, (hubIndex) { + final item = testMediaItem( + id: 'movie_$hubIndex', + backend: MediaBackend.plex, + kind: MediaKind.movie, + title: 'Movie $hubIndex', + ); + return MediaHub(id: 'hub_$hubIndex', title: 'Hub $hubIndex', type: 'movie', items: [item], size: 1); + }); + + await tester.pumpWidget( + ChangeNotifierProvider( + create: (_) => MultiServerProvider(serverManager, DataAggregationService(serverManager)), + child: InputModeTracker( + child: MaterialApp( + theme: monoTheme(dark: true), + home: Scaffold( + body: SizedBox( + width: 1280, + height: 720, + child: TvBrowseRail( + focusMemory: focusMemory, + hubs: hubs, + autofocus: true, + iconForHub: (_, _) => Icons.movie_rounded, + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + tester.state(find.byType(TvBrowseRail)).requestFocus(); + await tester.pump(); + + final position = _verticalRailPosition(tester); + final initialOffset = position.pixels; + tester.widget(find.byKey(const ValueKey('tv_browse_rail_semantic_proxy'))).properties.onScrollDown!(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 80)); + await tester.pump(const Duration(milliseconds: 80)); + final animatedOffset = position.pixels; + expect(animatedOffset, greaterThan(initialOffset)); + expect(tester.hasRunningAnimations, isTrue); + + final snapshots = tester.widgetList(find.byType(SnapshotWidget)); + expect(snapshots, isNotEmpty); + expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isTrue); + await tester.pumpAndSettle(); + expect(position.pixels, greaterThan(animatedOffset)); + expect(snapshots.every((widget) => widget.controller.allowSnapshotting), isFalse); + }); + testWidgets('active hub header uses theme foreground in light mode', (tester) async { final serverManager = MultiServerManager(); final theme = monoTheme(dark: false);