feat(lyrics): animate live previews while scrubbing playback

This commit is contained in:
zarzet
2026-09-26 01:31:31 +07:00
parent 55a390d679
commit 8e7e71827c
6 changed files with 639 additions and 113 deletions
@@ -35,6 +35,7 @@ import 'package:spotiflac_android/widgets/mornye_player_background.dart';
import 'package:spotiflac_android/widgets/mornye_player_artwork.dart';
import 'package:spotiflac_android/widgets/mornye_artwork_contrast.dart';
import 'package:spotiflac_android/widgets/mini_player.dart';
import 'package:spotiflac_android/widgets/playback_seek_slider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -103,6 +104,7 @@ void main() {
Stream<PlaybackState>? playbackEvents,
Widget Function(Widget)? wrapPlayer,
MotionArtwork? motionArtwork,
MusicPlayerController? controller,
}) async {
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
@@ -112,6 +114,8 @@ void main() {
await tester.pumpWidget(
ProviderScope(
overrides: [
if (controller != null)
musicPlayerControllerProvider.overrideWithValue(controller),
currentMediaItemProvider.overrideWith((ref) => mediaItems.stream),
playerMotionArtworkProvider.overrideWith(
(ref, album) async => motionArtwork,
@@ -379,6 +383,247 @@ void main() {
expect(tester.takeException(), isNull);
});
for (final size in [const Size(393, 852), const Size(768, 1024)]) {
for (final reducedMotion in [false, true]) {
testWidgets(
'slider scrubs lyrics both ways before seeking (size: $size, reduced motion: $reducedMotion)',
(tester) async {
metadataOverrides['lyrics'] = List.generate(
18,
(index) =>
'[${(index ~/ 6).toString().padLeft(2, '0')}:'
'${(index % 6 * 10).toString().padLeft(2, '0')}.00]'
'Line $index with enough words to wrap across several rows',
).join('\n');
final playback = StreamController<PlaybackState>.broadcast();
addTearDown(playback.close);
final controller = _SeekController();
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: size,
playbackEvents: playback.stream,
controller: controller,
wrapPlayer: (player) => Builder(
builder: (context) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: reducedMotion),
child: player,
),
),
);
mediaItems.add(item('first'));
playback.add(
PlaybackState(updatePosition: const Duration(seconds: 1)),
);
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final slider = find.descendant(
of: find.byType(PlaybackSeekSlider),
matching: find.byType(Slider),
);
final bounds = tester.getRect(slider);
Offset point(double fraction) => Offset(
bounds.left + 8 + (bounds.width - 16) * fraction,
bounds.center.dy,
);
final list = find.byType(ListView);
double offset() => tester.widget<ListView>(list).controller!.offset;
void expectFocus() {
final seconds = tester.widget<Slider>(slider).value / 1000;
final index = seconds ~/ 10;
final line = find.text(
'Line $index with enough words to wrap across several rows',
);
final focus = (size.height * 0.06).clamp(16, 48);
expect(
tester.getTopLeft(line).dy,
closeTo(tester.getTopLeft(list).dy + focus + 16, 2),
);
final label = tester.widget<MornyePlaybackTime>(
find.byKey(const ValueKey('elapsed:first')),
);
expect(label.seconds, seconds.floor());
}
final gesture = await tester.startGesture(point(0.02));
await tester.pumpAndSettle();
final initial = offset();
await gesture.moveTo(point(0.82));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
final intermediate = offset();
expect(intermediate, greaterThan(initial));
await tester.pumpAndSettle();
if (!reducedMotion) expect(offset(), greaterThan(intermediate));
expectFocus();
expect(controller.seeks, isEmpty);
final forward = offset();
await gesture.moveTo(point(0.19));
await tester.pump();
await tester.pump();
await tester.pump(const Duration(milliseconds: 70));
expect(offset(), lessThan(forward));
await tester.pumpAndSettle();
expectFocus();
final held = offset();
// Neither playback ticks nor the automatic next-line timer may
// move the user's preview while the finger is still down.
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
playing: true,
updatePosition: const Duration(seconds: 151),
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 10));
expect(offset(), closeTo(held, 0.01));
expectFocus();
expect(controller.seeks, isEmpty);
await gesture.up();
await tester.pump();
expect(controller.seeks, hasLength(1));
playback.add(PlaybackState(updatePosition: controller.seeks.single));
await tester.pump();
controller.completions.single.complete();
await tester.pumpAndSettle();
expect(offset(), closeTo(held, 0.01));
expectFocus();
expect(tester.takeException(), isNull);
},
);
}
}
testWidgets('scrubbing previews word fills and instrumental dots exactly', (
tester,
) async {
metadataOverrides['lyrics'] = '''
<tt xmlns="http://www.w3.org/ns/ttml"><body><div>
<p begin="00:10.000" end="00:30.000"><span begin="00:10.000" end="00:20.000">First </span><span begin="00:20.000" end="00:30.000">second</span></p>
<p begin="00:40.000" end="00:50.000">Last vocal</p>
</div></body></tt>
''';
final playback = StreamController<PlaybackState>.broadcast();
addTearDown(playback.close);
final controller = _SeekController();
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 852),
playbackEvents: playback.stream,
controller: controller,
);
mediaItems.add(item('first'));
playback.add(PlaybackState(updatePosition: const Duration(seconds: 1)));
await tester.pumpAndSettle();
await tester.tap(find.byIcon(CupertinoIcons.quote_bubble));
await tester.pumpAndSettle();
final sliderFinder = find.descendant(
of: find.byType(PlaybackSeekSlider),
matching: find.byType(Slider),
);
Slider slider() => tester.widget<Slider>(sliderFinder);
// Exact millisecond targets let this check word fills independently of
// the gesture/scroll integration test above.
slider().onChangeStart!(1000);
Future<void> preview(int milliseconds) async {
slider().onChanged!(milliseconds.toDouble());
await tester.pumpAndSettle();
}
Future<List<int>> wordPixels() async {
final paint = find.descendant(
of: find.byWidgetPredicate(
(widget) =>
widget is Semantics && widget.properties.label == 'First second',
),
matching: find.byType(CustomPaint),
);
final boundary = tester.renderObject<RenderRepaintBoundary>(
find.ancestor(of: paint, matching: find.byType(RepaintBoundary)).first,
);
return (await tester.runAsync(() async {
final image = await boundary.toImage();
final bytes = (await image.toByteData(
format: ui.ImageByteFormat.rawRgba,
))!;
final result = bytes.buffer.asUint8List().toList();
image.dispose();
return result;
}))!;
}
await preview(15000);
final firstWord = await wordPixels();
await preview(25000);
expect(await wordPixels(), isNot(firstWord));
await preview(15000);
expect(await wordPixels(), firstWord);
playback.add(
PlaybackState(
processingState: AudioProcessingState.ready,
playing: true,
updatePosition: const Duration(seconds: 45),
),
);
await tester.pump();
await tester.pump(const Duration(seconds: 1));
expect(await wordPixels(), firstWord);
for (final (milliseconds, alpha) in [(33333, 0.25), (36667, 1.0)]) {
await preview(milliseconds);
final dot = tester.widget<AnimatedContainer>(
find.byKey(const ValueKey('lyric-gap-dot-1')),
);
expect(
(dot.decoration! as BoxDecoration).color!.a,
closeTo(alpha, 0.001),
);
}
await preview(170000);
expect(find.byType(LyricGapIndicator), findsNothing);
expect(controller.seeks, isEmpty);
await preview(15000);
slider().onChangeEnd!(15000);
await tester.pump();
expect(controller.seeks, [const Duration(seconds: 15)]);
// A pending old seek must not leave the next track at the preview time.
metadataOverrides['lyrics'] = '[00:02.00]New track start\n[00:14.00]Later';
mediaItems.add(item('second'));
playback.add(PlaybackState(updatePosition: Duration.zero));
await tester.pumpAndSettle();
expect(slider().value, 0);
expect(find.text('New track start'), findsOneWidget);
expect(find.byType(LyricGapIndicator), findsNothing);
slider().onChangeStart!(0);
await preview(35000);
await preview(0);
final list = tester.widget<ListView>(find.byType(ListView));
expect(list.controller!.offset, 0);
controller.completions.single.complete();
await tester.pumpAndSettle();
expect(slider().value, 0);
expect(
tester
.widget<PlaybackSeekSlider>(find.byType(PlaybackSeekSlider))
.preview!
.value,
Duration.zero,
);
slider().onChangeEnd!(0);
controller.completions.last.complete();
await tester.pumpAndSettle();
expect(tester.takeException(), isNull);
});
testWidgets('Mornye landscape has no top handle or reserved toolbar height', (
tester,
) async {
@@ -2591,6 +2836,19 @@ void main() {
});
}
class _SeekController extends MusicPlayerController {
final seeks = <Duration>[];
final completions = <Completer<void>>[];
@override
Future<void> seek(Duration position) {
seeks.add(position);
final completion = Completer<void>();
completions.add(completion);
return completion.future;
}
}
class _TestCollections extends LibraryCollectionsNotifier {
@override
LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true);
+82
View File
@@ -2,6 +2,8 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:spotiflac_android/theme/mornye_theme.dart';
import 'package:spotiflac_android/utils/playback_seek_preview.dart';
import 'package:spotiflac_android/widgets/playback_seek_slider.dart';
void main() {
@@ -9,10 +11,13 @@ void main() {
tester,
) async {
final seeks = <Duration>[];
final sharedPreview = PlaybackSeekPreview();
addTearDown(sharedPreview.dispose);
final completion = Completer<void>();
Widget app(Duration position) => MaterialApp(
home: Scaffold(
body: PlaybackSeekSlider(
preview: sharedPreview,
position: position,
duration: const Duration(seconds: 100),
onSeek: (target) {
@@ -32,10 +37,12 @@ void main() {
await tester.pump();
final preview = tester.widget<Slider>(find.byType(Slider)).value;
expect(preview, greaterThan(50000));
expect(sharedPreview.value, Duration(milliseconds: preview.round()));
expect(seeks, isEmpty);
await tester.pumpWidget(app(const Duration(seconds: 11)));
expect(tester.widget<Slider>(find.byType(Slider)).value, preview);
expect(sharedPreview.value, Duration(milliseconds: preview.round()));
await gesture.up();
await tester.pump();
expect(seeks, [Duration(milliseconds: preview.round())]);
@@ -45,6 +52,7 @@ void main() {
completion.complete();
await tester.pump();
await tester.pump();
expect(sharedPreview.value, isNull);
expect(
tester.widget<Slider>(find.byType(Slider)).value,
seeks.single.inMilliseconds.toDouble(),
@@ -55,10 +63,13 @@ void main() {
tester,
) async {
final completions = <Completer<void>>[];
final sharedPreview = PlaybackSeekPreview();
addTearDown(sharedPreview.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PlaybackSeekSlider(
preview: sharedPreview,
position: Duration.zero,
duration: const Duration(seconds: 100),
onSeek: (_) {
@@ -84,11 +95,82 @@ void main() {
await tester.pump();
await tester.pump();
expect(tester.widget<Slider>(find.byType(Slider)).value, preview);
expect(sharedPreview.value, Duration(milliseconds: preview.round()));
await gesture.up();
expect(completions, hasLength(2));
completions.last.complete();
await tester.pump();
await tester.pump();
expect(sharedPreview.value, isNull);
});
testWidgets('removing a Mornye slider releases its pending preview safely', (
tester,
) async {
final preview = PlaybackSeekPreview();
addTearDown(preview.dispose);
final completion = Completer<void>();
await tester.pumpWidget(
MaterialApp(
theme: MornyeTheme.build(Brightness.dark),
home: Scaffold(
body: PlaybackSeekSlider(
preview: preview,
position: Duration.zero,
duration: const Duration(seconds: 100),
onSeek: (_) => completion.future,
),
),
),
);
await tester.tap(find.byType(Slider));
await tester.pump();
expect(preview.value, isNotNull);
await tester.pumpWidget(const SizedBox());
expect(preview.value, isNull);
final session = preview.begin(const Duration(seconds: 20));
completion.complete();
await tester.pump();
expect(preview.value, const Duration(seconds: 20));
preview.end(session);
expect(tester.takeException(), isNull);
});
testWidgets('failed seeks release the shared preview', (tester) async {
final preview = PlaybackSeekPreview();
addTearDown(preview.dispose);
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: PlaybackSeekSlider(
preview: preview,
position: Duration.zero,
duration: const Duration(seconds: 100),
onSeek: (_) async => throw StateError('Seek failed'),
),
),
),
);
final slider = tester.widget<Slider>(find.byType(Slider));
slider.onChangeStart!(50000);
slider.onChanged!(50000);
final commit = slider.onChangeEnd! as Future<void> Function(double);
await expectLater(commit(50000), throwsStateError);
expect(preview.value, isNull);
await tester.pump();
expect(tester.widget<Slider>(find.byType(Slider)).value, 0);
});
test('track reset invalidates pending preview sessions', () {
final preview = PlaybackSeekPreview();
addTearDown(preview.dispose);
final oldSession = preview.begin(const Duration(seconds: 30));
preview.reset();
preview.update(oldSession, const Duration(seconds: 40));
expect(preview.value, isNull);
preview.begin(const Duration(seconds: 10));
preview.end(oldSession);
expect(preview.value, const Duration(seconds: 10));
});
testWidgets('unknown duration disables seek and clamps stale position', (