From 351e8330883123bda834e14dcc310dc490637712 Mon Sep 17 00:00:00 2001 From: Kingkor Roy Tirtho Date: Mon, 25 Mar 2024 00:03:40 +0600 Subject: [PATCH] feat: add ability discover and connect to same network Spotube(s) and sync queue --- lib/collections/routes.dart | 7 ++ lib/collections/spotube_icons.dart | 2 + lib/components/root/bottom_player.dart | 2 + lib/main.dart | 3 + lib/models/connect/ws_event.dart | 8 +- lib/pages/connect/connect.dart | 61 +++++++++++ lib/pages/home/home.dart | 27 +++++ lib/provider/connect/clients.dart | 99 ++++++++++++++++++ lib/provider/connect/connect.dart | 121 ++++++++++++++++++++++ lib/provider/connect/server.dart | 18 +++- lib/services/device_info/device_info.dart | 21 ++++ pubspec.lock | 4 +- pubspec.yaml | 2 +- 13 files changed, 366 insertions(+), 9 deletions(-) create mode 100644 lib/pages/connect/connect.dart create mode 100644 lib/provider/connect/clients.dart create mode 100644 lib/provider/connect/connect.dart create mode 100644 lib/services/device_info/device_info.dart diff --git a/lib/collections/routes.dart b/lib/collections/routes.dart index 8428aaf3..1b5a7cee 100644 --- a/lib/collections/routes.dart +++ b/lib/collections/routes.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; 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/getting_started/getting_started.dart'; import 'package:spotube/pages/home/genres/genre_playlists.dart'; import 'package:spotube/pages/home/genres/genres.dart'; @@ -173,6 +174,12 @@ final routerProvider = Provider((ref) { ); }, ), + GoRoute( + path: "/connect", + pageBuilder: (context, state) => const SpotubePage( + child: ConnectPage(), + ), + ) ], ), GoRoute( diff --git a/lib/collections/spotube_icons.dart b/lib/collections/spotube_icons.dart index 8d497388..bee98958 100644 --- a/lib/collections/spotube_icons.dart +++ b/lib/collections/spotube_icons.dart @@ -117,4 +117,6 @@ abstract class SpotubeIcons { static const anonymous = FeatherIcons.user; static const history = FeatherIcons.clock; static const connect = FeatherIcons.link; + static const speaker = FeatherIcons.speaker; + static const monitor = FeatherIcons.monitor; } diff --git a/lib/components/root/bottom_player.dart b/lib/components/root/bottom_player.dart index 16633f7c..73df34c1 100644 --- a/lib/components/root/bottom_player.dart +++ b/lib/components/root/bottom_player.dart @@ -19,6 +19,7 @@ import 'package:spotube/hooks/utils/use_brightness_value.dart'; import 'package:spotube/models/logger.dart'; import 'package:flutter/material.dart'; import 'package:spotube/provider/authentication_provider.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/proxy_playlist/proxy_playlist_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_state.dart'; @@ -34,6 +35,7 @@ class BottomPlayer extends HookConsumerWidget { final playlist = ref.watch(ProxyPlaylistNotifier.provider); final layoutMode = ref.watch(userPreferencesProvider.select((s) => s.layoutMode)); + final remoteControl = ref.watch(connectProvider); final mediaQuery = MediaQuery.of(context); diff --git a/lib/main.dart b/lib/main.dart index 65e21d51..ce96c56b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -23,6 +23,8 @@ import 'package:spotube/l10n/l10n.dart'; import 'package:spotube/models/logger.dart'; import 'package:spotube/models/skip_segment.dart'; import 'package:spotube/models/source_match.dart'; +import 'package:spotube/provider/connect/clients.dart'; +import 'package:spotube/provider/connect/connect.dart'; import 'package:spotube/provider/connect/server.dart'; import 'package:spotube/provider/palette_provider.dart'; import 'package:spotube/provider/user_preferences/user_preferences_provider.dart'; @@ -182,6 +184,7 @@ class SpotubeState extends ConsumerState { final router = ref.watch(routerProvider); ref.read(connectServerProvider); + ref.read(connectClientsProvider); useDisableBatteryOptimizations(); useInitSysTray(ref); diff --git a/lib/models/connect/ws_event.dart b/lib/models/connect/ws_event.dart index 7f72acb9..c9eb2fcb 100644 --- a/lib/models/connect/ws_event.dart +++ b/lib/models/connect/ws_event.dart @@ -47,8 +47,7 @@ class WebSocketEvent { EventCallback callback, ) async { if (type == WsEvent.position) { - await callback( - WebSocketPositionEvent.fromJson(data as Map)); + await callback(WebSocketPositionEvent.fromJson({"data": data})); } } @@ -129,7 +128,8 @@ class WebSocketEvent { ) async { if (type == WsEvent.queue) { await callback( - WebSocketQueueEvent.fromJson(data as Map)); + WebSocketQueueEvent.fromJson(data as Map), + ); } } } @@ -186,6 +186,6 @@ class WebSocketQueueEvent extends WebSocketEvent { factory WebSocketQueueEvent.fromJson(Map json) => WebSocketQueueEvent( - ProxyPlaylist.fromJsonRaw(json["data"] as Map), + ProxyPlaylist.fromJsonRaw(json), ); } diff --git a/lib/pages/connect/connect.dart b/lib/pages/connect/connect.dart new file mode 100644 index 00000000..456a9779 --- /dev/null +++ b/lib/pages/connect/connect.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; +import 'package:gap/gap.dart'; +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'; + +class ConnectPage extends HookConsumerWidget { + const ConnectPage({super.key}); + + @override + Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); + + final connectClients = ref.watch(connectClientsProvider); + final connectClientsNotifier = ref.read(connectClientsProvider.notifier); + final discoveredDevices = connectClients.asData?.value.services; + + return Scaffold( + appBar: const PageWindowTitleBar( + automaticallyImplyLeading: true, + title: Text("Devices"), + ), + body: ListView.separated( + padding: const EdgeInsets.all(10), + itemCount: discoveredDevices?.length ?? 0, + separatorBuilder: (context, index) => const Gap(10), + itemBuilder: (context, index) { + final device = discoveredDevices![index]; + final selected = + connectClients.asData?.value.resolvedService?.name == device.name; + return Card( + child: ListTile( + leading: const Icon(SpotubeIcons.monitor), + title: Text(device.name), + subtitle: selected + ? Text( + "${connectClients.asData?.value.resolvedService?.host}" + ":${connectClients.asData?.value.resolvedService?.port}", + ) + : null, + selected: selected, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10), + ), + selectedTileColor: colorScheme.secondary.withOpacity(0.1), + onTap: () { + if (selected) { + connectClientsNotifier.clearResolvedService(); + } else { + connectClientsNotifier.resolveService(device); + } + }, + trailing: selected ? const Icon(SpotubeIcons.done) : null, + ), + ); + }, + ), + ); + } +} diff --git a/lib/pages/home/home.dart b/lib/pages/home/home.dart index ed297065..1217f7af 100644 --- a/lib/pages/home/home.dart +++ b/lib/pages/home/home.dart @@ -3,18 +3,22 @@ import 'package:flutter_desktop_tools/flutter_desktop_tools.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:gap/gap.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:spotube/collections/spotube_icons.dart'; import 'package:spotube/components/home/sections/featured.dart'; import 'package:spotube/components/home/sections/friends.dart'; import 'package:spotube/components/home/sections/genres.dart'; import 'package:spotube/components/home/sections/made_for_user.dart'; import 'package:spotube/components/home/sections/new_releases.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 HomePage extends HookConsumerWidget { const HomePage({super.key}); @override Widget build(BuildContext context, ref) { + final ThemeData(:colorScheme) = Theme.of(context); final controller = useScrollController(); return SafeArea( @@ -29,6 +33,29 @@ class HomePage extends HookConsumerWidget { slivers: [ if (DesktopTools.platform.isMacOS || DesktopTools.platform.isWeb) const SliverGap(20), + SliverAppBar( + actions: [ + Consumer( + builder: (context, ref, _) { + final connectClients = ref.watch(connectClientsProvider); + + return IconButton( + icon: const Icon(SpotubeIcons.speaker), + style: connectClients.asData?.value.resolvedService != + null + ? IconButton.styleFrom( + backgroundColor: colorScheme.primaryContainer, + foregroundColor: colorScheme.primary, + ) + : null, + onPressed: () { + ServiceUtils.push(context, "/connect"); + }, + ); + }, + ) + ], + ), const HomeGenresSection(), const SliverToBoxAdapter(child: HomeFeaturedSection()), const HomePageFriendsSection(), diff --git a/lib/provider/connect/clients.dart b/lib/provider/connect/clients.dart new file mode 100644 index 00000000..246a488f --- /dev/null +++ b/lib/provider/connect/clients.dart @@ -0,0 +1,99 @@ +import 'package:bonsoir/bonsoir.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class ConnectClientsState { + final List services; + final ResolvedBonsoirService? resolvedService; + final BonsoirDiscovery discovery; + + ConnectClientsState({ + required this.services, + required this.discovery, + this.resolvedService, + }); + + ConnectClientsState copyWith({ + List? services, + BonsoirDiscovery? discovery, + ResolvedBonsoirService? resolvedService, + }) { + return ConnectClientsState( + services: services ?? this.services, + discovery: discovery ?? this.discovery, + resolvedService: resolvedService ?? this.resolvedService, + ); + } +} + +class ConnectClientsNotifier extends AsyncNotifier { + ConnectClientsNotifier(); + + @override + build() async { + final discovery = BonsoirDiscovery(type: '_spotube._tcp'); + await discovery.ready; + + final subscription = discovery.eventStream?.listen((event) { + switch (event.type) { + case BonsoirDiscoveryEventType.discoveryServiceFound: + state = AsyncData(state.value!.copyWith( + services: [ + ...?state.value?.services, + event.service!, + ], + )); + break; + case BonsoirDiscoveryEventType.discoveryServiceResolved: + state = AsyncData( + state.value!.copyWith( + resolvedService: event.service as ResolvedBonsoirService, + ), + ); + break; + case BonsoirDiscoveryEventType.discoveryServiceLost: + state = AsyncData( + state.value!.copyWith( + services: state.value!.services + .where((service) => service.name != event.service!.name) + .toList(), + ), + ); + break; + default: + break; + } + }); + + ref.onDispose(() { + subscription?.cancel(); + discovery.stop(); + }); + + await discovery.start(); + + return ConnectClientsState( + services: [], + discovery: discovery, + ); + } + + Future resolveService(BonsoirService service) async { + if (state.value == null) return; + await service.resolve(state.value!.discovery.serviceResolver); + } + + Future clearResolvedService() async { + if (state.value == null) return; + state = AsyncData( + ConnectClientsState( + services: state.value!.services, + discovery: state.value!.discovery, + ), + ); + } +} + +final connectClientsProvider = + AsyncNotifierProvider( + () => ConnectClientsNotifier(), +); diff --git a/lib/provider/connect/connect.dart b/lib/provider/connect/connect.dart new file mode 100644 index 00000000..be31b9e3 --- /dev/null +++ b/lib/provider/connect/connect.dart @@ -0,0 +1,121 @@ +import 'dart:async'; +import 'dart:convert'; + +import 'package:catcher_2/catcher_2.dart'; +import 'package:flutter_riverpod/flutter_riverpod.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: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 positionStreamController = StreamController.broadcast(); +final positionProvider = StreamProvider.autoDispose( + (ref) => positionStreamController.stream, +); + +class ConnectNotifier extends AsyncNotifier { + @override + build() async { + try { + final connectClients = ref.watch(connectClientsProvider); + print('Building ConnectNotifier'); + + if (connectClients.asData?.value.resolvedService == null) return null; + + final service = connectClients.asData!.value.resolvedService!; + + print( + 'Connecting to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final channel = WebSocketChannel.connect( + Uri.parse('ws://${service.host}:${service.port}/ws'), + ); + + await channel.ready; + + print( + 'Connected to ${service.name}: ws://${service.host}:${service.port}/ws'); + + final subscription = channel.stream.listen( + (message) { + final event = + WebSocketEvent.fromJson(jsonDecode(message), (data) => data); + + event.onQueue((event) { + ref.read(ProxyPlaylistNotifier.notifier).state = event.data; + }); + + event.onPlaying((event) { + playingStreamController.add(event.data); + }); + + event.onPosition((event) { + positionStreamController.add(event.data); + }); + }, + onError: (error) { + Catcher2.reportCheckedError( + error, + StackTrace.current, + ); + }, + ); + + ref.onDispose(() { + subscription.cancel(); + channel.sink.close(status.goingAway); + }); + + return channel; + } catch (e, stack) { + Catcher2.reportCheckedError(e, stack); + rethrow; + } + } + + void emit(Object message) { + if (state.value == null) return; + state.value?.sink.add( + message is String ? message : (message as dynamic).toJson(), + ); + } + + void resume() { + emit(WebSocketResumeEvent()); + } + + void pause() { + emit(WebSocketPauseEvent()); + } + + void stop() { + emit(WebSocketStopEvent()); + } + + void jumpTo(int position) { + emit(WebSocketJumpEvent(position)); + } + + void load(WebSocketLoadEventData data) { + emit(WebSocketLoadEvent(data)); + } + + void next() { + emit(WebSocketNextEvent()); + } + + void previous() { + emit(WebSocketPreviousEvent()); + } +} + +final connectProvider = + AsyncNotifierProvider( + () => ConnectNotifier(), +); diff --git a/lib/provider/connect/server.dart b/lib/provider/connect/server.dart index aa9cb15f..78844ef8 100644 --- a/lib/provider/connect/server.dart +++ b/lib/provider/connect/server.dart @@ -4,6 +4,7 @@ import 'dart:io'; import 'dart:math'; import 'package:catcher_2/catcher_2.dart'; +import 'package:shelf/shelf.dart'; import 'package:shelf/shelf_io.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shelf_router/shelf_router.dart'; @@ -14,6 +15,8 @@ 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'; 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'; final logger = getLogger('ConnectServer'); @@ -29,6 +32,13 @@ final connectServerProvider = FutureProvider((ref) async { final app = Router(); + app.get( + "/ping", + (Request req) { + return Response.ok("pong"); + }, + ); + final subscriptions = []; final websocket = webSocketHandler( @@ -40,6 +50,7 @@ final connectServerProvider = FutureProvider((ref) async { WebSocketQueueEvent(next).toJson(), ); }, + fireImmediately: true, ); subscriptions.addAll([ @@ -122,16 +133,19 @@ final connectServerProvider = FutureProvider((ref) async { } return app(request); }, - InternetAddress.loopbackIPv4, + InternetAddress.anyIPv4, port, ); logger.i('Server running on http://${server.address.host}:${server.port}'); final service = BonsoirService( - name: 'Spotube', + name: await DeviceInfoService.instance.computerName(), type: '_spotube._tcp', port: port, + attributes: { + "id": PrimitiveUtils.uuid.v4(), + }, ); final broadcast = BonsoirBroadcast(service: service); diff --git a/lib/services/device_info/device_info.dart b/lib/services/device_info/device_info.dart new file mode 100644 index 00000000..156b5e6e --- /dev/null +++ b/lib/services/device_info/device_info.dart @@ -0,0 +1,21 @@ +import 'package:device_info_plus/device_info_plus.dart'; + +class DeviceInfoService { + final DeviceInfoPlugin deviceInfo; + DeviceInfoService._() : deviceInfo = DeviceInfoPlugin(); + + static final instance = DeviceInfoService._(); + + Future computerName() async { + final info = await deviceInfo.deviceInfo; + + return switch (info) { + AndroidDeviceInfo() => info.model, + IosDeviceInfo() => info.localizedModel, + MacOsDeviceInfo() => info.computerName, + WindowsDeviceInfo() => info.computerName, + LinuxDeviceInfo() => info.name, + _ => 'Unknown', + }; + } +} diff --git a/pubspec.lock b/pubspec.lock index adf30b4f..f97ff537 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -542,10 +542,10 @@ packages: dependency: "direct main" description: name: device_info_plus - sha256: "86add5ef97215562d2e090535b0a16f197902b10c369c558a100e74ea06e8659" + sha256: "77f757b789ff68e4eaf9c56d1752309bd9f7ad557cb105b938a7f8eb89e59110" url: "https://pub.dev" source: hosted - version: "9.0.3" + version: "9.1.2" device_info_plus_platform_interface: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9e869164..254ae242 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,7 +25,7 @@ dependencies: cupertino_icons: ^1.0.5 curved_navigation_bar: ^1.0.3 dbus: ^0.7.8 - device_info_plus: ^9.0.3 + device_info_plus: ^9.1.2 device_preview: ^1.1.0 dio: ^5.4.1 disable_battery_optimization: ^1.1.0+1