mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-19 22:17:29 +02:00
fix(lyrics): preserve manual scrolling during control transitions
This commit is contained in:
1 parent
def4536d0a
commit
cd3fb40f2e
2 files changed
+144
-5
No files matched your search
@@ -3161,7 +3161,12 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
_syncPositionSubscription();
|
||||
// Appearance can rebuild when transport controls fade (for example with
|
||||
// custom glass clarity). Keep the existing playback subscription and the
|
||||
// user's scroll ownership; only new lyrics/page activation should reset it.
|
||||
if (_positionSubscription == null && widget.isActive) {
|
||||
_syncPositionSubscription();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -3567,13 +3572,29 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
? Duration.zero
|
||||
: const Duration(milliseconds: 280);
|
||||
|
||||
return NotificationListener<UserScrollNotification>(
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: (notification) {
|
||||
if (notification.direction != ScrollDirection.idle) {
|
||||
if (notification.depth != 0 ||
|
||||
notification.metrics.axis != Axis.vertical) {
|
||||
return false;
|
||||
}
|
||||
final dragging =
|
||||
(notification is ScrollStartNotification &&
|
||||
notification.dragDetails != null) ||
|
||||
(notification is ScrollUpdateNotification &&
|
||||
notification.dragDetails != null) ||
|
||||
(notification is UserScrollNotification &&
|
||||
notification.direction != ScrollDirection.idle);
|
||||
if (dragging) {
|
||||
_userScrolling = true;
|
||||
_userScrollIdleTimer?.cancel();
|
||||
} else if (notification is ScrollEndNotification && _userScrolling) {
|
||||
// Start the return-to-playback timeout only after the drag and its
|
||||
// momentum finish. A direction notification is not an idle event:
|
||||
// a long drag can keep that direction while controls fade in/out.
|
||||
_userScrollIdleTimer?.cancel();
|
||||
_userScrollIdleTimer = Timer(const Duration(seconds: 4), () {
|
||||
if (!mounted) return;
|
||||
if (!mounted || !widget.isActive) return;
|
||||
_userScrolling = false;
|
||||
unawaited(_maybeAutoScroll(_active));
|
||||
});
|
||||
|
||||
@@ -317,6 +317,76 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('custom glass keeps live lyrics under the finger on reversal', (
|
||||
tester,
|
||||
) async {
|
||||
metadataOverrides['lyrics'] = List.generate(
|
||||
20,
|
||||
(index) =>
|
||||
'[00:${(index * 3).toString().padLeft(2, '0')}.00]Live lyric $index with several words on this line',
|
||||
).join('\n');
|
||||
final playback = StreamController<PlaybackState>.broadcast();
|
||||
addTearDown(playback.close);
|
||||
await pumpNowPlaying(
|
||||
tester,
|
||||
theme: MornyeTheme.build(Brightness.dark, glassClarity: 0.5),
|
||||
size: const Size(393, 852),
|
||||
playbackEvents: playback.stream,
|
||||
);
|
||||
mediaItems.add(item('many'));
|
||||
playback.add(
|
||||
PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
updatePosition: const Duration(seconds: 30),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
|
||||
await tester.pumpAndSettle();
|
||||
final list = find.byType(ListView);
|
||||
final bounds = tester.getRect(list);
|
||||
final scroll = tester.widget<ListView>(list).controller!;
|
||||
final initial = scroll.offset;
|
||||
final pause = find.widgetWithIcon(
|
||||
MornyePlaybackButton,
|
||||
CupertinoIcons.pause_fill,
|
||||
);
|
||||
final touch = await tester.startGesture(
|
||||
Offset(bounds.center.dx, bounds.top + 180),
|
||||
);
|
||||
await touch.moveBy(const Offset(0, -20));
|
||||
await tester.pump();
|
||||
await touch.moveBy(const Offset(0, -100));
|
||||
await tester.pump();
|
||||
final hidden = scroll.offset;
|
||||
expect(hidden, greaterThan(initial));
|
||||
expect(pause.hitTestable(), findsNothing);
|
||||
|
||||
playback.add(
|
||||
PlaybackState(
|
||||
playing: true,
|
||||
processingState: AudioProcessingState.ready,
|
||||
updatePosition: const Duration(seconds: 33),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 250));
|
||||
expect(scroll.offset, closeTo(hidden, 0.01));
|
||||
await touch.moveBy(const Offset(0, 24));
|
||||
await tester.pump();
|
||||
expect(scroll.offset, closeTo(hidden - 24, 0.01));
|
||||
expect(pause.hitTestable(), findsOneWidget);
|
||||
for (var frame = 0; frame < 15; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 16));
|
||||
expect(scroll.offset, closeTo(hidden - 24, 0.01));
|
||||
expect(tester.getRect(list), bounds);
|
||||
}
|
||||
await touch.up();
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final reducedMotion in [false, true]) {
|
||||
testWidgets(
|
||||
'static cover shrinks on pause without moving controls (reduced motion: $reducedMotion)',
|
||||
@@ -820,6 +890,8 @@ void main() {
|
||||
testWidgets(
|
||||
'manual lyric scrolling keeps its viewport stable while controls animate (height: $height)',
|
||||
(tester) async {
|
||||
final clarity = ValueNotifier(0.5);
|
||||
addTearDown(clarity.dispose);
|
||||
metadataOverrides['lyrics'] = List.generate(
|
||||
20,
|
||||
(index) =>
|
||||
@@ -830,6 +902,14 @@ void main() {
|
||||
theme: MornyeTheme.build(Brightness.dark),
|
||||
size: Size(393, height),
|
||||
playback: PlaybackState(updatePosition: const Duration(seconds: 30)),
|
||||
wrapPlayer: (player) => ValueListenableBuilder<double>(
|
||||
valueListenable: clarity,
|
||||
builder: (context, value, child) => Theme(
|
||||
data: MornyeTheme.build(Brightness.dark, glassClarity: value),
|
||||
child: child!,
|
||||
),
|
||||
child: player,
|
||||
),
|
||||
);
|
||||
mediaItems.add(item('many'));
|
||||
await tester.pumpAndSettle();
|
||||
@@ -864,6 +944,12 @@ void main() {
|
||||
final hiddenOffset = state.position.pixels;
|
||||
expect(hiddenOffset, greaterThan(initialOffset));
|
||||
expect(transport.hitTestable(), findsNothing);
|
||||
// An inherited appearance update must not reset playback following
|
||||
// while the user owns the scroll position.
|
||||
clarity.value = 0.6;
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
expect(state.position.pixels, closeTo(hiddenOffset, 0.01));
|
||||
for (var frame = 0; frame < 3; frame++) {
|
||||
await tester.pump(const Duration(milliseconds: 40));
|
||||
expect(tester.getRect(list), bounds);
|
||||
@@ -871,7 +957,13 @@ void main() {
|
||||
expect(state.position.pixels, closeTo(hiddenOffset, 0.01));
|
||||
}
|
||||
|
||||
// Continue the same drag, then reverse before the hide completes.
|
||||
// A held drag must not expire the manual-scroll pause, even if the
|
||||
// finger has not changed direction for longer than its idle timeout.
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(state.position.pixels, closeTo(hiddenOffset, 0.01));
|
||||
|
||||
// Continue the same drag and reverse while controls reappear.
|
||||
await touch.moveBy(const Offset(0, -32));
|
||||
await tester.pump();
|
||||
expect(state.position.pixels, closeTo(hiddenOffset + 32, 0.01));
|
||||
@@ -888,6 +980,32 @@ void main() {
|
||||
expect(tester.getTopLeft(find.text('Second')).dy, headerTop);
|
||||
expect(transport.hitTestable(), findsNWidgets(3));
|
||||
await touch.up();
|
||||
await tester.pumpAndSettle();
|
||||
final releasedOffset = state.position.pixels;
|
||||
clarity.value = 0.5;
|
||||
await tester.pump();
|
||||
await tester.pumpAndSettle();
|
||||
expect(state.position.pixels, closeTo(releasedOffset, 0.01));
|
||||
await tester.pump(const Duration(seconds: 3));
|
||||
|
||||
// Starting another drag cancels the previous idle timeout. It must
|
||||
// remain paused through further scrolling in the same direction.
|
||||
final nextTouch = await tester.startGesture(
|
||||
Offset(bounds.center.dx, bounds.top + 100),
|
||||
);
|
||||
await nextTouch.moveBy(const Offset(0, -20));
|
||||
await tester.pump();
|
||||
await nextTouch.moveBy(const Offset(0, -40));
|
||||
await tester.pump();
|
||||
final nextOffset = state.position.pixels;
|
||||
await tester.pump(const Duration(seconds: 5));
|
||||
await tester.pump(const Duration(milliseconds: 400));
|
||||
expect(state.position.pixels, closeTo(nextOffset, 0.01));
|
||||
await nextTouch.up();
|
||||
await tester.pumpAndSettle();
|
||||
await tester.pump(const Duration(seconds: 4));
|
||||
await tester.pumpAndSettle();
|
||||
expect(state.position.pixels, closeTo(initialOffset, 0.01));
|
||||
await tester.pumpWidget(const SizedBox());
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user