mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 21:02:09 +02:00
feat(lyrics): animate live previews while scrubbing playback
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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', (
|
||||
|
||||
Reference in New Issue
Block a user