mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 04:42:02 +02:00
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:
@@ -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));
|
||||
|
||||
Reference in New Issue
Block a user