perf(player): scope position and contrast rebuilds to controls

Keep playback position updates inside the timeline subtree and notify only artwork foreground controls when contrast changes. Preserve layout and transport state; add regression coverage for stable artwork and transport widgets.
This commit is contained in:
zarzet
2026-09-20 17:20:52 +07:00
parent 0c930c691f
commit a7717ddfbf
2 changed files with 167 additions and 83 deletions
+102 -83
View File
@@ -228,6 +228,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final _artworkControlsKey = GlobalKey();
final _artworkVolumeKey = GlobalKey();
Map<String, Color> _artworkForeground = {};
final _artworkColorsChanged = ValueNotifier(0);
@override
void initState() {
@@ -267,6 +268,7 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
void dispose() {
_mediaItemSub?.close();
_pageController.dispose();
_artworkColorsChanged.dispose();
super.dispose();
}
@@ -648,7 +650,9 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
'volume': _artworkVolumeKey,
},
onChanged: (colors) {
if (mounted) setState(() => _artworkForeground = colors);
if (!mounted) return;
_artworkForeground = colors;
_artworkColorsChanged.value++;
},
child: MornyePlayerBackground(
artUri: mediaItem.artUri,
@@ -1042,15 +1046,18 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
final transportShift = landscape
? 0.0
: ((volumeGap - 16) / 2).clamp(0.0, 20.0);
Widget controls() => _PlaybackControls(
key: _artworkControlsKey,
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
controller: controller,
colorScheme: foreground('controls'),
qualityLabel: _qualityLabel(),
compact: landscape,
transportTopPadding: 16 + transportShift,
Widget controls() => AnimatedBuilder(
animation: _artworkColorsChanged,
builder: (context, _) => _PlaybackControls(
key: _artworkControlsKey,
mediaId: mediaItem.id,
duration: mediaItem.duration ?? Duration.zero,
controller: controller,
colorScheme: foreground('controls'),
qualityLabel: _qualityLabel(),
compact: landscape,
transportTopPadding: 16 + transportShift,
),
);
if (landscape) {
@@ -1078,7 +1085,11 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
: Padding(
key: _artworkHeaderKey,
padding: const EdgeInsets.fromLTRB(28, 12, 28, 8),
child: _trackHeader(mediaItem, foreground('header')),
child: AnimatedBuilder(
animation: _artworkColorsChanged,
builder: (context, _) =>
_trackHeader(mediaItem, foreground('header')),
),
),
),
AnimatedSize(
@@ -1100,9 +1111,12 @@ class _NowPlayingScreenState extends ConsumerState<NowPlayingScreen> {
children: [
controls(),
SizedBox(height: volumeGap - transportShift),
MornyeVolumeControl(
key: _artworkVolumeKey,
foreground: foreground('volume').onSurface,
AnimatedBuilder(
animation: _artworkColorsChanged,
builder: (context, _) => MornyeVolumeControl(
key: _artworkVolumeKey,
foreground: foreground('volume').onSurface,
),
),
const SizedBox(height: 8),
],
@@ -1882,8 +1896,6 @@ class _PlaybackControls extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final mornye = context.isMornye;
final position = ref.watch(playbackPositionProvider);
final elapsedSeconds = position.inSeconds;
final isPlaying = ref.watch(playbackPlayingProvider);
final isLoading = ref.watch(playbackLoadingProvider);
final timeStyle = Theme.of(
@@ -1901,77 +1913,84 @@ class _PlaybackControls extends ConsumerWidget {
);
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
),
thumbColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
// A 7dp thumb was hard to grab; 10dp with a 24dp overlay
// gives the drag gesture a full-size target.
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 10,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 24,
),
),
child: PlaybackSeekSlider(
key: ValueKey(mediaId),
position: position,
duration: duration,
onSeek: controller.seek,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
if (mornye)
MornyePlaybackTime(
key: ValueKey('elapsed:$mediaId'),
seconds: elapsedSeconds,
style: timeStyle,
)
else
Text(formatClock(elapsedSeconds), style: timeStyle),
Expanded(
child: Center(
child: _QualityBadge(
label: qualityLabel,
colorScheme: colorScheme,
),
Consumer(
builder: (context, ref, _) {
final position = ref.watch(playbackPositionProvider);
final elapsedSeconds = position.inSeconds;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
SliderTheme(
data: SliderThemeData(
trackHeight: 4,
activeTrackColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
inactiveTrackColor: colorScheme.onSurface.withValues(
alpha: 0.18,
),
thumbColor: mornye
? colorScheme.onSurface
: colorScheme.primary,
// A 7dp thumb was hard to grab; 10dp with a 24dp overlay
// gives the drag gesture a full-size target.
thumbShape: const RoundSliderThumbShape(
enabledThumbRadius: 10,
),
overlayShape: const RoundSliderOverlayShape(
overlayRadius: 24,
),
),
if (mornye)
MornyePlaybackTime(
key: ValueKey('remaining:$mediaId'),
// Subtract whole seconds so both labels roll together,
// even when the track duration includes milliseconds.
seconds: (duration.inSeconds - elapsedSeconds).clamp(
0,
duration.inSeconds,
child: PlaybackSeekSlider(
key: ValueKey(mediaId),
position: position,
duration: duration,
onSeek: controller.seek,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
if (mornye)
MornyePlaybackTime(
key: ValueKey('elapsed:$mediaId'),
seconds: elapsedSeconds,
style: timeStyle,
)
else
Text(formatClock(elapsedSeconds), style: timeStyle),
Expanded(
child: Center(
child: _QualityBadge(
label: qualityLabel,
colorScheme: colorScheme,
),
),
),
remaining: true,
style: timeStyle,
)
else
Text(formatClock(duration.inSeconds), style: timeStyle),
],
),
if (mornye)
MornyePlaybackTime(
key: ValueKey('remaining:$mediaId'),
// Subtract whole seconds so both labels roll together,
// even when the track duration includes milliseconds.
seconds: (duration.inSeconds - elapsedSeconds)
.clamp(0, duration.inSeconds),
remaining: true,
style: timeStyle,
)
else
Text(
formatClock(duration.inSeconds),
style: timeStyle,
),
],
),
),
],
),
],
),
);
},
),
SizedBox(height: compact ? 8 : transportTopPadding),
Row(
@@ -32,6 +32,7 @@ import 'package:spotiflac_android/widgets/mornye_metadata_row.dart';
import 'package:spotiflac_android/widgets/mornye_chrome.dart';
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';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
@@ -183,6 +184,61 @@ void main() {
},
);
testWidgets('video contrast updates controls without rebuilding artwork', (
tester,
) async {
await pumpNowPlaying(
tester,
theme: MornyeTheme.build(Brightness.dark),
size: const Size(393, 852),
motionArtwork: const MotionArtwork(
'file:///cover.mp4',
aspectRatio: 0.75,
),
);
mediaItems.add(item('first'));
await tester.pumpAndSettle();
final background = tester.widget<MornyePlayerBackground>(
find.byType(MornyePlayerBackground),
);
final title = find.text('First').hitTestable();
final bounds = tester.getRect(title);
final contrast = tester.widget<MornyeArtworkContrast>(
find.byType(MornyeArtworkContrast),
);
for (final color in [Colors.black, Colors.white]) {
contrast.onChanged({'header': color, 'controls': color, 'volume': color});
await tester.pump();
expect(
tester.widget<MornyePlayerBackground>(
find.byType(MornyePlayerBackground),
),
same(background),
);
expect(tester.getRect(title), bounds);
expect(
tester
.widgetList<MornyePlaybackButton>(find.byType(MornyePlaybackButton))
.where(
(button) => [
CupertinoIcons.backward_fill,
CupertinoIcons.play_fill,
CupertinoIcons.forward_fill,
].contains(button.icon),
)
.map((button) => button.color),
everyElement(color),
);
expect(
tester
.widget<MornyeVolumeControl>(find.byType(MornyeVolumeControl))
.foreground,
color,
);
}
await tester.pumpWidget(const SizedBox());
});
testWidgets('opening Mornye lyrics centers the current wrapped line', (
tester,
) async {
@@ -805,8 +861,17 @@ void main() {
await positionAt(34200);
await tester.pumpAndSettle();
expectTimes(34, 146);
final transport = tester
.widgetList<MornyePlaybackButton>(find.byType(MornyePlaybackButton))
.toList();
await positionAt(34800);
expectTimes(34, 146);
final updatedTransport = tester
.widgetList<MornyePlaybackButton>(find.byType(MornyePlaybackButton))
.toList();
for (var i = 0; i < transport.length; i++) {
expect(updatedTransport[i], same(transport[i]));
}
await positionAt(35000);
await tester.pump(const Duration(milliseconds: 80));