diff --git a/lib/widgets/mornye_artwork_contrast.dart b/lib/widgets/mornye_artwork_contrast.dart index 741216bf..4bc9be13 100644 --- a/lib/widgets/mornye_artwork_contrast.dart +++ b/lib/widgets/mornye_artwork_contrast.dart @@ -50,7 +50,8 @@ class MornyeArtworkContrast extends StatefulWidget { State createState() => _MornyeArtworkContrastState(); } -class _MornyeArtworkContrastState extends State { +class _MornyeArtworkContrastState extends State + with WidgetsBindingObserver { final _background = GlobalKey(); Timer? _timer; bool _sampling = false; @@ -59,9 +60,13 @@ class _MornyeArtworkContrastState extends State { @override void initState() { super.initState(); + WidgetsBinding.instance.addObserver(this); _schedule(); } + @override + void didChangeAppLifecycleState(AppLifecycleState state) => _schedule(); + @override void didUpdateWidget(MornyeArtworkContrast oldWidget) { super.didUpdateWidget(oldWidget); @@ -74,6 +79,9 @@ class _MornyeArtworkContrastState extends State { _colors = {}; return; } + // Playback can keep the Dart isolate alive after the UI is backgrounded. + final lifecycle = WidgetsBinding.instance.lifecycleState; + if (lifecycle != null && lifecycle != AppLifecycleState.resumed) return; WidgetsBinding.instance.addPostFrameCallback((_) => _sample()); _timer = Timer.periodic( const Duration(milliseconds: 333), @@ -152,6 +160,7 @@ class _MornyeArtworkContrastState extends State { @override void dispose() { + WidgetsBinding.instance.removeObserver(this); _timer?.cancel(); super.dispose(); } diff --git a/lib/widgets/player_lyrics.dart b/lib/widgets/player_lyrics.dart index c82283fb..78850a64 100644 --- a/lib/widgets/player_lyrics.dart +++ b/lib/widgets/player_lyrics.dart @@ -1263,7 +1263,7 @@ class _WordHighlightedLyricLineState ) : position, ); - setState(() {}); + if (!_shouldAnimate) setState(() {}); } void _updateTransportState({bool? playing, bool? loading}) { diff --git a/test/player_lyrics_repaint_test.dart b/test/player_lyrics_repaint_test.dart new file mode 100644 index 00000000..c9342863 --- /dev/null +++ b/test/player_lyrics_repaint_test.dart @@ -0,0 +1,167 @@ +import 'dart:async'; +import 'dart:ui' as ui; + +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/providers/music_player_provider.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/lyrics_parser.dart'; +import 'package:spotiflac_android/widgets/player_lyrics.dart'; + +const _text = 'AAAA BBBB'; +const _lyrics = ParsedLyrics( + synced: true, + wordSynced: true, + plainText: '', + lines: [ + LyricLine( + time: Duration.zero, + end: Duration(seconds: 20), + text: _text, + words: [ + LyricWord( + time: Duration.zero, + end: Duration(seconds: 10), + text: 'AAAA ', + ), + LyricWord( + time: Duration(seconds: 10), + end: Duration(seconds: 20), + text: 'BBBB', + ), + ], + ), + ], +); + +Finder get _paint => find.descendant( + of: find.bySemanticsLabel(_text), + matching: find.byType(CustomPaint), +); + +Future> _pixels(WidgetTester tester) async { + final painter = tester.widget(_paint).painter!; + final size = tester.getSize(_paint); + return (await tester.runAsync(() async { + final recorder = ui.PictureRecorder(); + painter.paint(Canvas(recorder), size); + final picture = recorder.endRecording(); + final image = await picture.toImage(size.width.ceil(), size.height.ceil()); + final bytes = (await image.toByteData(format: ui.ImageByteFormat.rawRgba))!; + final result = bytes.buffer.asUint8List().toList(); + image.dispose(); + picture.dispose(); + return result; + }))!; +} + +void main() { + for (final mornye in [false, true]) { + testWidgets( + 'playing lyrics retain their painter and refresh stopped highlights ($mornye)', + (tester) async { + final playback = StreamController.broadcast(); + final preview = ValueNotifier(null); + addTearDown(playback.close); + addTearDown(preview.dispose); + final theme = mornye + ? MornyeTheme.build(Brightness.dark) + : ThemeData.dark(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + playbackStateProvider.overrideWith((ref) => playback.stream), + ], + child: MaterialApp( + theme: theme, + home: Scaffold( + body: SyncedLyricsView( + lyrics: _lyrics, + seekPreview: preview, + colorScheme: theme.colorScheme, + isActive: true, + showPronunciation: false, + showTranslation: false, + ), + ), + ), + ), + ); + Future report( + int seconds, { + bool playing = false, + AudioProcessingState state = AudioProcessingState.ready, + }) async { + playback.add( + PlaybackState( + playing: playing, + processingState: state, + updatePosition: Duration(seconds: seconds), + ), + ); + await tester.pump(); + await tester.pump(); + } + + await report(2); + await tester.pumpAndSettle(); + await report(2, playing: true); + await tester.pump(const Duration(milliseconds: 500)); + final retained = tester.widget(_paint).painter!; + final early = await _pixels(tester); + var repaintNotifications = 0; + void repaint() => repaintNotifications++; + retained.addListener(repaint); + await report(6, playing: true); + await tester.pump(const Duration(milliseconds: 16)); + expect(tester.widget(_paint).painter, same(retained)); + expect(repaintNotifications, greaterThan(0)); + expect(await _pixels(tester), isNot(orderedEquals(early))); + retained.removeListener(repaint); + + await report(8); + await tester.pumpAndSettle(); + final paused = await _pixels(tester); + await tester.pump(const Duration(seconds: 1)); + expect(await _pixels(tester), orderedEquals(paused)); + await report(3); + await tester.pumpAndSettle(); + expect(await _pixels(tester), isNot(orderedEquals(paused))); + + await report(4, playing: true, state: AudioProcessingState.buffering); + await tester.pumpAndSettle(); + if (mornye) { + expect(_paint, findsNothing); + } else { + final buffered = await _pixels(tester); + await tester.pump(const Duration(seconds: 1)); + expect(await _pixels(tester), orderedEquals(buffered)); + await report(7, playing: true, state: AudioProcessingState.buffering); + await tester.pumpAndSettle(); + expect(await _pixels(tester), isNot(orderedEquals(buffered))); + } + + preview.value = const Duration(seconds: 2); + await tester.pumpAndSettle(); + final sought = await _pixels(tester); + preview.value = const Duration(seconds: 8); + await tester.pumpAndSettle(); + expect(await _pixels(tester), isNot(orderedEquals(sought))); + preview.value = const Duration(seconds: 2); + await tester.pumpAndSettle(); + expect(await _pixels(tester), orderedEquals(sought)); + await report(12, playing: true, state: AudioProcessingState.buffering); + await tester.pumpAndSettle(); + expect(await _pixels(tester), orderedEquals(sought)); + preview.value = null; + await report(2); + await tester.pumpAndSettle(); + expect(await _pixels(tester), orderedEquals(sought)); + expect(tester.takeException(), isNull); + await tester.pumpWidget(const SizedBox.shrink()); + }, + ); + } +}