diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 38439d04..8e627cf5 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -4,6 +4,7 @@ import 'dart:ui' show BoxHeightStyle, ImageFilter; import 'package:audio_service/audio_service.dart'; import 'package:flutter/material.dart'; import 'package:flutter/cupertino.dart' show CupertinoIcons; +import 'package:flutter/foundation.dart' show ValueListenable; import 'package:flutter/rendering.dart' show ScrollDirection; import 'package:flutter/services.dart' show SystemUiOverlayStyle; import 'package:flutter_riverpod/flutter_riverpod.dart'; @@ -27,6 +28,7 @@ import 'package:spotiflac_android/utils/int_utils.dart'; import 'package:spotiflac_android/utils/isrc_utils.dart'; import 'package:spotiflac_android/utils/lyrics_parser.dart'; import 'package:spotiflac_android/utils/lyrics_timeline.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/utils/logger.dart'; import 'package:spotiflac_android/utils/string_utils.dart'; import 'package:spotiflac_android/utils/synced_lyrics_scroll.dart'; @@ -355,19 +357,23 @@ class _NowPlayingScreenState extends ConsumerState { final _motionArtworkKey = GlobalKey(); Map _artworkForeground = {}; final _artworkColorsChanged = ValueNotifier(0); + final _seekPreview = PlaybackSeekPreview(); @override void initState() { super.initState(); _mediaItemSub = ref.listenManual>( currentMediaItemProvider, - (previous, next) => _loadMetadataForItem( - next.value, - // When automatic playback advances while Lyrics is already visible, - // onPageChanged will not run again. Inspect an unresolved SAF URI now - // instead of leaving the new track with an empty Lyrics page. - inspectUnresolvedContentUri: _currentPage == 1, - ), + (previous, next) { + if (previous?.value?.id != next.value?.id) _seekPreview.reset(); + _loadMetadataForItem( + next.value, + // When automatic playback advances while Lyrics is already visible, + // onPageChanged will not run again. Inspect an unresolved SAF URI now + // instead of leaving the new track with an empty Lyrics page. + inspectUnresolvedContentUri: _currentPage == 1, + ); + }, ); _lyricsPlayingSub = ref.listenManual(playbackPlayingProvider, ( previous, @@ -402,6 +408,7 @@ class _NowPlayingScreenState extends ConsumerState { _lyricsPlayingSub?.close(); _pageController.dispose(); _artworkColorsChanged.dispose(); + _seekPreview.dispose(); super.dispose(); } @@ -1405,6 +1412,7 @@ class _NowPlayingScreenState extends ConsumerState { mediaId: mediaItem.id, duration: mediaItem.duration ?? Duration.zero, controller: controller, + seekPreview: _seekPreview, colorScheme: foreground('controls'), qualityLabel: _qualityLabel(), compact: landscape, @@ -1665,6 +1673,7 @@ class _NowPlayingScreenState extends ConsumerState { mediaId: mediaItem.id, duration: mediaItem.duration ?? Duration.zero, controller: controller, + seekPreview: _seekPreview, colorScheme: colorScheme, qualityLabel: _qualityLabel(), ), @@ -1713,6 +1722,7 @@ class _NowPlayingScreenState extends ConsumerState { children: [ _SyncedLyricsView( lyrics: _lyrics, + seekPreview: _seekPreview, credits: _lyricsCredits(), colorScheme: colorScheme, isActive: isActive, @@ -2540,6 +2550,7 @@ class _PlaybackControls extends ConsumerWidget { final String mediaId; final Duration duration; final MusicPlayerController controller; + final PlaybackSeekPreview seekPreview; final ColorScheme colorScheme; final String? qualityLabel; final bool compact; @@ -2550,6 +2561,7 @@ class _PlaybackControls extends ConsumerWidget { required this.mediaId, required this.duration, required this.controller, + required this.seekPreview, required this.colorScheme, required this.qualityLabel, this.compact = false, @@ -2579,84 +2591,90 @@ class _PlaybackControls extends ConsumerWidget { ); return Column( children: [ - Consumer( - builder: (context, ref, _) { - final position = ref.watch(playbackPositionProvider); - final elapsedSeconds = position.inSeconds; - return Padding( - padding: EdgeInsets.symmetric(horizontal: mornye ? 28 : 16), - child: Column( - children: [ - SliderTheme( - data: SliderThemeData( - trackHeight: 4, - activeTrackColor: mornye - ? colorScheme.onSurface - : colorScheme.primary, - inactiveTrackColor: colorScheme.onSurface.withValues( - alpha: 0.18, + ValueListenableBuilder( + valueListenable: seekPreview, + builder: (context, preview, _) => Consumer( + builder: (context, ref, _) { + final position = ref.watch(playbackPositionProvider); + final elapsedSeconds = (preview ?? position).inSeconds; + return Padding( + padding: EdgeInsets.symmetric(horizontal: mornye ? 28 : 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, + ), ), - 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, + preview: seekPreview, ), ), - child: PlaybackSeekSlider( - key: ValueKey(mediaId), - position: position, - duration: duration, - onSeek: controller.seek, - ), - ), - Padding( - padding: EdgeInsets.symmetric(horizontal: mornye ? 8 : 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, + Padding( + padding: EdgeInsets.symmetric( + horizontal: mornye ? 8 : 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, + ), ), ), - ), - 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, - ), - ], + 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), Padding( @@ -2790,6 +2808,7 @@ class _PlaybackControls extends ConsumerWidget { class _SyncedLyricsView extends ConsumerStatefulWidget { final ParsedLyrics lyrics; + final ValueListenable seekPreview; final _LyricsCredits? credits; final ColorScheme colorScheme; final bool isActive; @@ -2798,6 +2817,7 @@ class _SyncedLyricsView extends ConsumerStatefulWidget { const _SyncedLyricsView({ required this.lyrics, + required this.seekPreview, this.credits, required this.colorScheme, required this.isActive, @@ -2840,6 +2860,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { void initState() { super.initState(); _resetLineKeys(); + widget.seekPreview.addListener(_previewChanged); } @override @@ -2851,6 +2872,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { @override void didUpdateWidget(covariant _SyncedLyricsView oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.seekPreview != widget.seekPreview) { + oldWidget.seekPreview.removeListener(_previewChanged); + widget.seekPreview.addListener(_previewChanged); + } if (oldWidget.lyrics != widget.lyrics) { _resetLineKeys(); } @@ -2884,7 +2909,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _userScrollIdleTimer?.cancel(); _userScrolling = false; - final position = ref.read(playbackPositionProvider); + final position = _displayPosition; _playing = ref.read(playbackPlayingProvider); _loading = ref.read(playbackLoadingProvider); _active = _activeIndexAt(position); @@ -2896,6 +2921,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _positionSubscription = ref.listenManual( playbackPositionProvider, (previous, next) { + if (widget.seekPreview.value != null) return; final active = _activeIndexAt(next); if (active != _active) _setActiveLine(active, position: next); _scheduleNextLine(next); @@ -2906,7 +2932,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { next, ) { _playing = next; - final position = ref.read(playbackPositionProvider); + final position = _displayPosition; _setActiveLine(_activeIndexAt(position), position: position); _scheduleNextLine(position); }); @@ -2915,13 +2941,34 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { next, ) { _loading = next; - final position = ref.read(playbackPositionProvider); + final position = _displayPosition; _setActiveLine(_activeIndexAt(position), position: position); _scheduleNextLine(position); }); } + Duration get _displayPosition => + widget.seekPreview.value ?? ref.read(playbackPositionProvider); + + void _previewChanged() { + if (!mounted || !widget.isActive) return; + final position = _displayPosition; + final wasUserScrolling = _userScrolling; + _userScrolling = false; + _userScrollIdleTimer?.cancel(); + final active = _activeIndexAt(position); + if (active == _active && (wasUserScrolling || active < 0)) { + unawaited(_maybeAutoScroll(active)); + } + _setActiveLine(active, position: position); + _scheduleNextLine(position); + } + int _activeIndexAt(Duration position) { + // Scrubbing is an explicit preview, even at zero or while audio buffers. + if (widget.seekPreview.value != null) { + return LyricsParser.activeIndex(_lines, position); + } if (context.isMornye) { // Read one transport snapshot: playing/loading derived providers can // notify separately during the same playback event. @@ -2944,13 +2991,18 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { _activeTransitionPosition = position; }); WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) unawaited(_maybeAutoScroll(active)); + if (mounted && active == _active) unawaited(_maybeAutoScroll(active)); }); } void _scheduleNextLine(Duration position) { _lineBoundaryTimer?.cancel(); - if (!widget.isActive || !_playing || _loading) return; + if (!widget.isActive || + !_playing || + _loading || + widget.seekPreview.value != null) { + return; + } final lines = _lines; final dueIndex = syncedLyricsDueLineIndex( @@ -2973,6 +3025,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { @override void dispose() { + widget.seekPreview.removeListener(_previewChanged); _positionSubscription?.close(); _playingSubscription?.close(); _loadingSubscription?.close(); @@ -3042,7 +3095,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { } Future _maybeAutoScroll(int index, {bool immediate = false}) async { + // A short intro may have no countdown row. Still return to the first + // upcoming lyric when the user scrubs back before any vocals. + if (index < 0 && widget.seekPreview.value != null) index = 0; if (_userScrolling || index < 0 || !_scroll.hasClients) return; + final duration = immediate || MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380); final extents = _lineExtents; if (context.isMornye && extents != null && index < extents.length) { final position = _scroll.position; @@ -3053,12 +3112,12 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { position.minScrollExtent, position.maxScrollExtent, ); - if (immediate || MediaQuery.disableAnimationsOf(context)) { + if (duration == Duration.zero) { _scroll.jumpTo(offset); } else { await _scroll.animateTo( offset, - duration: const Duration(milliseconds: 380), + duration: duration, curve: Curves.easeOutCubic, ); } @@ -3071,9 +3130,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { lineContext, alignment: 0.5, alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, - duration: immediate - ? Duration.zero - : const Duration(milliseconds: 380), + duration: duration, curve: Curves.easeOutCubic, ); return; @@ -3089,19 +3146,30 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { position.minScrollExtent, position.maxScrollExtent, ); - await _scroll.animateTo( - clamped.toDouble(), - duration: const Duration(milliseconds: 380), - curve: Curves.easeOutCubic, - ); - if (!mounted || _userScrolling || index >= _lineKeys.length) return; + if (duration == Duration.zero) { + _scroll.jumpTo(clamped.toDouble()); + } else { + await _scroll.animateTo( + clamped.toDouble(), + duration: duration, + curve: Curves.easeOutCubic, + ); + } + if (!mounted || + _userScrolling || + index != _active || + index >= _lineKeys.length) { + return; + } final lineContext = _lineKeys[index].currentContext; if (lineContext != null && lineContext.mounted) { await Scrollable.ensureVisible( lineContext, alignment: 0.5, alignmentPolicy: ScrollPositionAlignmentPolicy.explicit, - duration: const Duration(milliseconds: 180), + duration: duration == Duration.zero + ? Duration.zero + : const Duration(milliseconds: 180), curve: Curves.easeOut, ); } @@ -3228,12 +3296,20 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { alignment: mornye ? Alignment.centerLeft : Alignment.center, - child: isActive && widget.isActive && !loading - ? LyricGapIndicator( - key: ValueKey(line.time), - start: line.time, - end: line.end!, - color: color, + child: + isActive && + widget.isActive && + (!loading || widget.seekPreview.value != null) + ? ValueListenableBuilder( + valueListenable: widget.seekPreview, + builder: (context, preview, _) => + LyricGapIndicator( + key: ValueKey(line.time), + start: line.time, + end: line.end!, + color: color, + position: preview, + ), ) : null, ), @@ -3251,6 +3327,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView> { colorScheme: widget.colorScheme, animate: widget.isActive, initialPosition: _activeTransitionPosition, + seekPreview: widget.seekPreview, supplementVisibility: visibility, ); } else { @@ -3506,6 +3583,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget { final ColorScheme colorScheme; final bool animate; final Duration initialPosition; + final ValueListenable seekPreview; final Offset supplementVisibility; const _WordHighlightedLyricLine({ @@ -3513,6 +3591,7 @@ class _WordHighlightedLyricLine extends ConsumerStatefulWidget { required this.colorScheme, required this.animate, required this.initialPosition, + required this.seekPreview, required this.supplementVisibility, }); @@ -3535,7 +3614,11 @@ class _WordHighlightedLyricLineState bool _playing = false; bool _loading = false; - bool get _shouldAnimate => widget.animate && _playing && !_loading; + bool get _shouldAnimate => + widget.animate && + _playing && + !_loading && + widget.seekPreview.value == null; Duration _positionAt({required bool advance}) { return interpolatedSyncedLyricsPosition( @@ -3557,6 +3640,7 @@ class _WordHighlightedLyricLineState vsync: this, duration: const Duration(seconds: 1), ); + widget.seekPreview.addListener(_previewChanged); _positionSubscription = ref.listenManual( playbackPositionProvider, (previous, next) => _updateReportedPosition(next), @@ -3575,6 +3659,11 @@ class _WordHighlightedLyricLineState @override void didUpdateWidget(covariant _WordHighlightedLyricLine oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.seekPreview != widget.seekPreview) { + oldWidget.seekPreview.removeListener(_previewChanged); + widget.seekPreview.addListener(_previewChanged); + _previewChanged(); + } if (oldWidget.line != widget.line || oldWidget.initialPosition != widget.initialPosition) { _anchorAt(widget.initialPosition); @@ -3588,7 +3677,14 @@ class _WordHighlightedLyricLineState } Duration _currentPosition() { - return _positionAt(advance: _shouldAnimate); + return widget.seekPreview.value ?? _positionAt(advance: _shouldAnimate); + } + + void _previewChanged() { + if (!mounted) return; + _anchorAt(widget.seekPreview.value ?? ref.read(playbackPositionProvider)); + _syncAnimationClock(); + setState(() {}); } void _anchorAt(Duration position) { @@ -3597,7 +3693,7 @@ class _WordHighlightedLyricLineState } void _updateReportedPosition(Duration position) { - if (!mounted) return; + if (!mounted || widget.seekPreview.value != null) return; final predicted = _currentPosition(); _anchorAt( _shouldAnimate @@ -3615,7 +3711,10 @@ class _WordHighlightedLyricLineState final position = _currentPosition(); if (playing != null) _playing = playing; if (loading != null) _loading = loading; - _anchorAt(_shouldAnimate ? position : ref.read(playbackPositionProvider)); + _anchorAt( + widget.seekPreview.value ?? + (_shouldAnimate ? position : ref.read(playbackPositionProvider)), + ); _syncAnimationClock(); setState(() {}); } @@ -3645,6 +3744,7 @@ class _WordHighlightedLyricLineState @override void dispose() { + widget.seekPreview.removeListener(_previewChanged); _positionSubscription?.close(); _playingSubscription?.close(); _loadingSubscription?.close(); diff --git a/lib/utils/playback_seek_preview.dart b/lib/utils/playback_seek_preview.dart new file mode 100644 index 00000000..c5592573 --- /dev/null +++ b/lib/utils/playback_seek_preview.dart @@ -0,0 +1,46 @@ +import 'package:flutter/foundation.dart'; + +/// A player-local display position while a seek gesture is in progress. +/// Session tokens prevent an old seek or removed slider clearing a newer drag. +class PlaybackSeekPreview extends ChangeNotifier + implements ValueListenable { + Duration? _value; + Object? _session; + bool _disposed = false; + + @override + Duration? get value => _value; + + Object begin(Duration position) { + final session = Object(); + _session = session; + update(session, position); + return session; + } + + void update(Object session, Duration position) { + if (_disposed || !identical(session, _session) || position == _value) { + return; + } + _value = position; + notifyListeners(); + } + + void end(Object? session) { + if (identical(session, _session)) reset(); + } + + void reset() { + if (_disposed) return; + _session = null; + if (_value == null) return; + _value = null; + notifyListeners(); + } + + @override + void dispose() { + _disposed = true; + super.dispose(); + } +} diff --git a/lib/widgets/lyric_gap_indicator.dart b/lib/widgets/lyric_gap_indicator.dart index 28461bb0..9d5801f0 100644 --- a/lib/widgets/lyric_gap_indicator.dart +++ b/lib/widgets/lyric_gap_indicator.dart @@ -11,16 +11,18 @@ class LyricGapIndicator extends ConsumerWidget { required this.start, required this.end, required this.color, + this.position, }); final Duration start; final Duration end; final Color color; + final Duration? position; @override Widget build(BuildContext context, WidgetRef ref) { final progress = syncedLyricSegmentProgress( - position: ref.watch(playbackPositionProvider), + position: position ?? ref.watch(playbackPositionProvider), start: start, end: end, ); diff --git a/lib/widgets/playback_seek_slider.dart b/lib/widgets/playback_seek_slider.dart index 368d46fd..aba7aad5 100644 --- a/lib/widgets/playback_seek_slider.dart +++ b/lib/widgets/playback_seek_slider.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/widgets/mornye_player_slider.dart'; /// Previews scrubbing locally and seeks once when the gesture ends. @@ -7,12 +8,14 @@ class PlaybackSeekSlider extends StatefulWidget { final Duration position; final Duration duration; final Future Function(Duration) onSeek; + final PlaybackSeekPreview? preview; const PlaybackSeekSlider({ super.key, required this.position, required this.duration, required this.onSeek, + this.preview, }); @override @@ -22,18 +25,53 @@ class PlaybackSeekSlider extends StatefulWidget { class _PlaybackSeekSliderState extends State { double? _previewMs; int _gestureGeneration = 0; + Object? _previewSession; + + void _start(double value) { + _gestureGeneration++; + _previewSession = widget.preview?.begin( + Duration(milliseconds: value.round()), + ); + } + + void _preview(double value) { + setState(() => _previewMs = value); + final session = _previewSession; + if (session != null) { + widget.preview?.update(session, Duration(milliseconds: value.round())); + } + } + + Future _adjust(double value) { + _start(value); + _preview(value); + return _commit(value); + } Future _commit(double value) async { final generation = _gestureGeneration; + final preview = widget.preview; + final session = _previewSession; try { await widget.onSeek(Duration(milliseconds: value.round())); } finally { + preview?.end(session); if (mounted && generation == _gestureGeneration) { setState(() => _previewMs = null); } } } + @override + void dispose() { + final preview = widget.preview; + final session = _previewSession; + // Rotation or track replacement can remove this slider during layout. + // Notify surviving lyrics/time labels after that frame has finished. + WidgetsBinding.instance.addPostFrameCallback((_) => preview?.end(session)); + super.dispose(); + } + @override Widget build(BuildContext context) { final durationMs = widget.duration.inMilliseconds; @@ -58,10 +96,10 @@ class _PlaybackSeekSliderState extends State { ? percentage((currentMs - 5000).clamp(0, maxMs).toDouble()) : null, onIncrease: enabled - ? () => _commit((currentMs + 5000).clamp(0, maxMs).toDouble()) + ? () => _adjust((currentMs + 5000).clamp(0, maxMs).toDouble()) : null, onDecrease: enabled - ? () => _commit((currentMs - 5000).clamp(0, maxMs).toDouble()) + ? () => _adjust((currentMs - 5000).clamp(0, maxMs).toDouble()) : null, child: IgnorePointer( ignoring: !enabled, @@ -72,8 +110,8 @@ class _PlaybackSeekSliderState extends State { SliderTheme.of(context).inactiveTrackColor ?? Colors.white12, value: enabled ? currentMs : 0, max: maxMs, - onChangeStart: (_) => _gestureGeneration++, - onChanged: (value) => setState(() => _previewMs = value), + onChangeStart: _start, + onChanged: _preview, onChangeEnd: _commit, ), ), @@ -88,8 +126,8 @@ class _PlaybackSeekSliderState extends State { ) : 0, max: maxMs, - onChangeStart: enabled ? (_) => _gestureGeneration++ : null, - onChanged: enabled ? (value) => setState(() => _previewMs = value) : null, + onChangeStart: enabled ? _start : null, + onChanged: enabled ? _preview : null, onChangeEnd: enabled ? _commit : null, ); } diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index cd31840c..2c6868e5 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -35,6 +35,7 @@ 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'; import 'package:spotiflac_android/widgets/mini_player.dart'; +import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -103,6 +104,7 @@ void main() { Stream? playbackEvents, Widget Function(Widget)? wrapPlayer, MotionArtwork? motionArtwork, + MusicPlayerController? controller, }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -112,6 +114,8 @@ void main() { await tester.pumpWidget( ProviderScope( overrides: [ + if (controller != null) + musicPlayerControllerProvider.overrideWithValue(controller), currentMediaItemProvider.overrideWith((ref) => mediaItems.stream), playerMotionArtworkProvider.overrideWith( (ref, album) async => motionArtwork, @@ -379,6 +383,247 @@ void main() { expect(tester.takeException(), isNull); }); + for (final size in [const Size(393, 852), const Size(768, 1024)]) { + for (final reducedMotion in [false, true]) { + testWidgets( + 'slider scrubs lyrics both ways before seeking (size: $size, reduced motion: $reducedMotion)', + (tester) async { + metadataOverrides['lyrics'] = List.generate( + 18, + (index) => + '[${(index ~/ 6).toString().padLeft(2, '0')}:' + '${(index % 6 * 10).toString().padLeft(2, '0')}.00]' + 'Line $index with enough words to wrap across several rows', + ).join('\n'); + final playback = StreamController.broadcast(); + addTearDown(playback.close); + final controller = _SeekController(); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: size, + playbackEvents: playback.stream, + controller: controller, + wrapPlayer: (player) => Builder( + builder: (context) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: reducedMotion), + child: player, + ), + ), + ); + mediaItems.add(item('first')); + playback.add( + PlaybackState(updatePosition: const Duration(seconds: 1)), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + final slider = find.descendant( + of: find.byType(PlaybackSeekSlider), + matching: find.byType(Slider), + ); + final bounds = tester.getRect(slider); + Offset point(double fraction) => Offset( + bounds.left + 8 + (bounds.width - 16) * fraction, + bounds.center.dy, + ); + final list = find.byType(ListView); + double offset() => tester.widget(list).controller!.offset; + void expectFocus() { + final seconds = tester.widget(slider).value / 1000; + final index = seconds ~/ 10; + final line = find.text( + 'Line $index with enough words to wrap across several rows', + ); + final focus = (size.height * 0.06).clamp(16, 48); + expect( + tester.getTopLeft(line).dy, + closeTo(tester.getTopLeft(list).dy + focus + 16, 2), + ); + final label = tester.widget( + find.byKey(const ValueKey('elapsed:first')), + ); + expect(label.seconds, seconds.floor()); + } + + final gesture = await tester.startGesture(point(0.02)); + await tester.pumpAndSettle(); + final initial = offset(); + await gesture.moveTo(point(0.82)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 70)); + final intermediate = offset(); + expect(intermediate, greaterThan(initial)); + await tester.pumpAndSettle(); + if (!reducedMotion) expect(offset(), greaterThan(intermediate)); + expectFocus(); + expect(controller.seeks, isEmpty); + + final forward = offset(); + await gesture.moveTo(point(0.19)); + await tester.pump(); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 70)); + expect(offset(), lessThan(forward)); + await tester.pumpAndSettle(); + expectFocus(); + final held = offset(); + // Neither playback ticks nor the automatic next-line timer may + // move the user's preview while the finger is still down. + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: true, + updatePosition: const Duration(seconds: 151), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 10)); + expect(offset(), closeTo(held, 0.01)); + expectFocus(); + expect(controller.seeks, isEmpty); + + await gesture.up(); + await tester.pump(); + expect(controller.seeks, hasLength(1)); + playback.add(PlaybackState(updatePosition: controller.seeks.single)); + await tester.pump(); + controller.completions.single.complete(); + await tester.pumpAndSettle(); + expect(offset(), closeTo(held, 0.01)); + expectFocus(); + expect(tester.takeException(), isNull); + }, + ); + } + } + + testWidgets('scrubbing previews word fills and instrumental dots exactly', ( + tester, + ) async { + metadataOverrides['lyrics'] = ''' +
+

First second

+

Last vocal

+
+'''; + final playback = StreamController.broadcast(); + addTearDown(playback.close); + final controller = _SeekController(); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + playbackEvents: playback.stream, + controller: controller, + ); + mediaItems.add(item('first')); + playback.add(PlaybackState(updatePosition: const Duration(seconds: 1))); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(CupertinoIcons.quote_bubble)); + await tester.pumpAndSettle(); + final sliderFinder = find.descendant( + of: find.byType(PlaybackSeekSlider), + matching: find.byType(Slider), + ); + Slider slider() => tester.widget(sliderFinder); + // Exact millisecond targets let this check word fills independently of + // the gesture/scroll integration test above. + slider().onChangeStart!(1000); + Future preview(int milliseconds) async { + slider().onChanged!(milliseconds.toDouble()); + await tester.pumpAndSettle(); + } + + Future> wordPixels() async { + final paint = find.descendant( + of: find.byWidgetPredicate( + (widget) => + widget is Semantics && widget.properties.label == 'First second', + ), + matching: find.byType(CustomPaint), + ); + final boundary = tester.renderObject( + find.ancestor(of: paint, matching: find.byType(RepaintBoundary)).first, + ); + return (await tester.runAsync(() async { + final image = await boundary.toImage(); + final bytes = (await image.toByteData( + format: ui.ImageByteFormat.rawRgba, + ))!; + final result = bytes.buffer.asUint8List().toList(); + image.dispose(); + return result; + }))!; + } + + await preview(15000); + final firstWord = await wordPixels(); + await preview(25000); + expect(await wordPixels(), isNot(firstWord)); + await preview(15000); + expect(await wordPixels(), firstWord); + playback.add( + PlaybackState( + processingState: AudioProcessingState.ready, + playing: true, + updatePosition: const Duration(seconds: 45), + ), + ); + await tester.pump(); + await tester.pump(const Duration(seconds: 1)); + expect(await wordPixels(), firstWord); + + for (final (milliseconds, alpha) in [(33333, 0.25), (36667, 1.0)]) { + await preview(milliseconds); + final dot = tester.widget( + find.byKey(const ValueKey('lyric-gap-dot-1')), + ); + expect( + (dot.decoration! as BoxDecoration).color!.a, + closeTo(alpha, 0.001), + ); + } + await preview(170000); + expect(find.byType(LyricGapIndicator), findsNothing); + expect(controller.seeks, isEmpty); + await preview(15000); + slider().onChangeEnd!(15000); + await tester.pump(); + expect(controller.seeks, [const Duration(seconds: 15)]); + + // A pending old seek must not leave the next track at the preview time. + metadataOverrides['lyrics'] = '[00:02.00]New track start\n[00:14.00]Later'; + mediaItems.add(item('second')); + playback.add(PlaybackState(updatePosition: Duration.zero)); + await tester.pumpAndSettle(); + expect(slider().value, 0); + expect(find.text('New track start'), findsOneWidget); + expect(find.byType(LyricGapIndicator), findsNothing); + slider().onChangeStart!(0); + await preview(35000); + await preview(0); + final list = tester.widget(find.byType(ListView)); + expect(list.controller!.offset, 0); + controller.completions.single.complete(); + await tester.pumpAndSettle(); + expect(slider().value, 0); + expect( + tester + .widget(find.byType(PlaybackSeekSlider)) + .preview! + .value, + Duration.zero, + ); + slider().onChangeEnd!(0); + controller.completions.last.complete(); + await tester.pumpAndSettle(); + expect(tester.takeException(), isNull); + }); + testWidgets('Mornye landscape has no top handle or reserved toolbar height', ( tester, ) async { @@ -2591,6 +2836,19 @@ void main() { }); } +class _SeekController extends MusicPlayerController { + final seeks = []; + final completions = >[]; + + @override + Future seek(Duration position) { + seeks.add(position); + final completion = Completer(); + completions.add(completion); + return completion.future; + } +} + class _TestCollections extends LibraryCollectionsNotifier { @override LibraryCollectionsState build() => LibraryCollectionsState(isLoaded: true); diff --git a/test/playback_seek_slider_test.dart b/test/playback_seek_slider_test.dart index 39b4f1d8..d7bb7eaf 100644 --- a/test/playback_seek_slider_test.dart +++ b/test/playback_seek_slider_test.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/theme/mornye_theme.dart'; +import 'package:spotiflac_android/utils/playback_seek_preview.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; void main() { @@ -9,10 +11,13 @@ void main() { tester, ) async { final seeks = []; + final sharedPreview = PlaybackSeekPreview(); + addTearDown(sharedPreview.dispose); final completion = Completer(); Widget app(Duration position) => MaterialApp( home: Scaffold( body: PlaybackSeekSlider( + preview: sharedPreview, position: position, duration: const Duration(seconds: 100), onSeek: (target) { @@ -32,10 +37,12 @@ void main() { await tester.pump(); final preview = tester.widget(find.byType(Slider)).value; expect(preview, greaterThan(50000)); + expect(sharedPreview.value, Duration(milliseconds: preview.round())); expect(seeks, isEmpty); await tester.pumpWidget(app(const Duration(seconds: 11))); expect(tester.widget(find.byType(Slider)).value, preview); + expect(sharedPreview.value, Duration(milliseconds: preview.round())); await gesture.up(); await tester.pump(); expect(seeks, [Duration(milliseconds: preview.round())]); @@ -45,6 +52,7 @@ void main() { completion.complete(); await tester.pump(); await tester.pump(); + expect(sharedPreview.value, isNull); expect( tester.widget(find.byType(Slider)).value, seeks.single.inMilliseconds.toDouble(), @@ -55,10 +63,13 @@ void main() { tester, ) async { final completions = >[]; + final sharedPreview = PlaybackSeekPreview(); + addTearDown(sharedPreview.dispose); await tester.pumpWidget( MaterialApp( home: Scaffold( body: PlaybackSeekSlider( + preview: sharedPreview, position: Duration.zero, duration: const Duration(seconds: 100), onSeek: (_) { @@ -84,11 +95,82 @@ void main() { await tester.pump(); await tester.pump(); expect(tester.widget(find.byType(Slider)).value, preview); + expect(sharedPreview.value, Duration(milliseconds: preview.round())); await gesture.up(); expect(completions, hasLength(2)); completions.last.complete(); await tester.pump(); await tester.pump(); + expect(sharedPreview.value, isNull); + }); + + testWidgets('removing a Mornye slider releases its pending preview safely', ( + tester, + ) async { + final preview = PlaybackSeekPreview(); + addTearDown(preview.dispose); + final completion = Completer(); + await tester.pumpWidget( + MaterialApp( + theme: MornyeTheme.build(Brightness.dark), + home: Scaffold( + body: PlaybackSeekSlider( + preview: preview, + position: Duration.zero, + duration: const Duration(seconds: 100), + onSeek: (_) => completion.future, + ), + ), + ), + ); + await tester.tap(find.byType(Slider)); + await tester.pump(); + expect(preview.value, isNotNull); + await tester.pumpWidget(const SizedBox()); + expect(preview.value, isNull); + final session = preview.begin(const Duration(seconds: 20)); + completion.complete(); + await tester.pump(); + expect(preview.value, const Duration(seconds: 20)); + preview.end(session); + expect(tester.takeException(), isNull); + }); + + testWidgets('failed seeks release the shared preview', (tester) async { + final preview = PlaybackSeekPreview(); + addTearDown(preview.dispose); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: PlaybackSeekSlider( + preview: preview, + position: Duration.zero, + duration: const Duration(seconds: 100), + onSeek: (_) async => throw StateError('Seek failed'), + ), + ), + ), + ); + final slider = tester.widget(find.byType(Slider)); + slider.onChangeStart!(50000); + slider.onChanged!(50000); + final commit = slider.onChangeEnd! as Future Function(double); + await expectLater(commit(50000), throwsStateError); + expect(preview.value, isNull); + await tester.pump(); + expect(tester.widget(find.byType(Slider)).value, 0); + }); + + test('track reset invalidates pending preview sessions', () { + final preview = PlaybackSeekPreview(); + addTearDown(preview.dispose); + final oldSession = preview.begin(const Duration(seconds: 30)); + preview.reset(); + preview.update(oldSession, const Duration(seconds: 40)); + expect(preview.value, isNull); + preview.begin(const Duration(seconds: 10)); + preview.end(oldSession); + expect(preview.value, const Duration(seconds: 10)); }); testWidgets('unknown duration disables seek and clamps stale position', (