feat: add play in remote device support

This commit is contained in:
Kingkor Roy Tirtho 2024-03-28 21:11:51 +06:00
parent 940b79c880
commit 2f3a2e671d
13 changed files with 334 additions and 79 deletions

View File

@ -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;
}

View File

@ -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");
},

View File

@ -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),
],
),
),

View File

@ -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;

View File

@ -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<bool> showSelectDeviceDialog(BuildContext context, WidgetRef ref) async {
final connectClients = ref.read(connectClientsProvider);
if (connectClients.asData?.value.resolvedService == null) {
return false;
}
final isRemote = await showDialog<bool>(
context: context,
builder: (context) => const SelectDeviceDialog(),
);
return isRemote ?? false;
}

View File

@ -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);
}
}
},
);

View File

@ -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;
}

View File

@ -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"
}

View File

@ -95,7 +95,11 @@ class WebSocketEvent<T> {
EventCallback<WebSocketLoadEvent> callback,
) async {
if (type == WsEvent.load) {
await callback(WebSocketLoadEvent.fromJson(data as Map<String, dynamic>));
await callback(
WebSocketLoadEvent(
WebSocketLoadEventData.fromJson(data as Map<String, dynamic>),
),
);
}
}

View File

@ -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<Track> 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);
}
}
}

View File

@ -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,
);
}
}
}
},

View File

@ -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);

View File

@ -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"
]
}