feat: add ability discover and connect to same network Spotube(s) and sync queue

This commit is contained in:
Kingkor Roy Tirtho 2024-03-25 00:03:40 +06:00
parent c399baa5ab
commit 351e833088
13 changed files with 366 additions and 9 deletions

View File

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

View File

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

View File

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

View File

@ -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<Spotube> {
final router = ref.watch(routerProvider);
ref.read(connectServerProvider);
ref.read(connectClientsProvider);
useDisableBatteryOptimizations();
useInitSysTray(ref);

View File

@ -47,8 +47,7 @@ class WebSocketEvent<T> {
EventCallback<WebSocketPositionEvent> callback,
) async {
if (type == WsEvent.position) {
await callback(
WebSocketPositionEvent.fromJson(data as Map<String, dynamic>));
await callback(WebSocketPositionEvent.fromJson({"data": data}));
}
}
@ -129,7 +128,8 @@ class WebSocketEvent<T> {
) async {
if (type == WsEvent.queue) {
await callback(
WebSocketQueueEvent.fromJson(data as Map<String, dynamic>));
WebSocketQueueEvent.fromJson(data as Map<String, dynamic>),
);
}
}
}
@ -186,6 +186,6 @@ class WebSocketQueueEvent extends WebSocketEvent<ProxyPlaylist> {
factory WebSocketQueueEvent.fromJson(Map<String, dynamic> json) =>
WebSocketQueueEvent(
ProxyPlaylist.fromJsonRaw(json["data"] as Map<String, dynamic>),
ProxyPlaylist.fromJsonRaw(json),
);
}

View File

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

View File

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

View File

@ -0,0 +1,99 @@
import 'package:bonsoir/bonsoir.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class ConnectClientsState {
final List<BonsoirService> services;
final ResolvedBonsoirService? resolvedService;
final BonsoirDiscovery discovery;
ConnectClientsState({
required this.services,
required this.discovery,
this.resolvedService,
});
ConnectClientsState copyWith({
List<BonsoirService>? services,
BonsoirDiscovery? discovery,
ResolvedBonsoirService? resolvedService,
}) {
return ConnectClientsState(
services: services ?? this.services,
discovery: discovery ?? this.discovery,
resolvedService: resolvedService ?? this.resolvedService,
);
}
}
class ConnectClientsNotifier extends AsyncNotifier<ConnectClientsState> {
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<void> resolveService(BonsoirService service) async {
if (state.value == null) return;
await service.resolve(state.value!.discovery.serviceResolver);
}
Future<void> clearResolvedService() async {
if (state.value == null) return;
state = AsyncData(
ConnectClientsState(
services: state.value!.services,
discovery: state.value!.discovery,
),
);
}
}
final connectClientsProvider =
AsyncNotifierProvider<ConnectClientsNotifier, ConnectClientsState>(
() => ConnectClientsNotifier(),
);

View File

@ -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<bool>.broadcast();
final playingProvider = StreamProvider.autoDispose<bool>(
(ref) => playingStreamController.stream,
);
final positionStreamController = StreamController<Duration>.broadcast();
final positionProvider = StreamProvider.autoDispose<Duration>(
(ref) => positionStreamController.stream,
);
class ConnectNotifier extends AsyncNotifier<WebSocketChannel?> {
@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, WebSocketChannel?>(
() => ConnectNotifier(),
);

View File

@ -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 = <StreamSubscription>[];
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);

View File

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

View File

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

View File

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