diff --git a/lib/providers/music_player_provider.dart b/lib/providers/music_player_provider.dart index 5d7285c0..55e6539d 100644 --- a/lib/providers/music_player_provider.dart +++ b/lib/providers/music_player_provider.dart @@ -218,6 +218,8 @@ class MusicPlayerController { void moveQueueItem(int oldIndex, int newIndex) { _handler?.moveQueueItem(oldIndex, newIndex); } + + void removeQueuedItem(MediaItem item) => _handler?.removeQueuedItem(item); } final musicPlayerControllerProvider = Provider( diff --git a/lib/screens/now_playing_screen.dart b/lib/screens/now_playing_screen.dart index 0b06e464..3b46dc66 100644 --- a/lib/screens/now_playing_screen.dart +++ b/lib/screens/now_playing_screen.dart @@ -39,6 +39,8 @@ import 'package:spotiflac_android/widgets/audio_quality_badges.dart'; import 'package:spotiflac_android/widgets/audio_output_button.dart'; import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/player_queue_dismissible.dart'; +import 'package:spotiflac_android/widgets/player_track_swipe.dart'; import 'package:spotiflac_android/widgets/overflow_marquee.dart'; import 'package:spotiflac_android/widgets/playback_seek_slider.dart'; import 'package:spotiflac_android/widgets/playlist_picker_sheet.dart'; @@ -929,14 +931,17 @@ class _NowPlayingScreenState extends ConsumerState { /// the route-level drag region — so the artwork hooks the route directly. Widget _artworkDragRegion(BuildContext context, Widget child) { final route = ModalRoute.of(context); - if (route is! NowPlayingRoute) return child; + if (route is! NowPlayingRoute) return PlayerTrackSwipeRegion(child: child); final pageHeight = MediaQuery.sizeOf(context).height; - return GestureDetector( - onVerticalDragStart: (_) => route.startDrag(), - onVerticalDragUpdate: (details) => route.updateDrag(details, pageHeight), - onVerticalDragEnd: (details) => route.endDrag(details, pageHeight), - onVerticalDragCancel: route.cancelDrag, - child: child, + return PlayerTrackSwipeRegion( + child: GestureDetector( + onVerticalDragStart: (_) => route.startDrag(), + onVerticalDragUpdate: (details) => + route.updateDrag(details, pageHeight), + onVerticalDragEnd: (details) => route.endDrag(details, pageHeight), + onVerticalDragCancel: route.cancelDrag, + child: child, + ), ); } @@ -1158,7 +1163,18 @@ class _NowPlayingScreenState extends ConsumerState { final motion = MediaQuery.disableAnimationsOf(context) ? Duration.zero : const Duration(milliseconds: 380); - return NotificationListener( + final queue = ref.watch(playQueueProvider).value ?? const []; + final reportedIndex = ref.watch( + playbackStateProvider.select((state) => state.value?.queueIndex), + ); + final currentIndex = + reportedIndex != null && + reportedIndex >= 0 && + reportedIndex < queue.length && + queue[reportedIndex].id == mediaItem.id + ? reportedIndex + : queue.indexWhere((item) => item.id == mediaItem.id); + final page = NotificationListener( onNotification: (notification) { if (!showLyrics || _landscape || @@ -1167,7 +1183,9 @@ class _NowPlayingScreenState extends ConsumerState { return false; } final delta = notification.scrollDelta ?? 0; - if (delta.sign != _lyricsScrollDistance.sign) _lyricsScrollDistance = 0; + if (delta.sign != _lyricsScrollDistance.sign) { + _lyricsScrollDistance = 0; + } _lyricsScrollDistance += delta; if (_lyricsScrollDistance.abs() >= 16) { final hidden = _lyricsScrollDistance > 0; @@ -1216,6 +1234,15 @@ class _NowPlayingScreenState extends ConsumerState { return Stack( clipBehavior: Clip.none, children: [ + Positioned( + top: 0, + left: 0, + right: 0, + height: compact ? compactHeaderHeight + 8 : stage.maxHeight, + child: const PlayerTrackSwipeRegion( + child: SizedBox.expand(), + ), + ), Positioned.fill( top: 8 + compactHeaderHeight + 16, child: IgnorePointer( @@ -1515,6 +1542,16 @@ class _NowPlayingScreenState extends ConsumerState { }, ), ); + return PlayerTrackSwipe( + queue: queue, + currentIndex: currentIndex, + onSelected: (item) async { + final latest = ref.read(playQueueProvider).value ?? const []; + final index = latest.indexWhere((entry) => identical(entry, item)); + if (index >= 0) await controller.jumpTo(index); + }, + child: page, + ); } Widget _trackHeader( @@ -1531,46 +1568,51 @@ class _NowPlayingScreenState extends ConsumerState { builder: (context) => Row( children: [ Expanded( - child: Builder( - builder: (titleContext) => InkWell( - borderRadius: BorderRadius.circular(8), - onTap: - (mediaItem.artist ?? '').trim().isEmpty && - (mediaItem.album ?? '').trim().isEmpty - ? null - : () => _showTrackNavigationMenu(titleContext, mediaItem), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisSize: MainAxisSize.min, - children: [ - OverflowMarquee( - resetKey: (mediaItem.id, mediaItem.title), - child: ExplicitTrackTitle( - title: mediaItem.title, - explicit: _isExplicit(mediaItem), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 22 - 4 * progress, - fontWeight: FontWeight.w600, - color: colorScheme.onSurface, + child: PlayerTrackSwipeTitles( + current: mediaItem, + builder: (mediaItem) => Builder( + builder: (titleContext) => InkWell( + borderRadius: BorderRadius.circular(8), + onTap: + (mediaItem.artist ?? '').trim().isEmpty && + (mediaItem.album ?? '').trim().isEmpty + ? null + : () => _showTrackNavigationMenu(titleContext, mediaItem), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + OverflowMarquee( + resetKey: (mediaItem.id, mediaItem.title), + child: ExplicitTrackTitle( + title: mediaItem.title, + explicit: _isExplicit(mediaItem), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 22 - 4 * progress, + fontWeight: FontWeight.w600, + color: colorScheme.onSurface, + ), ), ), - ), - const SizedBox(height: 4), - OverflowMarquee( - resetKey: (mediaItem.id, mediaItem.artist), - child: Text( - mediaItem.artist ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 20 - 4 * progress, - color: colorScheme.onSurface.withValues(alpha: 0.72), + const SizedBox(height: 4), + OverflowMarquee( + resetKey: (mediaItem.id, mediaItem.artist), + child: Text( + mediaItem.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 20 - 4 * progress, + color: colorScheme.onSurface.withValues( + alpha: 0.72, + ), + ), ), ), - ), - ], + ], + ), ), ), ), @@ -2384,50 +2426,56 @@ class _NowPlayingScreenState extends ConsumerState { itemBuilder: (context, i) { final item = queue[i]; final isCurrent = current?.id == item.id; - return ListTile( - key: ValueKey('${item.id}_$i'), - contentPadding: const EdgeInsets.only( - left: 16, - right: 4, - ), - leading: Icon( - isCurrent ? Icons.equalizer : Icons.music_note, - color: isCurrent - ? colorScheme.primary - : colorScheme.onSurfaceVariant, - ), - title: Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodyLarge?.copyWith( - fontWeight: isCurrent - ? FontWeight.bold - : FontWeight.normal, + return PlayerQueueDismissible( + key: ObjectKey(item), + enabled: !isCurrent, + onRemove: () => controller.removeQueuedItem(item), + child: ListTile( + contentPadding: const EdgeInsets.only( + left: 16, + right: 4, + ), + leading: Icon( + isCurrent + ? Icons.equalizer + : Icons.music_note, color: isCurrent ? colorScheme.primary - : colorScheme.onSurface, + : colorScheme.onSurfaceVariant, ), - ), - subtitle: Text( - item.artist ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: textTheme.bodySmall?.copyWith( - color: colorScheme.onSurfaceVariant, + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodyLarge?.copyWith( + fontWeight: isCurrent + ? FontWeight.bold + : FontWeight.normal, + color: isCurrent + ? colorScheme.primary + : colorScheme.onSurface, + ), ), - ), - trailing: ReorderableDragStartListener( - index: i, - child: Padding( - padding: const EdgeInsets.all(8), - child: Icon( - Icons.drag_handle, + subtitle: Text( + item.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: textTheme.bodySmall?.copyWith( color: colorScheme.onSurfaceVariant, ), ), + trailing: ReorderableDragStartListener( + index: i, + child: Padding( + padding: const EdgeInsets.all(8), + child: Icon( + Icons.drag_handle, + color: colorScheme.onSurfaceVariant, + ), + ), + ), + onTap: () => controller.jumpTo(i), ), - onTap: () => controller.jumpTo(i), ); }, ), diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index cfe7c9a0..aca4f6a0 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -1222,6 +1222,29 @@ class MusicPlayerHandler extends BaseAudioHandler unawaited(_persistSession(position: playbackState.value.position)); } + /// Removes one queued occurrence, including when the same song is queued + /// twice. The current source is protected if playback advances mid-gesture. + void removeQueuedItem(MediaItem item) { + final at = _queueItems.indexWhere((entry) => identical(entry, item)); + if (_disposed || at < 0 || at == _index) return; + _queueItems.removeAt(at); + _media.removeAt(at); + _originalQueueOrder?.removeWhere((entry) => identical(entry, item)); + if (at < _index) _index--; + final history = [ + for (final index in _playHistory) + if (index != at) index > at ? index - 1 : index, + ]; + _playHistory + ..clear() + ..addAll(history); + _markSessionQueueChanged(); + unawaited(_autoMix.cancel()); + queue.add(List.unmodifiable(_queueItems)); + _broadcastState(); + unawaited(_persistSession(position: playbackState.value.position)); + } + Future _playIndex( int index, { bool recordHistory = true, diff --git a/lib/widgets/mornye_player_queue.dart b/lib/widgets/mornye_player_queue.dart index f3324628..ea8d0147 100644 --- a/lib/widgets/mornye_player_queue.dart +++ b/lib/widgets/mornye_player_queue.dart @@ -6,6 +6,7 @@ import 'package:spotiflac_android/l10n/l10n.dart'; import 'package:spotiflac_android/providers/music_player_provider.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/widgets/player_artwork.dart'; +import 'package:spotiflac_android/widgets/player_queue_dismissible.dart'; import 'package:spotiflac_android/widgets/mornye_context_menu.dart'; /// Upcoming tracks share the player's artwork backdrop and transport controls. @@ -193,48 +194,51 @@ class MornyePlayerQueue extends ConsumerWidget { ), itemBuilder: (context, index) { final item = queue[start + index]; - return ListTile( - key: ValueKey('${item.id}_${start + index}'), - contentPadding: const EdgeInsets.only(left: 12), - minVerticalPadding: 6, - leading: ClipRRect( - borderRadius: BorderRadius.circular(5), - child: SizedBox.square( - dimension: 40, - child: PlayerArtwork( - artUri: item.artUri?.toString(), - colorScheme: colorScheme, - cacheWidth: 120, - iconSize: 22, - ), - ), - ), - title: Text( - item.title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: type.bodyLarge?.copyWith(color: Colors.white), - ), - subtitle: Text( - item.artist ?? '', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: type.bodySmall?.copyWith(color: Colors.white70), - ), - trailing: ReorderableDragStartListener( - index: index, - child: const ColoredBox( - color: Colors.transparent, + return PlayerQueueDismissible( + key: ObjectKey(item), + onRemove: () => controller.removeQueuedItem(item), + child: ListTile( + contentPadding: const EdgeInsets.only(left: 12), + minVerticalPadding: 6, + leading: ClipRRect( + borderRadius: BorderRadius.circular(5), child: SizedBox.square( - dimension: 44, - child: Icon( - CupertinoIcons.line_horizontal_3, - color: Colors.white38, + dimension: 40, + child: PlayerArtwork( + artUri: item.artUri?.toString(), + colorScheme: colorScheme, + cacheWidth: 120, + iconSize: 22, ), ), ), + title: Text( + item.title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.bodyLarge?.copyWith(color: Colors.white), + ), + subtitle: Text( + item.artist ?? '', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: type.bodySmall?.copyWith(color: Colors.white70), + ), + trailing: ReorderableDragStartListener( + index: index, + child: const ColoredBox( + color: Colors.transparent, + child: SizedBox.square( + dimension: 44, + child: Icon( + CupertinoIcons.line_horizontal_3, + color: Colors.white38, + ), + ), + ), + ), + onTap: () => controller.jumpTo(start + index), ), - onTap: () => controller.jumpTo(start + index), ); }, ), diff --git a/lib/widgets/player_queue_dismissible.dart b/lib/widgets/player_queue_dismissible.dart new file mode 100644 index 00000000..8d6efa95 --- /dev/null +++ b/lib/widgets/player_queue_dismissible.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; + +/// Removing a row affects the queue only, never the song on disk. +class PlayerQueueDismissible extends StatelessWidget { + const PlayerQueueDismissible({ + super.key, + required this.onRemove, + required this.child, + this.enabled = true, + }); + + final VoidCallback onRemove; + final Widget child; + final bool enabled; + + @override + Widget build(BuildContext context) => Dismissible( + key: const ValueKey('queue-dismiss'), + direction: enabled ? DismissDirection.endToStart : DismissDirection.none, + // Remove before collapsing so a concurrently playing row is never left + // dismissed in the tree. The service resolves the entry by identity. + confirmDismiss: (_) async { + onRemove(); + return false; + }, + background: Container( + alignment: AlignmentDirectional.centerEnd, + padding: const EdgeInsets.symmetric(horizontal: 20), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.error, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + Icons.delete_outline, + color: Theme.of(context).colorScheme.onError, + semanticLabel: MaterialLocalizations.of(context).deleteButtonTooltip, + ), + ), + child: child, + ); +} diff --git a/lib/widgets/player_track_swipe.dart b/lib/widgets/player_track_swipe.dart new file mode 100644 index 00000000..0135537a --- /dev/null +++ b/lib/widgets/player_track_swipe.dart @@ -0,0 +1,197 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + +/// Shares one horizontal gesture between artwork and metadata. Only the +/// titles translate; artwork, menus and the rest of the player stay anchored. +class PlayerTrackSwipe extends StatefulWidget { + const PlayerTrackSwipe({ + super.key, + required this.queue, + required this.currentIndex, + required this.onSelected, + required this.child, + }); + + final List queue; + final int currentIndex; + final Future Function(MediaItem) onSelected; + final Widget child; + + @override + State createState() => _PlayerTrackSwipeState(); +} + +class _PlayerTrackSwipeState extends State + with SingleTickerProviderStateMixin { + late final _offset = AnimationController.unbounded(vsync: this); + double _width = 240; + double _drag = 0; + bool _settling = false; + int _generation = 0; + + MediaItem? _neighbor(double direction) { + if (widget.currentIndex < 0) return null; + final at = widget.currentIndex + (direction > 0 ? 1 : -1); + return at >= 0 && at < widget.queue.length ? widget.queue[at] : null; + } + + @override + void didUpdateWidget(PlayerTrackSwipe oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.currentIndex != widget.currentIndex || + !listEquals(oldWidget.queue, widget.queue)) { + _reset(); + } + } + + void _reset() { + _generation++; + _settling = false; + _drag = 0; + _offset.value = 0; + } + + void _start(DragStartDetails details) { + if (_settling) return; + _offset.stop(); + _drag = _offset.value; + } + + void _update(DragUpdateDetails details) { + if (_settling) return; + _drag += details.primaryDelta ?? 0; + _offset.value = _neighbor(_drag) == null + ? _drag * 0.18 / (1 + _drag.abs() / _width) + : _drag.clamp(-_width, _width); + } + + Future _finish([DragEndDetails? details]) async { + if (_settling) return; + final offset = _offset.value; + final velocity = details?.primaryVelocity ?? 0; + final target = _neighbor(offset); + final commit = + details != null && + target != null && + (offset.abs() > _width * 0.25 || + (velocity.abs() > 550 && velocity.sign == offset.sign)); + final generation = ++_generation; + _settling = true; + try { + await _offset + .animateTo( + commit ? _width * offset.sign : 0, + duration: MediaQuery.disableAnimationsOf(context) + ? Duration.zero + : const Duration(milliseconds: 280), + curve: Curves.easeOutCubic, + ) + .orCancel; + if (!mounted || generation != _generation) return; + if (commit) await widget.onSelected(target); + } on TickerCanceled { + // A new song/queue or closing the player supersedes this gesture. + } finally { + if (mounted && generation == _generation) _reset(); + } + } + + @override + void dispose() { + _generation++; + _offset.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) => + _SwipeScope(state: this, child: widget.child); +} + +class _SwipeScope extends InheritedWidget { + const _SwipeScope({required this.state, required super.child}); + + final _PlayerTrackSwipeState state; + + @override + bool updateShouldNotify(_SwipeScope oldWidget) => true; +} + +/// Multiple hit regions can drive the same titles without moving their child. +class PlayerTrackSwipeRegion extends StatelessWidget { + const PlayerTrackSwipeRegion({super.key, required this.child}); + + final Widget child; + + @override + Widget build(BuildContext context) { + final state = context + .dependOnInheritedWidgetOfExactType<_SwipeScope>() + ?.state; + if (state == null) return child; + return GestureDetector( + behavior: HitTestBehavior.translucent, + onHorizontalDragStart: state._start, + onHorizontalDragUpdate: state._update, + onHorizontalDragEnd: state._finish, + onHorizontalDragCancel: state._finish, + child: child, + ); + } +} + +class PlayerTrackSwipeTitles extends StatelessWidget { + const PlayerTrackSwipeTitles({ + super.key, + required this.current, + required this.builder, + }); + + final MediaItem current; + final Widget Function(MediaItem item) builder; + + @override + Widget build(BuildContext context) { + final state = context + .dependOnInheritedWidgetOfExactType<_SwipeScope>() + ?.state; + if (state == null) return builder(current); + return PlayerTrackSwipeRegion( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth; + state._width = width > 0 ? width : 1; + return ClipRect( + child: AnimatedBuilder( + animation: state._offset, + child: builder(current), + builder: (context, child) { + final offset = state._offset.value; + final next = state._neighbor(offset); + return Stack( + clipBehavior: Clip.none, + children: [ + Transform.translate( + offset: Offset(offset, 0), + child: child, + ), + if (offset != 0 && next != null) + Positioned( + left: offset - width * offset.sign, + top: 0, + width: width, + child: ExcludeSemantics( + child: IgnorePointer(child: builder(next)), + ), + ), + ], + ); + }, + ), + ); + }, + ), + ); + } +} diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index f6d6ef7f..3e4f4226 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -375,6 +375,37 @@ void main() { }, ); + test( + 'queue removal preserves duplicates, current audio and unshuffled order', + () async { + await handler.setQueueAndPlay([ + _tracks[0], + _tracks[0], + _tracks[1], + _tracks[2], + ]); + final original = List.of(handler.queue.value); + await handler.setShuffleMode(AudioServiceShuffleMode.all); + handler.removeQueuedItem(original[1]); + handler.removeQueuedItem(original[1]); // A stale swipe is harmless. + handler.removeQueuedItem( + original[0], + ); // The playing occurrence is protected. + await handler.setShuffleMode(AudioServiceShuffleMode.none); + expect(handler.queue.value.map((item) => item.id), [ + 'one', + 'two', + 'three', + ]); + expect(native.resumedSources, ['/one.flac']); + await handler.skipToNext(); + handler.removeQueuedItem(original[0]); + expect(handler.playbackState.value.queueIndex, 0); + expect(handler.mediaItem.value?.id, 'two'); + expect(native.sources['music-player'], '/two.flac'); + }, + ); + test('restored shuffle can return to the saved original order', () async { await handler.restoreSession( items: [_tracks[1], _tracks[2], _tracks[0]], diff --git a/test/now_playing_lyrics_transition_test.dart b/test/now_playing_lyrics_transition_test.dart index 3ae58988..bb6dd191 100644 --- a/test/now_playing_lyrics_transition_test.dart +++ b/test/now_playing_lyrics_transition_test.dart @@ -105,6 +105,7 @@ void main() { Widget Function(Widget)? wrapPlayer, MotionArtwork? motionArtwork, MusicPlayerController? controller, + List queue = const [], }) async { tester.view.physicalSize = size; tester.view.devicePixelRatio = 1; @@ -141,7 +142,7 @@ void main() { ? const Stream.empty() : Stream.value(playback)), ), - playQueueProvider.overrideWith((ref) => const Stream.empty()), + playQueueProvider.overrideWith((ref) => Stream.value(queue)), systemVolumeProvider.overrideWith((ref) => Stream.value(0.5)), systemVolumeWriterProvider.overrideWith( (ref) => @@ -160,6 +161,56 @@ void main() { ); } + for (final page in ['player', 'lyrics', 'queue']) { + testWidgets('cover swipe changes the track in the Mornye $page', ( + tester, + ) async { + final queue = [item('first'), item('second')]; + final controller = _QueueController( + (index) => mediaItems.add(queue[index]), + ); + await pumpNowPlaying( + tester, + theme: MornyeTheme.build(Brightness.dark), + size: const Size(393, 852), + queue: queue, + controller: controller, + playback: PlaybackState(queueIndex: 0), + ); + mediaItems.add(queue.first); + await tester.pumpAndSettle(); + if (page != 'player') { + await tester.tap( + find.byIcon( + page == 'lyrics' + ? CupertinoIcons.quote_bubble + : CupertinoIcons.list_bullet, + ), + ); + await tester.pumpAndSettle(); + } + final cover = find + .byKey( + ValueKey( + page == 'player' + ? 'full-player-artwork' + : 'compact-player-artwork', + ), + ) + .hitTestable(); + final rect = tester.getRect(cover); + final gesture = await tester.startGesture(rect.center); + await gesture.moveBy(const Offset(110, 0)); + await tester.pump(); + expect(tester.getRect(cover), rect); + await gesture.up(); + await tester.pumpAndSettle(); + expect(controller.selected, [1]); + expect(find.text('Second').hitTestable(), findsOneWidget); + expect(tester.takeException(), isNull); + }); + } + testWidgets( 'portrait motion cover keeps the raised square-cover control positions', (tester) async { @@ -3139,6 +3190,18 @@ void main() { }); } +class _QueueController extends MusicPlayerController { + _QueueController(this.onSelected); + final void Function(int) onSelected; + final selected = []; + + @override + Future jumpTo(int index) async { + selected.add(index); + onSelected(index); + } +} + class _SeekController extends MusicPlayerController { final seeks = []; final completions = >[]; diff --git a/test/player_track_swipe_test.dart b/test/player_track_swipe_test.dart new file mode 100644 index 00000000..db291d5c --- /dev/null +++ b/test/player_track_swipe_test.dart @@ -0,0 +1,144 @@ +import 'package:audio_service/audio_service.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:spotiflac_android/widgets/player_queue_dismissible.dart'; +import 'package:spotiflac_android/widgets/player_track_swipe.dart'; + +void main() { + final queue = [ + for (var i = 0; i < 3; i++) + MediaItem(id: '$i', title: 'Song $i', artist: 'Artist $i'), + ]; + + for (final reducedMotion in [false, true]) { + testWidgets( + 'cover drag moves only titles and selects once ($reducedMotion)', + (tester) async { + var index = 0; + final selected = []; + await tester.pumpWidget( + MaterialApp( + home: MediaQuery( + data: MediaQueryData(disableAnimations: reducedMotion), + child: StatefulBuilder( + builder: (context, setState) { + return PlayerTrackSwipe( + queue: queue, + currentIndex: index, + onSelected: (item) async { + selected.add(queue.indexOf(item)); + setState(() => index = queue.indexOf(item)); + }, + child: Scaffold( + body: Center( + child: SizedBox( + width: 300, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const PlayerTrackSwipeRegion( + child: SizedBox( + key: ValueKey('cover'), + width: 200, + height: 200, + ), + ), + PlayerTrackSwipeTitles( + current: queue[index], + builder: (item) => Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(item.title), + Text(item.artist!), + ], + ), + ), + ], + ), + ), + ), + ), + ); + }, + ), + ), + ), + ); + final cover = find.byKey(const ValueKey('cover')); + final coverRect = tester.getRect(cover); + final titleX = tester.getTopLeft(find.text('Song 0')).dx; + final gesture = await tester.startGesture(coverRect.center); + await gesture.moveBy(const Offset(110, 0)); + await tester.pump(); + expect(tester.getRect(cover), coverRect); + expect(tester.getTopLeft(find.text('Song 0')).dx, greaterThan(titleX)); + expect(find.text('Song 1'), findsOneWidget); + await gesture.up(); + await tester.pumpAndSettle(); + expect(selected, [1]); + expect(tester.getTopLeft(find.text('Song 1')).dx, closeTo(titleX, 0.1)); + + await tester.drag(find.text('Song 1'), const Offset(-180, 0)); + await tester.pumpAndSettle(); + expect(selected, [1, 0]); + // At the first song the previous direction resists, then returns. + await tester.drag(cover, const Offset(-200, 0)); + await tester.pumpAndSettle(); + expect(selected, [1, 0]); + expect(tester.getTopLeft(find.text('Song 0')).dx, closeTo(titleX, 0.1)); + await tester.drag(cover, const Offset(20, 0)); + await tester.pumpAndSettle(); + expect(selected, [1, 0]); + expect(tester.takeException(), isNull); + }, + ); + } + + testWidgets('queue left swipe removes just that occurrence', (tester) async { + final entries = [queue[0], queue[0].copyWith(), queue[1]]; + await tester.pumpWidget( + MaterialApp( + home: StatefulBuilder( + builder: (context, setState) => Scaffold( + body: Column( + children: [ + for (final item in entries) + PlayerQueueDismissible( + key: ObjectKey(item), + enabled: !identical(item, entries.first), + onRemove: () => setState(() { + entries.removeWhere((entry) => identical(entry, item)); + }), + child: SizedBox( + width: 320, + height: 64, + child: Text(item.title), + ), + ), + ], + ), + ), + ), + ), + ); + await tester.drag( + find.byType(PlayerQueueDismissible).at(1), + const Offset(280, 0), + ); + await tester.pumpAndSettle(); + expect(entries, hasLength(3)); + await tester.drag( + find.byType(PlayerQueueDismissible).at(1), + const Offset(-280, 0), + ); + await tester.pumpAndSettle(); + expect(entries.map((item) => item.id), ['0', '1']); + await tester.drag( + find.byType(PlayerQueueDismissible).first, + const Offset(-280, 0), + ); + await tester.pumpAndSettle(); + expect(entries, hasLength(2)); + expect(tester.takeException(), isNull); + }); +}