feat(music): make the background fill the mini player's only progress display

The mini player drew a thin scrubbable progress bar along its top edge on
top of the ambient background fill. Drop the scrub lane: the fill is the
card's only progress display, and precise seeking stays on the
now-playing screen's seek bar. Horizontal drags across the card's top
edge swipe-dismiss the card again.
This commit is contained in:
edde746
2026-08-29 19:12:03 +02:00
parent 45e8da1610
commit 6e2dda092d
2 changed files with 3 additions and 199 deletions
+3 -140
View File
@@ -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<MusicPlaybackService>();
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<MusicPlaybackService>();
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<Duration>(
// 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),
),
),
],
);
},
),
),
),
);
}
}
-59
View File
@@ -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<FractionallySizedBox>(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();