From 22cc210f3049263c0e1fcbacf0f6116b8e4873e2 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 25 Mar 2024 22:02:48 +0600 Subject: [PATCH] feat(connect): add player controls, shuffle, loop, progress bar and queue support --- lib/collections/routes.dart | 20 +- lib/collections/spotube_icons.dart | 1 + lib/components/player/player.dart | 29 +- lib/components/player/player_controls.dart | 24 +- lib/components/player/player_overlay.dart | 2 +- lib/components/player/player_queue.dart | 34 ++- .../player/player_track_details.dart | 8 +- lib/components/root/bottom_player.dart | 4 +- lib/models/connect/connect.dart | 1 + lib/models/connect/ws_event.dart | 166 +++++++++++ lib/pages/connect/connect.dart | 14 +- lib/pages/connect/control/control.dart | 269 ++++++++++++++++++ lib/pages/lyrics/mini_lyrics.dart | 13 +- lib/pages/root/root_app.dart | 15 +- lib/provider/connect/clients.dart | 9 +- lib/provider/connect/connect.dart | 89 ++++-- lib/provider/connect/server.dart | 76 ++++- 17 files changed, 708 insertions(+), 66 deletions(-) create mode 100644 lib/pages/connect/control/control.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 1b5a7cee..80067405 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -7,6 +7,7 @@ import 'package:spotify/spotify.dart' hide Search; import 'package:spotube/models/spotify/recommendation_seeds.dart'; import 'package:spotube/pages/album/album.dart'; import 'package:spotube/pages/connect/connect.dart'; +import 'package:spotube/pages/connect/control/control.dart'; import 'package:spotube/pages/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genres.dart'; @@ -175,11 +176,20 @@ final routerProvider = Provider((ref) { }, ), GoRoute( - path: "/connect", - pageBuilder: (context, state) => const SpotubePage( - child: ConnectPage(), - ), - ) + path: "/connect", + pageBuilder: (context, state) => const SpotubePage( + child: ConnectPage(), + ), + routes: [ + GoRoute( + path: "control", + pageBuilder: (context, state) { + return const SpotubePage( + child: ConnectControlPage(), + ); + }, + ) + ]) ], ), GoRoute( diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index bee98958..2489fd71 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -119,4 +119,5 @@ abstract class SpotubeIcons { static const connect = FeatherIcons.link; static const speaker = FeatherIcons.speaker; static const monitor = FeatherIcons.monitor; + static const power = FeatherIcons.power; } diff --git a/lib/components/player/player.dart b/lib/components/player/player.dart index 5559be73..3439bc65 100644 --- a/lib/components/player/player.dart +++ b/lib/components/player/player.dart @@ -46,9 +46,7 @@ class PlayerView extends HookConsumerWidget { final currentTrack = ref.watch(ProxyPlaylistNotifier.provider.select( (value) => value.activeTrack, )); - final isLocalTrack = ref.watch(ProxyPlaylistNotifier.provider.select( - (value) => value.activeTrack is LocalTrack, - )); + final isLocalTrack = currentTrack is LocalTrack; final mediaQuery = MediaQuery.of(context); useEffect(() { @@ -240,7 +238,7 @@ class PlayerView extends HookConsumerWidget { ), if (isLocalTrack) Text( - currentTrack?.artists?.asString() ?? "", + currentTrack.artists?.asString() ?? "", style: theme.textTheme.bodyMedium!.copyWith( fontWeight: FontWeight.bold, color: bodyTextColor, @@ -304,10 +302,25 @@ class PlayerView extends HookConsumerWidget { .height * .7, ), - builder: (context) { - return const PlayerQueue( - floating: false); - }, + builder: (context) => Consumer( + builder: (context, ref, _) { + final playlist = ref.watch( + ProxyPlaylistNotifier + .provider, + ); + final playlistNotifier = + ref.read( + ProxyPlaylistNotifier + .notifier, + ); + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: false, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ); } : null), diff --git a/lib/components/player/player_controls.dart b/lib/components/player/player_controls.dart index 02cbfff5..0190e2e6 100644 --- a/lib/components/player/player_controls.dart +++ b/lib/components/player/player_controls.dart @@ -256,20 +256,16 @@ class PlayerControls extends HookConsumerWidget { onPressed: playlist.isFetching == true ? null : () async { - switch (audioPlayer.loopMode) { - case PlaybackLoopMode.all: - audioPlayer - .setLoopMode(PlaybackLoopMode.one); - break; - case PlaybackLoopMode.one: - audioPlayer - .setLoopMode(PlaybackLoopMode.none); - break; - case PlaybackLoopMode.none: - audioPlayer - .setLoopMode(PlaybackLoopMode.all); - break; - } + audioPlayer.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => + PlaybackLoopMode.one, + PlaybackLoopMode.one => + PlaybackLoopMode.none, + PlaybackLoopMode.none => + PlaybackLoopMode.all, + }, + ); }, ); }), diff --git a/lib/components/player/player_overlay.dart b/lib/components/player/player_overlay.dart index 1ad91a52..e2ca9674 100644 --- a/lib/components/player/player_overlay.dart +++ b/lib/components/player/player_overlay.dart @@ -115,7 +115,7 @@ class PlayerOverlay extends HookConsumerWidget { width: double.infinity, color: Colors.transparent, child: PlayerTrackDetails( - albumArt: albumArt, + track: playlist.activeTrack, color: textColor, ), ), diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 141479a6..4cb4acac 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -8,6 +8,7 @@ import 'package:fuzzywuzzy/fuzzywuzzy.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:scroll_to_index/scroll_to_index.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/fallbacks/not_found.dart'; import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart'; @@ -16,19 +17,40 @@ import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/controllers/use_auto_scroll_controller.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; class PlayerQueue extends HookConsumerWidget { final bool floating; + final ProxyPlaylist playlist; + + final Future Function(Track track) onJump; + final Future Function(String trackId) onRemove; + final Future Function(int oldIndex, int newIndex) onReorder; + final Future Function() onStop; + const PlayerQueue({ this.floating = true, + required this.playlist, + required this.onJump, + required this.onRemove, + required this.onReorder, + required this.onStop, super.key, }); + PlayerQueue.fromProxyPlaylistNotifier({ + this.floating = true, + required this.playlist, + required ProxyPlaylistNotifier notifier, + super.key, + }) : onJump = notifier.jumpToTrack, + onRemove = notifier.removeTrack, + onReorder = notifier.moveTrack, + onStop = notifier.stop; + @override Widget build(BuildContext context, ref) { - final playlist = ref.watch(ProxyPlaylistNotifier.provider); - final playlistNotifier = ref.watch(ProxyPlaylistNotifier.notifier); final controller = useAutoScrollController(); final searchText = useState(''); @@ -191,7 +213,7 @@ class PlayerQueue extends HookConsumerWidget { ], ), onPressed: () { - playlistNotifier.stop(); + onStop(); Navigator.of(context).pop(); }, ), @@ -204,7 +226,7 @@ class PlayerQueue extends HookConsumerWidget { Flexible( child: ReorderableListView.builder( onReorder: (oldIndex, newIndex) { - playlistNotifier.moveTrack(oldIndex, newIndex); + onReorder(oldIndex, newIndex); }, scrollController: controller, itemCount: tracks.length, @@ -232,7 +254,7 @@ class PlayerQueue extends HookConsumerWidget { if (playlist.activeTrack?.id == track.id) { return; } - await playlistNotifier.jumpToTrack(track); + onJump(track); }, leadingActions: [ ReorderableDragStartListener( @@ -265,7 +287,7 @@ class PlayerQueue extends HookConsumerWidget { if (playlist.activeTrack?.id == track.id) { return; } - await playlistNotifier.jumpToTrack(track); + onJump(track); }, ), ); diff --git a/lib/components/player/player_track_details.dart b/lib/components/player/player_track_details.dart index 95fecdc2..65e40fe6 100644 --- a/lib/components/player/player_track_details.dart +++ b/lib/components/player/player_track_details.dart @@ -8,13 +8,14 @@ import 'package:spotube/components/shared/links/artist_link.dart'; import 'package:spotube/components/shared/links/link_text.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/constrains.dart'; +import 'package:spotube/extensions/image.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; class PlayerTrackDetails extends HookConsumerWidget { - final String? albumArt; final Color? color; - const PlayerTrackDetails({super.key, this.albumArt, this.color}); + final Track? track; + const PlayerTrackDetails({super.key, this.color, this.track}); @override Widget build(BuildContext context, ref) { @@ -34,7 +35,8 @@ class PlayerTrackDetails extends HookConsumerWidget { child: ClipRRect( borderRadius: BorderRadius.circular(4), child: UniversalImage( - path: albumArt ?? "", + path: (track?.album?.images) + .asUrlString(placeholder: ImagePlaceholder.albumArt), placeholder: Assets.albumPlaceholder.path, ), ), diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 73df34c1..89fea296 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -75,7 +75,9 @@ class BottomPlayer extends HookConsumerWidget { child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - Expanded(child: PlayerTrackDetails(albumArt: albumArt)), + Expanded( + child: PlayerTrackDetails(track: playlist.activeTrack), + ), // controls Flexible( flex: 3, diff --git a/lib/models/connect/connect.dart b/lib/models/connect/connect.dart index 4d6920ac..efb37315 100644 --- a/lib/models/connect/connect.dart +++ b/lib/models/connect/connect.dart @@ -7,6 +7,7 @@ import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/extensions/track.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; part 'connect.freezed.dart'; part 'connect.g.dart'; diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart index c9eb2fcb..54cfaad0 100644 --- a/lib/models/connect/ws_event.dart +++ b/lib/models/connect/ws_event.dart @@ -2,6 +2,13 @@ part of 'connect.dart'; enum WsEvent { error, + removeTrack, + addTrack, + reorder, + shuffle, + loop, + seek, + duration, queue, position, playing, @@ -132,6 +139,92 @@ class WebSocketEvent { ); } } + + Future onDuration( + EventCallback callback, + ) async { + if (type == WsEvent.duration) { + await callback( + WebSocketDurationEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onSeek( + EventCallback callback, + ) async { + if (type == WsEvent.seek) { + await callback( + WebSocketSeekEvent( + Duration(seconds: data as int), + ), + ); + } + } + + Future onShuffle( + EventCallback callback, + ) async { + if (type == WsEvent.shuffle) { + await callback(WebSocketShuffleEvent(data as bool)); + } + } + + Future onLoop( + EventCallback callback, + ) async { + if (type == WsEvent.loop) { + await callback( + WebSocketLoopEvent( + PlaybackLoopMode.fromString(data as String), + ), + ); + } + } + + Future onRemoveTrack( + EventCallback callback, + ) async { + if (type == WsEvent.removeTrack) { + await callback(WebSocketRemoveTrackEvent(data as String)); + } + } + + Future onAddTrack( + EventCallback callback, + ) async { + if (type == WsEvent.addTrack) { + await callback( + WebSocketAddTrackEvent.fromJson(data as Map)); + } + } + + Future onReorder( + EventCallback callback, + ) async { + if (type == WsEvent.reorder) { + await callback( + WebSocketReorderEvent.fromJson(data as Map)); + } + } +} + +class WebSocketLoopEvent extends WebSocketEvent { + WebSocketLoopEvent(PlaybackLoopMode data) : super(WsEvent.loop, data); + + WebSocketLoopEvent.fromJson(Map json) + : super( + WsEvent.loop, PlaybackLoopMode.fromString(json["data"] as String)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.name, + }); + } } class WebSocketPositionEvent extends WebSocketEvent { @@ -149,6 +242,40 @@ class WebSocketPositionEvent extends WebSocketEvent { } } +class WebSocketDurationEvent extends WebSocketEvent { + WebSocketDurationEvent(Duration data) : super(WsEvent.duration, data); + + WebSocketDurationEvent.fromJson(Map json) + : super(WsEvent.duration, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketSeekEvent extends WebSocketEvent { + WebSocketSeekEvent(Duration data) : super(WsEvent.seek, data); + + WebSocketSeekEvent.fromJson(Map json) + : super(WsEvent.seek, Duration(seconds: json["data"] as int)); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": data.inSeconds, + }); + } +} + +class WebSocketShuffleEvent extends WebSocketEvent { + WebSocketShuffleEvent(bool data) : super(WsEvent.shuffle, data); +} + class WebSocketPlayingEvent extends WebSocketEvent { WebSocketPlayingEvent(bool data) : super(WsEvent.playing, data); } @@ -189,3 +316,42 @@ class WebSocketQueueEvent extends WebSocketEvent { ProxyPlaylist.fromJsonRaw(json), ); } + +class WebSocketRemoveTrackEvent extends WebSocketEvent { + WebSocketRemoveTrackEvent(String data) : super(WsEvent.removeTrack, data); +} + +class WebSocketAddTrackEvent extends WebSocketEvent { + WebSocketAddTrackEvent(Track data) : super(WsEvent.addTrack, data); + + WebSocketAddTrackEvent.fromJson(Map json) + : super( + WsEvent.addTrack, + Track.fromJson(json["data"] as Map), + ); +} + +typedef ReorderData = ({int oldIndex, int newIndex}); + +class WebSocketReorderEvent extends WebSocketEvent { + WebSocketReorderEvent(ReorderData data) : super(WsEvent.reorder, data); + + factory WebSocketReorderEvent.fromJson(Map json) => + WebSocketReorderEvent( + ( + oldIndex: json["oldIndex"] as int, + newIndex: json["newIndex"] as int, + ), + ); + + @override + String toJson() { + return jsonEncode({ + "type": type.name, + "data": { + "oldIndex": data.oldIndex, + "newIndex": data.newIndex, + }, + }); + } +} diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart index 456a9779..3ddb94d0 100644 --- a/lib/pages/connect/connect.dart +++ b/lib/pages/connect/connect.dart @@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/shared/page_window_title_bar.dart'; import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/utils/service_utils.dart'; class ConnectPage extends HookConsumerWidget { const ConnectPage({super.key}); @@ -46,12 +47,21 @@ class ConnectPage extends HookConsumerWidget { selectedTileColor: colorScheme.secondary.withOpacity(0.1), onTap: () { if (selected) { - connectClientsNotifier.clearResolvedService(); + ServiceUtils.push( + context, + "/connect/control", + ); } else { connectClientsNotifier.resolveService(device); } }, - trailing: selected ? const Icon(SpotubeIcons.done) : null, + trailing: selected + ? IconButton( + icon: const Icon(SpotubeIcons.power), + onPressed: () => + connectClientsNotifier.clearResolvedService(), + ) + : null, ), ); }, diff --git a/lib/pages/connect/control/control.dart b/lib/pages/connect/control/control.dart new file mode 100644 index 00000000..53f51969 --- /dev/null +++ b/lib/pages/connect/control/control.dart @@ -0,0 +1,269 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/widgets.dart'; +import 'package:gap/gap.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/player/player_queue.dart'; +import 'package:spotube/components/shared/image/universal_image.dart'; +import 'package:spotube/components/shared/links/anchor_button.dart'; +import 'package:spotube/components/shared/links/artist_link.dart'; +import 'package:spotube/components/shared/page_window_title_bar.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/extensions/duration.dart'; +import 'package:spotube/extensions/image.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; +import 'package:spotube/utils/service_utils.dart'; + +class ConnectControlPage extends HookConsumerWidget { + const ConnectControlPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:textTheme, :colorScheme) = Theme.of(context); + + final resolvedService = + ref.watch(connectClientsProvider).asData?.value.resolvedService; + final connectNotifier = ref.read(connectProvider.notifier); + final playlist = ref.watch(queueProvider); + final playing = ref.watch(playingProvider); + final shuffled = ref.watch(shuffleProvider); + final loopMode = ref.watch(loopModeProvider); + + final resumePauseStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primary, + foregroundColor: colorScheme.onPrimary, + padding: const EdgeInsets.all(12), + iconSize: 24, + ); + final buttonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.surface.withOpacity(0.4), + minimumSize: const Size(28, 28), + ); + + final activeButtonStyle = IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.onPrimaryContainer, + minimumSize: const Size(28, 28), + ); + + ref.listen(connectClientsProvider, (prev, next) { + if (next.asData?.value.resolvedService == null) { + context.pop(); + } + }); + + return Scaffold( + appBar: PageWindowTitleBar( + title: Text(resolvedService!.name), + automaticallyImplyLeading: true, + ), + body: CustomScrollView( + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10), + child: ClipRRect( + borderRadius: BorderRadius.circular(20), + child: UniversalImage( + path: (playlist.activeTrack?.album?.images).asUrlString( + placeholder: ImagePlaceholder.albumArt, + ), + fit: BoxFit.cover, + ), + ), + ), + ), + const SliverGap(10), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverMainAxisGroup( + slivers: [ + SliverToBoxAdapter( + child: AnchorButton( + playlist.activeTrack?.name ?? "", + style: textTheme.titleLarge!, + onTap: () { + ServiceUtils.push( + context, + "/track/${playlist.activeTrack?.id}", + ); + }, + ), + ), + SliverToBoxAdapter( + child: ArtistLink( + artists: playlist.activeTrack?.artists ?? [], + textStyle: textTheme.bodyMedium!, + mainAxisAlignment: WrapAlignment.start, + ), + ), + ], + ), + ), + const SliverGap(30), + SliverToBoxAdapter( + child: Consumer( + builder: (context, ref, _) { + final position = ref.watch(positionProvider); + final duration = ref.watch(durationProvider); + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + children: [ + Slider( + value: position > duration + ? 0 + : position.inSeconds.toDouble(), + min: 0, + max: duration.inSeconds.toDouble(), + onChanged: (value) { + connectNotifier + .seek(Duration(seconds: value.toInt())); + }, + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(position.toHumanReadableString()), + Text(duration.toHumanReadableString()), + ], + ), + ], + ), + ); + }, + ), + ), + SliverToBoxAdapter( + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton( + tooltip: shuffled + ? context.l10n.unshuffle_playlist + : context.l10n.shuffle_playlist, + icon: const Icon(SpotubeIcons.shuffle), + style: shuffled ? activeButtonStyle : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + connectNotifier.setShuffle(!shuffled); + }, + ), + IconButton( + tooltip: context.l10n.previous_track, + icon: const Icon(SpotubeIcons.skipBack), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.previous, + ), + IconButton( + tooltip: playing + ? context.l10n.pause_playback + : context.l10n.resume_playback, + icon: playlist.activeTrack == null + ? SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + color: colorScheme.onPrimary, + ), + ) + : Icon( + playing ? SpotubeIcons.pause : SpotubeIcons.play, + ), + style: resumePauseStyle, + onPressed: playlist.activeTrack == null + ? null + : () { + if (playing) { + connectNotifier.pause(); + } else { + connectNotifier.resume(); + } + }, + ), + IconButton( + tooltip: context.l10n.next_track, + icon: const Icon(SpotubeIcons.skipForward), + onPressed: playlist.activeTrack == null + ? null + : connectNotifier.next, + ), + IconButton( + tooltip: loopMode == PlaybackLoopMode.one + ? context.l10n.loop_track + : loopMode == PlaybackLoopMode.all + ? context.l10n.repeat_playlist + : null, + icon: Icon( + loopMode == PlaybackLoopMode.one + ? SpotubeIcons.repeatOne + : SpotubeIcons.repeat, + ), + style: loopMode == PlaybackLoopMode.one || + loopMode == PlaybackLoopMode.all + ? activeButtonStyle + : buttonStyle, + onPressed: playlist.activeTrack == null + ? null + : () async { + connectNotifier.setLoopMode( + switch (loopMode) { + PlaybackLoopMode.all => PlaybackLoopMode.one, + PlaybackLoopMode.one => PlaybackLoopMode.none, + PlaybackLoopMode.none => PlaybackLoopMode.all, + }, + ); + }, + ) + ], + ), + ), + const SliverGap(30), + SliverPadding( + padding: const EdgeInsets.symmetric(horizontal: 20), + sliver: SliverToBoxAdapter( + child: OutlinedButton.icon( + icon: const Icon(SpotubeIcons.queue), + label: Text(context.l10n.queue), + onPressed: () { + showModalBottomSheet( + context: context, + builder: (context) { + return Consumer(builder: (context, ref, _) { + final playlist = ref.watch(queueProvider); + return PlayerQueue( + playlist: playlist, + floating: true, + onJump: (track) async { + final index = + playlist.tracks.toList().indexOf(track); + connectNotifier.jumpTo(index); + }, + onRemove: (track) async { + await connectNotifier.removeTrack(track); + }, + onStop: () async => connectNotifier.stop(), + onReorder: (oldIndex, newIndex) async { + await connectNotifier.reorder( + (oldIndex: oldIndex, newIndex: newIndex), + ); + }, + ); + }); + }, + ); + }, + ), + ), + ) + ], + ), + ); + } +} diff --git a/lib/pages/lyrics/mini_lyrics.dart b/lib/pages/lyrics/mini_lyrics.dart index a617909c..310df75c 100644 --- a/lib/pages/lyrics/mini_lyrics.dart +++ b/lib/pages/lyrics/mini_lyrics.dart @@ -221,7 +221,18 @@ class MiniLyricsPage extends HookConsumerWidget { MediaQuery.of(context).size.height * .7, ), builder: (context) { - return const PlayerQueue(floating: true); + return Consumer(builder: (context, ref, _) { + final playlist = ref + .watch(ProxyPlaylistNotifier.provider); + + return PlayerQueue + .fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: ref + .read(ProxyPlaylistNotifier.notifier), + ); + }); }, ); } diff --git a/lib/pages/root/root_app.dart b/lib/pages/root/root_app.dart index b562adab..729ad88d 100644 --- a/lib/pages/root/root_app.dart +++ b/lib/pages/root/root_app.dart @@ -17,6 +17,7 @@ import 'package:spotube/extensions/context.dart'; import 'package:spotube/hooks/configurators/use_endless_playback.dart'; import 'package:spotube/hooks/configurators/use_update_checker.dart'; import 'package:spotube/provider/download_manager_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/connectivity_adapter.dart'; import 'package:spotube/utils/persisted_state_notifier.dart'; @@ -191,7 +192,19 @@ class RootApp extends HookConsumerWidget { top: 40, bottom: 100, ), - child: const PlayerQueue(floating: true), + child: Consumer( + builder: (context, ref, _) { + final playlist = ref.watch(ProxyPlaylistNotifier.provider); + final playlistNotifier = + ref.read(ProxyPlaylistNotifier.notifier); + + return PlayerQueue.fromProxyPlaylistNotifier( + floating: true, + playlist: playlist, + notifier: playlistNotifier, + ); + }, + ), ) : null, bottomNavigationBar: Column( diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart index 246a488f..d2d51725 100644 --- a/lib/provider/connect/clients.dart +++ b/lib/provider/connect/clients.dart @@ -52,10 +52,15 @@ class ConnectClientsNotifier extends AsyncNotifier { break; case BonsoirDiscoveryEventType.discoveryServiceLost: state = AsyncData( - state.value!.copyWith( + ConnectClientsState( services: state.value!.services - .where((service) => service.name != event.service!.name) + .where((s) => s.name != event.service!.name) .toList(), + discovery: state.value!.discovery, + resolvedService: + event.service?.name == state.value!.resolvedService!.name + ? null + : state.value!.resolvedService, ), ); break; diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart index be31b9e3..4f9d0275 100644 --- a/lib/provider/connect/connect.dart +++ b/lib/provider/connect/connect.dart @@ -1,22 +1,37 @@ -import 'dart:async'; import 'dart:convert'; import 'package:catcher_2/catcher_2.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:spotify/spotify.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/provider/connect/clients.dart'; -import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; +import 'package:spotube/provider/proxy_playlist/proxy_playlist.dart'; +import 'package:spotube/services/audio_player/loop_mode.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; import 'package:web_socket_channel/status.dart' as status; -final playingStreamController = StreamController.broadcast(); -final playingProvider = StreamProvider.autoDispose( - (ref) => playingStreamController.stream, +final playingProvider = StateProvider( + (ref) => false, ); -final positionStreamController = StreamController.broadcast(); -final positionProvider = StreamProvider.autoDispose( - (ref) => positionStreamController.stream, +final positionProvider = StateProvider( + (ref) => Duration.zero, +); + +final durationProvider = StateProvider( + (ref) => Duration.zero, +); + +final shuffleProvider = StateProvider( + (ref) => false, +); + +final loopModeProvider = StateProvider( + (ref) => PlaybackLoopMode.none, +); + +final queueProvider = StateProvider( + (ref) => ProxyPlaylist({}), ); class ConnectNotifier extends AsyncNotifier { @@ -48,15 +63,27 @@ class ConnectNotifier extends AsyncNotifier { WebSocketEvent.fromJson(jsonDecode(message), (data) => data); event.onQueue((event) { - ref.read(ProxyPlaylistNotifier.notifier).state = event.data; + ref.read(queueProvider.notifier).state = event.data; }); event.onPlaying((event) { - playingStreamController.add(event.data); + ref.read(playingProvider.notifier).state = event.data; }); event.onPosition((event) { - positionStreamController.add(event.data); + ref.read(positionProvider.notifier).state = event.data; + }); + + event.onDuration((event) { + ref.read(durationProvider.notifier).state = event.data; + }); + + event.onShuffle((event) { + ref.read(shuffleProvider.notifier).state = event.data; + }); + + event.onLoop((event) { + ref.read(loopModeProvider.notifier).state = event.data; }); }, onError: (error) { @@ -79,40 +106,64 @@ class ConnectNotifier extends AsyncNotifier { } } - void emit(Object message) { + Future emit(Object message) async { if (state.value == null) return; state.value?.sink.add( message is String ? message : (message as dynamic).toJson(), ); } - void resume() { + Future resume() async { emit(WebSocketResumeEvent()); } - void pause() { + Future pause() async { emit(WebSocketPauseEvent()); } - void stop() { + Future stop() async { emit(WebSocketStopEvent()); } - void jumpTo(int position) { + Future jumpTo(int position) async { emit(WebSocketJumpEvent(position)); } - void load(WebSocketLoadEventData data) { + Future load(WebSocketLoadEventData data) async { emit(WebSocketLoadEvent(data)); } - void next() { + Future next() async { emit(WebSocketNextEvent()); } - void previous() { + Future previous() async { emit(WebSocketPreviousEvent()); } + + Future seek(Duration position) async { + emit(WebSocketSeekEvent(position)); + } + + Future setShuffle(bool value) async { + emit(WebSocketShuffleEvent(value)); + } + + Future setLoopMode(PlaybackLoopMode value) async { + emit(WebSocketLoopEvent(value)); + } + + Future addTrack(Track data) async { + emit(WebSocketAddTrackEvent(data)); + } + + Future removeTrack(String data) async { + emit(WebSocketRemoveTrackEvent(data)); + } + + Future reorder(ReorderData data) async { + emit(WebSocketReorderEvent(data)); + } } final connectProvider = diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index 78844ef8..41b62011 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -11,6 +11,7 @@ import 'package:shelf_router/shelf_router.dart'; import 'package:shelf_web_socket/shelf_web_socket.dart'; import 'package:spotube/models/connect/connect.dart'; import 'package:spotube/models/logger.dart'; +import 'package:spotube/provider/connect/clients.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -24,9 +25,11 @@ final logger = getLogger('ConnectServer'); final connectServerProvider = FutureProvider((ref) async { final enabled = ref.watch(userPreferencesProvider.select((s) => s.enableConnect)); + final resolvedService = await ref + .watch(connectClientsProvider.selectAsync((s) => s.resolvedService)); final playbackNotifier = ref.read(ProxyPlaylistNotifier.notifier); - if (!enabled) { + if (!enabled || resolvedService != null) { return null; } @@ -42,7 +45,7 @@ final connectServerProvider = FutureProvider((ref) async { final subscriptions = []; final websocket = webSocketHandler( - (WebSocketChannel channel, String? protocol) { + (WebSocketChannel channel, String? protocol) async { ref.listen( ProxyPlaylistNotifier.provider, (previous, next) { @@ -53,6 +56,25 @@ final connectServerProvider = FutureProvider((ref) async { fireImmediately: true, ); + // because audioPlayer events doesn't fireImmediately + channel.sink.add( + WebSocketPlayingEvent(audioPlayer.isPlaying).toJson(), + ); + channel.sink.add( + WebSocketPositionEvent(await audioPlayer.position ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketDurationEvent(await audioPlayer.duration ?? Duration.zero) + .toJson(), + ); + channel.sink.add( + WebSocketShuffleEvent(await audioPlayer.isShuffled).toJson(), + ); + channel.sink.add( + WebSocketLoopEvent(audioPlayer.loopMode).toJson(), + ); + subscriptions.addAll([ audioPlayer.positionStream.listen( (position) { @@ -64,7 +86,28 @@ final connectServerProvider = FutureProvider((ref) async { audioPlayer.playingStream.listen( (playing) { channel.sink.add( - WebSocketEvent(WsEvent.playing, playing).toJson(), + WebSocketPlayingEvent(playing).toJson(), + ); + }, + ), + audioPlayer.durationStream.listen( + (duration) { + channel.sink.add( + WebSocketDurationEvent(duration).toJson(), + ); + }, + ), + audioPlayer.shuffledStream.listen( + (shuffled) { + channel.sink.add( + WebSocketShuffleEvent(shuffled).toJson(), + ); + }, + ), + audioPlayer.loopModeStream.listen( + (loopMode) { + channel.sink.add( + WebSocketLoopEvent(loopMode).toJson(), ); }, ), @@ -111,6 +154,33 @@ final connectServerProvider = FutureProvider((ref) async { event.onJump((event) async { await playbackNotifier.jumpTo(event.data); }); + + event.onSeek((event) async { + await audioPlayer.seek(event.data); + }); + + event.onShuffle((event) async { + await audioPlayer.setShuffle(event.data); + }); + + event.onLoop((event) async { + await audioPlayer.setLoopMode(event.data); + }); + + event.onAddTrack((event) async { + await playbackNotifier.addTrack(event.data); + }); + + event.onRemoveTrack((event) async { + await playbackNotifier.removeTrack(event.data); + }); + + event.onReorder((event) async { + await playbackNotifier.moveTrack( + event.data.oldIndex, + event.data.newIndex, + ); + }); } catch (e, stackTrace) { Catcher2.reportCheckedError(e, stackTrace); channel.sink.add(WebSocketErrorEvent(e.toString()).toJson());