perf(player): avoid redundant lyric paints and background sampling

This commit is contained in:
zarzet committed 2026-10-06 21:59:18 +07:00
1 parent 04f679a26e
commit 9b89f2300c
3 files changed
+178 -2

No files matched your search

+10 -1
View File
@@ -50,7 +50,8 @@ class MornyeArtworkContrast extends StatefulWidget {
State<MornyeArtworkContrast> createState() => _MornyeArtworkContrastState();
}
class _MornyeArtworkContrastState extends State<MornyeArtworkContrast> {
class _MornyeArtworkContrastState extends State<MornyeArtworkContrast>
with WidgetsBindingObserver {
final _background = GlobalKey();
Timer? _timer;
bool _sampling = false;
@@ -59,9 +60,13 @@ class _MornyeArtworkContrastState extends State<MornyeArtworkContrast> {
@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<MornyeArtworkContrast> {
_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<MornyeArtworkContrast> {
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_timer?.cancel();
super.dispose();
}
+1 -1
View File
@@ -1263,7 +1263,7 @@ class _WordHighlightedLyricLineState
)
: position,
);
setState(() {});
if (!_shouldAnimate) setState(() {});
}
void _updateTransportState({bool? playing, bool? loading}) {
+167
View File
@@ -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<List<int>> _pixels(WidgetTester tester) async {
final painter = tester.widget<CustomPaint>(_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<PlaybackState>.broadcast();
final preview = ValueNotifier<Duration?>(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<void> 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<CustomPaint>(_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<CustomPaint>(_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());
},
);
}
}