diff --git a/lib/widgets/music/mini_player.dart b/lib/widgets/music/mini_player.dart index 9ee9eb837..0ed1323dd 100644 --- a/lib/widgets/music/mini_player.dart +++ b/lib/widgets/music/mini_player.dart @@ -379,15 +379,6 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix ], ), ), - Positioned( - top: 0, - left: 0, - right: 0, - child: _MiniPlayerScrubber( - key: const ValueKey('mini_player_scrubber'), - trackKey: widget.track.globalKey, - ), - ), ], ), ), @@ -417,9 +408,9 @@ class _MiniPlayerCardState extends State<_MiniPlayerCard> with ContextMenuTapMix /// Isolated progress leaf — positionStream ticks rebuild only this /// background layer, never the card content above it. The played fraction -/// renders as a subtle full-height tint that fills the card left-to-right; -/// it stays as ambient fill under [_MiniPlayerScrubber], which draws the -/// precise scrubbable bar along the card's top edge. +/// renders as a subtle full-height tint that fills the card left-to-right — +/// deliberately the card's only progress display; precise seeking lives on +/// the now-playing screen's seek bar. class _MiniPlayerProgress extends StatelessWidget { const _MiniPlayerProgress({super.key}); @@ -445,131 +436,3 @@ class _MiniPlayerProgress extends StatelessWidget { ); } } - -/// Thin scrubbable progress lane pinned to the card's top edge: a visible -/// bar (played [MonoTokens.text] over an outline track) inside a taller -/// transparent hit rect, so touch scrubbing works without swallowing card -/// taps below it. Hovering or dragging grows the bar and shows a thumb. -/// -/// Pointer-only by design: the overlay never renders on TV (playback there -/// auto-opens the now-playing screen), and the card's bespoke focus chain -/// (details ↔ transport) leads keyboard users to the now-playing screen, -/// which owns the full focusable seek bar — so the lane stays out of the -/// focus graph and out of semantics, like the tint behind it. -/// -/// Isolated leaf like [_MiniPlayerProgress]: positionStream ticks and scrub -/// setState rebuild only this lane, never the card content. -class _MiniPlayerScrubber extends StatefulWidget { - final String trackKey; - - const _MiniPlayerScrubber({super.key, required this.trackKey}); - - @override - State<_MiniPlayerScrubber> createState() => _MiniPlayerScrubberState(); -} - -class _MiniPlayerScrubberState extends State<_MiniPlayerScrubber> { - /// Transparent hit rect tall enough for touch; the bar itself is thinner. - static const double _laneHeight = 16; - static const double _restBarHeight = 3; - static const double _activeBarHeight = 5; - static const double _thumbDiameter = 12; - - /// Scrub fraction while a tap or drag is in flight; null when idle. - double? _scrubFraction; - bool _hovering = false; - - bool get _active => _scrubFraction != null || _hovering; - - @override - void didUpdateWidget(covariant _MiniPlayerScrubber oldWidget) { - super.didUpdateWidget(oldWidget); - if (oldWidget.trackKey != widget.trackKey) _scrubFraction = null; - } - - double _fractionAt(double dx) { - final width = context.size?.width ?? 0; - return width <= 0 ? 0 : (dx / width).clamp(0.0, 1.0); - } - - void _scrubTo(double dx) => setState(() => _scrubFraction = _fractionAt(dx)); - - void _commitScrub() { - final fraction = _scrubFraction; - if (fraction == null) return; - final service = context.read(); - final durationMs = service.duration?.inMilliseconds ?? 0; - if (durationMs > 0 && service.currentTrack?.globalKey == widget.trackKey) { - unawaited(service.seek(Duration(milliseconds: (fraction * durationMs).round()))); - } - setState(() => _scrubFraction = null); - } - - void _cancelScrub() => setState(() => _scrubFraction = null); - - @override - Widget build(BuildContext context) { - final tk = tokens(context); - final service = context.read(); - - return MouseRegion( - cursor: SystemMouseCursors.click, - onEnter: (_) => setState(() => _hovering = true), - onExit: (_) => setState(() => _hovering = false), - child: GestureDetector( - behavior: HitTestBehavior.opaque, - excludeFromSemantics: true, - onTapDown: (details) => _scrubTo(details.localPosition.dx), - onTapUp: (_) => _commitScrub(), - onTapCancel: _cancelScrub, - onHorizontalDragStart: (details) => _scrubTo(details.localPosition.dx), - onHorizontalDragUpdate: (details) => _scrubTo(details.localPosition.dx), - onHorizontalDragEnd: (_) => _commitScrub(), - onHorizontalDragCancel: _cancelScrub, - child: SizedBox( - height: _laneHeight, - width: double.infinity, - child: StreamBuilder( - // Remount per track so a stale snapshot never renders the old - // track's position — same pattern as the now-playing seek bar. - key: ValueKey(widget.trackKey), - stream: service.positionStream, - builder: (context, snapshot) { - final durationMs = service.duration?.inMilliseconds ?? 0; - final position = snapshot.data ?? service.position; - final played = durationMs <= 0 ? 0.0 : (position.inMilliseconds / durationMs).clamp(0.0, 1.0); - final fraction = _scrubFraction ?? played; - final barHeight = _active ? _activeBarHeight : _restBarHeight; - return Stack( - alignment: .centerLeft, - children: [ - SizedBox( - width: double.infinity, - height: barHeight, - child: ColoredBox(color: tk.outline), - ), - FractionallySizedBox( - widthFactor: fraction, - child: SizedBox( - height: barHeight, - child: ColoredBox(color: tk.text), - ), - ), - if (_active) - Align( - alignment: Alignment(fraction * 2 - 1, 0), - child: Container( - width: _thumbDiameter, - height: _thumbDiameter, - decoration: BoxDecoration(color: tk.text, shape: BoxShape.circle), - ), - ), - ], - ); - }, - ), - ), - ), - ); - } -} diff --git a/test/widgets/music/mini_player_test.dart b/test/widgets/music/mini_player_test.dart index 50043d461..55a86a2b5 100644 --- a/test/widgets/music/mini_player_test.dart +++ b/test/widgets/music/mini_player_test.dart @@ -268,65 +268,6 @@ void main() { expect(progressWidths(), isNot(contains(closeTo(2 / 3, 0.001)))); }); - testWidgets('tapping the scrub lane seeks proportionally without opening Now Playing', (tester) async { - final service = _FakeMusicService(track: _track); - final observer = MusicUiRouteObserver(); - final navigatorObserver = _RecordingNavigatorObserver(); - - await tester.pumpWidget(wrap(service: service, observer: observer, navigatorObserver: navigatorObserver)); - await tester.pumpAndSettle(); - - final lane = tester.getRect(find.byKey(const ValueKey('mini_player_scrubber'))); - await tester.tapAt(Offset(lane.left + lane.width / 2, lane.center.dy)); - await tester.pump(); - - // Half of the 3-minute duration. - expect(service.seekCalls, [const Duration(minutes: 1, seconds: 30)]); - expect(service.toggleCalls, 0); - expect(service.nextCalls, 0); - expect(navigatorObserver.pushedRoutes.where((route) => route.settings.name == kNowPlayingRouteName), isEmpty); - - // The card body below the lane still opens Now Playing. - final cardRect = tester.getRect(find.byKey(const ValueKey('mini_player_dismiss'))); - await tester.tapAt(Offset(cardRect.left + 2, cardRect.center.dy)); - final route = navigatorObserver.pushedRoutes.last; - expect(route.settings.name, kNowPlayingRouteName); - expect(service.seekCalls, hasLength(1)); - route.navigator!.removeRoute(route); - }); - - testWidgets('dragging the scrub lane shows the scrub position and commits one seek on release', (tester) async { - final service = _FakeMusicService(track: _track); - final observer = MusicUiRouteObserver(); - - await tester.pumpWidget(wrap(service: service, observer: observer)); - await tester.pumpAndSettle(); - - final lane = tester.getRect(find.byKey(const ValueKey('mini_player_scrubber'))); - final gesture = await tester.startGesture(Offset(lane.left + lane.width * 0.25, lane.center.dy)); - await tester.pump(); - await gesture.moveBy(Offset(lane.width * 0.5, 0)); - await tester.pump(); - - // Mid-drag: nothing committed yet, but the lane renders the scrub - // position (playback itself is still at zero). - expect(service.seekCalls, isEmpty); - final scrubFill = tester - .widgetList(find.byType(FractionallySizedBox)) - .where((box) => box.heightFactor == null) - .map((box) => box.widthFactor); - expect(scrubFill, contains(closeTo(0.75, 0.001))); - - await gesture.up(); - await tester.pump(); - - // Three quarters of the 3-minute duration, committed exactly once; the - // horizontal drag scrubbed instead of swipe-dismissing the card. - expect(service.seekCalls, [const Duration(minutes: 2, seconds: 15)]); - expect(service.stopCalls, 0); - expect(find.text('Dawn'), findsOneWidget); - }); - testWidgets('tapping the card edge opens the named Now Playing route', (tester) async { final service = _FakeMusicService(track: _track); final observer = MusicUiRouteObserver();