diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 72624a35..75f40d1d 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -228,6 +228,7 @@ class _NowPlayingScreenState extends ConsumerState { final _artworkControlsKey = GlobalKey(); final _artworkVolumeKey = GlobalKey(); Map _artworkForeground = {}; + final _artworkColorsChanged = ValueNotifier(0); @override void initState() { @@ -267,6 +268,7 @@ class _NowPlayingScreenState extends ConsumerState { void dispose() { _mediaItemSub?.close(); _pageController.dispose(); + _artworkColorsChanged.dispose(); super.dispose(); } @@ -648,7 +650,9 @@ class _NowPlayingScreenState extends ConsumerState { '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 { 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 { : 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 { 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( diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index f8350f10..6b83d360 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -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( + find.byType(MornyePlayerBackground), + ); + final title = find.text('First').hitTestable(); + final bounds = tester.getRect(title); + final contrast = tester.widget( + 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( + find.byType(MornyePlayerBackground), + ), + same(background), + ); + expect(tester.getRect(title), bounds); + expect( + tester + .widgetList(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(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(find.byType(MornyePlaybackButton)) + .toList(); await positionAt(34800); expectTimes(34, 146); + final updatedTransport = tester + .widgetList(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));