From 2f3a2e671d28cd336c748a607cd798eb59c7eb01 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Thu, 28 Mar 2024 21:11:51 +0600 Subject: [PATCH] feat: add play in remote device support --- lib/components/album/album_card.dart | 18 ++++- lib/components/connect/connect_device.dart | 11 ++- lib/components/player/player_queue.dart | 18 ++++- lib/components/playlist/playlist_card.dart | 18 ++++- .../shared/dialogs/select_device_dialog.dart | 70 ++++++++++++++++++ .../sections/body/track_view_body.dart | 42 ++++++++--- .../sections/header/header_buttons.dart | 44 +++++++++--- lib/l10n/app_en.arb | 3 +- lib/models/connect/ws_event.dart | 6 +- lib/pages/artist/section/top_tracks.dart | 48 ++++++++++--- lib/pages/search/sections/tracks.dart | 72 ++++++++++++++----- lib/provider/connect/server.dart | 3 +- untranslated_messages.json | 60 ++++++++++------ 13 files changed, 334 insertions(+), 79 deletions(-) create mode 100644 lib/components/shared/dialogs/select_device_dialog.dart diff --git a/lib/components/album/album_card.dart b/lib/components/album/album_card.dart index 083c1949..678bfd06 100644 --- a/lib/components/album/album_card.dart +++ b/lib/components/album/album_card.dart @@ -2,11 +2,14 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/artist_simple.dart'; import 'package:spotube/extensions/context.dart'; import 'package:spotube/extensions/image.dart'; import 'package:spotube/extensions/track.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -72,8 +75,19 @@ class AlbumCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(album.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: album.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(album.id!); + } } finally { updating.value = false; } diff --git a/lib/components/connect/connect_device.dart b/lib/components/connect/connect_device.dart index 511db3b9..8ece074f 100644 --- a/lib/components/connect/connect_device.dart +++ b/lib/components/connect/connect_device.dart @@ -12,11 +12,14 @@ class ConnectDeviceButton extends HookConsumerWidget { @override Widget build(BuildContext context, ref) { final ThemeData(:colorScheme) = Theme.of(context); + final pixelRatio = MediaQuery.of(context).devicePixelRatio; final connectClients = ref.watch(connectClientsProvider); return SizedBox( - height: 40, + height: 40 * pixelRatio, child: Stack( + alignment: Alignment.centerRight, + fit: StackFit.loose, children: [ Center( child: InkWell( @@ -66,8 +69,10 @@ class ConnectDeviceButton extends HookConsumerWidget { right: 0, child: IconButton.filled( icon: const Icon(SpotubeIcons.speaker), - style: - IconButton.styleFrom(foregroundColor: colorScheme.onPrimary), + style: IconButton.styleFrom( + visualDensity: VisualDensity.standard, + foregroundColor: colorScheme.onPrimary, + ), onPressed: () { ServiceUtils.push(context, "/connect"); }, diff --git a/lib/components/player/player_queue.dart b/lib/components/player/player_queue.dart index 86c543b0..bfd0cbb8 100644 --- a/lib/components/player/player_queue.dart +++ b/lib/components/player/player_queue.dart @@ -231,7 +231,7 @@ class PlayerQueue extends HookConsumerWidget { onReorder(oldIndex, newIndex); }, scrollController: controller, - itemCount: tracks.length, + itemCount: tracks.length + 1, shrinkWrap: true, buildDefaultDragHandles: false, onReorderStart: (index) { @@ -241,6 +241,15 @@ class PlayerQueue extends HookConsumerWidget { HapticFeedback.selectionClick(); }, itemBuilder: (context, i) { + if (i == tracks.length) { + return AutoScrollTag( + index: i, + controller: controller, + key: const ValueKey('end'), + child: const Gap(100), + ); + } + final track = tracks.elementAt(i); return AutoScrollTag( key: ValueKey(i), @@ -277,8 +286,12 @@ class PlayerQueue extends HookConsumerWidget { controller: controller, child: ListView.builder( controller: controller, - itemCount: filteredTracks.length, + itemCount: filteredTracks.length + 1, itemBuilder: (context, i) { + if (i == filteredTracks.length) { + return const Gap(100); + } + final track = filteredTracks.elementAt(i); return Padding( padding: @@ -299,7 +312,6 @@ class PlayerQueue extends HookConsumerWidget { ), ), ), - const Gap(100), ], ), ), diff --git a/lib/components/playlist/playlist_card.dart b/lib/components/playlist/playlist_card.dart index 8915e97a..e5b87d6d 100644 --- a/lib/components/playlist/playlist_card.dart +++ b/lib/components/playlist/playlist_card.dart @@ -2,8 +2,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/playbutton_card.dart'; import 'package:spotube/extensions/image.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -71,8 +74,19 @@ class PlaylistCard extends HookConsumerWidget { if (fetchedTracks.isEmpty) return; - await playlistNotifier.load(fetchedTracks, autoPlay: true); - playlistNotifier.addCollection(playlist.id!); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: fetchedTracks, + collectionId: playlist.id!, + ), + ); + } else { + await playlistNotifier.load(fetchedTracks, autoPlay: true); + playlistNotifier.addCollection(playlist.id!); + } } finally { if (context.mounted) { updating.value = false; diff --git a/lib/components/shared/dialogs/select_device_dialog.dart b/lib/components/shared/dialogs/select_device_dialog.dart new file mode 100644 index 00000000..cd8dedb7 --- /dev/null +++ b/lib/components/shared/dialogs/select_device_dialog.dart @@ -0,0 +1,70 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/extensions/context.dart'; +import 'package:spotube/provider/connect/clients.dart'; + +class SelectDeviceDialog extends HookConsumerWidget { + const SelectDeviceDialog({super.key}); + + @override + Widget build(BuildContext context, ref) { + final isRemoteService = useState(false); + + final connectClients = ref.watch(connectClientsProvider); + final remoteService = connectClients.asData!.value.resolvedService!; + + return AlertDialog( + title: const Text("Choose the device:"), + insetPadding: const EdgeInsets.all(16), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Text( + "There are multiple device connected.\n" + "Choose the device you want this action to take place", + ), + RadioListTile.adaptive( + title: Text(remoteService.name), + value: true, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = value!; + }, + ), + RadioListTile.adaptive( + title: const Text("This Device"), + value: false, + groupValue: isRemoteService.value, + onChanged: (value) { + isRemoteService.value = !value!; + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(isRemoteService.value); + }, + child: Text(context.l10n.select), + ), + ], + ); + } +} + +Future showSelectDeviceDialog(BuildContext context, WidgetRef ref) async { + final connectClients = ref.read(connectClientsProvider); + + if (connectClients.asData?.value.resolvedService == null) { + return false; + } + + final isRemote = await showDialog( + context: context, + builder: (context) => const SelectDeviceDialog(), + ); + + return isRemote ?? false; +} diff --git a/lib/components/shared/tracks_view/sections/body/track_view_body.dart b/lib/components/shared/tracks_view/sections/body/track_view_body.dart index ee5b8da7..80368445 100644 --- a/lib/components/shared/tracks_view/sections/body/track_view_body.dart +++ b/lib/components/shared/tracks_view/sections/body/track_view_body.dart @@ -8,12 +8,15 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/expandable_search/expandable_search.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/track_view_body_headers.dart'; import 'package:spotube/components/shared/tracks_view/sections/body/use_is_user_playlist.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/components/shared/tracks_view/track_view_provider.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/utils/service_utils.dart'; import 'package:very_good_infinite_list/very_good_infinite_list.dart'; @@ -131,16 +134,37 @@ class TrackViewBodySection extends HookConsumerWidget { return; } - if (isActive || playlist.tracks.contains(track)) { - await playlistNotifier.jumpToTrack(track); + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); + + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remoteQueue = ref.read(queueProvider); + if (remoteQueue.collections.contains(props.collectionId) || + remoteQueue.tracks.any((s) => s.id == track.id)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + collectionId: props.collectionId, + initialIndex: index, + ), + ); + } } else { - final tracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - tracks, - initialIndex: index, - autoPlay: true, - ); - playlistNotifier.addCollection(props.collectionId); + if (isActive || playlist.tracks.contains(track)) { + await playlistNotifier.jumpToTrack(track); + } else { + final tracks = await props.pagination.onFetchAll(); + await playlistNotifier.load( + tracks, + initialIndex: index, + autoPlay: true, + ); + playlistNotifier.addCollection(props.collectionId); + } } }, ); diff --git a/lib/components/shared/tracks_view/sections/header/header_buttons.dart b/lib/components/shared/tracks_view/sections/header/header_buttons.dart index 513f7aaa..f505f765 100644 --- a/lib/components/shared/tracks_view/sections/header/header_buttons.dart +++ b/lib/components/shared/tracks_view/sections/header/header_buttons.dart @@ -6,8 +6,11 @@ import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:palette_generator/palette_generator.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/tracks_view/track_view_props.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/services/audio_player/audio_player.dart'; @@ -43,13 +46,25 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load( - allTracks, - autoPlay: true, - initialIndex: Random().nextInt(allTracks.length), - ); - await audioPlayer.setShuffle(true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + initialIndex: Random().nextInt(allTracks.length)), + ); + await remotePlayback.setShuffle(true); + } else { + await playlistNotifier.load( + allTracks, + autoPlay: true, + initialIndex: Random().nextInt(allTracks.length), + ); + await audioPlayer.setShuffle(true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } @@ -61,8 +76,19 @@ class TrackViewHeaderButtons extends HookConsumerWidget { final allTracks = await props.pagination.onFetchAll(); - await playlistNotifier.load(allTracks, autoPlay: true); - playlistNotifier.addCollection(props.collectionId); + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + await remotePlayback.load( + WebSocketLoadEventData( + tracks: allTracks, + collectionId: props.collectionId, + ), + ); + } else { + await playlistNotifier.load(allTracks, autoPlay: true); + playlistNotifier.addCollection(props.collectionId); + } } finally { isLoading.value = false; } diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 28c4ced4..9e3b2938 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -316,5 +316,6 @@ "browse_anonymously": "Browse Anonymously", "enable_connect": "Enable Connect", "enable_connect_description": "Control Spotube from other devices", - "devices": "Devices" + "devices": "Devices", + "select": "Select" } \ No newline at end of file diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart index 1093e92b..2d7213b1 100644 --- a/lib/models/connect/ws_event.dart +++ b/lib/models/connect/ws_event.dart @@ -95,7 +95,11 @@ class WebSocketEvent { EventCallback callback, ) async { if (type == WsEvent.load) { - await callback(WebSocketLoadEvent.fromJson(data as Map)); + await callback( + WebSocketLoadEvent( + WebSocketLoadEventData.fromJson(data as Map), + ), + ); } } diff --git a/lib/pages/artist/section/top_tracks.dart b/lib/pages/artist/section/top_tracks.dart index f5b0cddf..9dec5f7c 100644 --- a/lib/pages/artist/section/top_tracks.dart +++ b/lib/pages/artist/section/top_tracks.dart @@ -4,8 +4,11 @@ import 'package:skeletonizer/skeletonizer.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/collections/fake.dart'; import 'package:spotube/collections/spotube_icons.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -39,16 +42,41 @@ class ArtistPageTopTracks extends HookConsumerWidget { void playPlaylist(List tracks, {Track? currentTrack}) async { currentTrack ??= tracks.first; - if (!isPlaylistPlaying) { - playlistNotifier.load( - tracks, - initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), - autoPlay: true, - ); - } else if (isPlaylistPlaying && - currentTrack.id != null && - currentTrack.id != playlist.activeTrack?.id) { - await playlistNotifier.jumpToTrack(currentTrack); + + final isRemoteDevice = await showSelectDeviceDialog(context, ref); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isPlaylistPlaying = remotePlaylist.containsTracks(tracks); + + if (!isPlaylistPlaying) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + ), + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != remotePlaylist.activeTrack?.id) { + final index = playlist.tracks + .toList() + .indexWhere((s) => s.id == currentTrack!.id); + await remotePlayback.jumpTo(index); + } + } else { + if (!isPlaylistPlaying) { + playlistNotifier.load( + tracks, + initialIndex: tracks.indexWhere((s) => s.id == currentTrack?.id), + autoPlay: true, + ); + } else if (isPlaylistPlaying && + currentTrack.id != null && + currentTrack.id != playlist.activeTrack?.id) { + await playlistNotifier.jumpToTrack(currentTrack); + } } } diff --git a/lib/pages/search/sections/tracks.dart b/lib/pages/search/sections/tracks.dart index a790c7fd..2152cc45 100644 --- a/lib/pages/search/sections/tracks.dart +++ b/lib/pages/search/sections/tracks.dart @@ -3,8 +3,11 @@ import 'package:flutter/material.dart' hide Page; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:spotify/spotify.dart'; import 'package:spotube/components/shared/dialogs/prompt_dialog.dart'; +import 'package:spotube/components/shared/dialogs/select_device_dialog.dart'; import 'package:spotube/components/shared/track_tile/track_tile.dart'; import 'package:spotube/extensions/context.dart'; +import 'package:spotube/models/connect/connect.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/spotify/spotify.dart'; @@ -48,25 +51,58 @@ class SearchTracksSection extends HookConsumerWidget { track: track, playlist: playlist, onTap: () async { - final isTrackPlaying = playlist.activeTrack?.id == track.id; - if (!isTrackPlaying && context.mounted) { - final shouldPlay = (playlist.tracks.length) > 20 - ? await showPromptDialog( - context: context, - title: context.l10n.playing_track( - track.name!, - ), - message: context.l10n.queue_clear_alert( - playlist.tracks.length, - ), - ) - : true; + final isRemoteDevice = + await showSelectDeviceDialog(context, ref); - if (shouldPlay) { - await playlistNotifier.load( - [track], - autoPlay: true, - ); + if (isRemoteDevice) { + final remotePlayback = ref.read(connectProvider.notifier); + final remotePlaylist = ref.read(queueProvider); + + final isTrackPlaying = + remotePlaylist.activeTrack?.id == track.id; + + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await remotePlayback.load( + WebSocketLoadEventData( + tracks: [track], + ), + ); + } + } + } else { + final isTrackPlaying = playlist.activeTrack?.id == track.id; + if (!isTrackPlaying && context.mounted) { + final shouldPlay = (playlist.tracks.length) > 20 + ? await showPromptDialog( + context: context, + title: context.l10n.playing_track( + track.name!, + ), + message: context.l10n.queue_clear_alert( + playlist.tracks.length, + ), + ) + : true; + + if (shouldPlay) { + await playlistNotifier.load( + [track], + autoPlay: true, + ); + } } } }, diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index db170131..7288711c 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -19,6 +19,7 @@ import 'package:bonsoir/bonsoir.dart'; import 'package:spotube/services/device_info/device_info.dart'; import 'package:spotube/utils/primitive_utils.dart'; import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:spotube/provider/volume_provider.dart'; final logger = getLogger('ConnectServer'); @@ -193,7 +194,7 @@ final connectServerProvider = FutureProvider((ref) async { }); event.onVolume((event) async { - await audioPlayer.setVolume(event.data); + ref.read(volumeProvider.notifier).setVolume(event.data); }); } catch (e, stackTrace) { Catcher2.reportCheckedError(e, stackTrace); diff --git a/untranslated_messages.json b/untranslated_messages.json index efaf3fed..dd59724a 100644 --- a/untranslated_messages.json +++ b/untranslated_messages.json @@ -2,109 +2,127 @@ "ar": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "bn": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "ca": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "de": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "es": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "fa": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "fr": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "hi": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "it": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "ja": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "ko": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "ne": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "nl": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "pl": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "pt": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "ru": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "tr": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "uk": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "vi": [ @@ -112,12 +130,14 @@ "no_lyrics_available", "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ], "zh": [ "enable_connect", "enable_connect_description", - "devices" + "devices", + "select" ] }