From 959796210d39ccc23b590342026ac5433d166aeb Mon Sep 17 00:00:00 2001 From: minie4 Date: Thu, 29 Feb 2024 11:49:49 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Implement=20WebSocket=20API=20clien?= =?UTF-8?q?t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - List ports - Read and write mute state - Read and write volume --- lib/error_page.dart | 32 +++ lib/main.dart | 259 +++++++++++++----- lib/mixer_state.dart | 108 ++++++++ lib/mixing_card.dart | 110 ++++++-- lib/mixing_tab.dart | 41 ++- macos/Flutter/GeneratedPluginRegistrant.swift | 2 + pubspec.lock | 166 +++++++++++ pubspec.yaml | 2 + 8 files changed, 617 insertions(+), 103 deletions(-) create mode 100644 lib/error_page.dart create mode 100644 lib/mixer_state.dart diff --git a/lib/error_page.dart b/lib/error_page.dart new file mode 100644 index 0000000..0648576 --- /dev/null +++ b/lib/error_page.dart @@ -0,0 +1,32 @@ +import 'package:flutter/material.dart'; + +class ErrorPage extends StatefulWidget { + final String message; + const ErrorPage({super.key, this.message = "Something went wrong!"}); + + @override + State createState() => _ErrorPageState(); +} + +class _ErrorPageState extends State { + @override + Widget build(BuildContext context) { + return Center( + child: Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon( + Icons.cloud_off_outlined, + size: 48, + ), + const SizedBox(height: 10), + Text( + widget.message, + style: const TextStyle(fontSize: 16), + ) + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index 1516177..ec66ced 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,14 @@ +import 'dart:convert'; import 'dart:math'; import 'package:flutter/material.dart'; +import 'package:gomix_flutter/mixer_state.dart' as mixer; import 'package:gomix_flutter/mixing_tab.dart'; import 'package:gomix_flutter/ports_tab.dart'; import 'package:gomix_flutter/settings_tab.dart'; +import 'package:gomix_flutter/error_page.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:web_socket_channel/web_socket_channel.dart'; void main() => runApp(const GoMixClient()); @@ -39,80 +44,204 @@ class GoMixHome extends StatefulWidget { class _GoMixHomeState extends State { int currentPageIndex = 0; + mixer.MixerState mixerState = mixer.MixerState(inputs: [], outputs: []); + + WebSocketChannel? ws; + late final SharedPreferences prefs; + late final prefsFuture = + SharedPreferences.getInstance().then((e) => {prefs = e, initWs()}); + + ValueNotifier connectionError = ValueNotifier(""); + + void initWs() { + setState(() { + connectionError.value = ""; + }); + + var connections = jsonDecode(prefs.getString("connections") ?? "{}") + as Map; + var currentConnection = prefs.getString("selectedConnection"); + var wsUrl = (connections[currentConnection] ?? {"url": ""})["url"]; + + if (wsUrl == "") { + connectionError.value = + "No GoMix connection available! Please add a connection in the server selector."; + + return; + } + + ws = WebSocketChannel.connect( + Uri.parse(wsUrl), + ); + + // Listen to incoming websocket messages + ws?.stream.listen((message) { + final data = jsonDecode(message) as Map; + + // Update complete port list + if ((data["type"] == "response" && + data["requestMethod"] == "listPorts") || + data["type"] == "portListChange") { + setState(() { + mixerState = + mixer.mixerStateFromJson(jsonEncode(data["responseData"])); + }); + } + // Update a single port + if (data["type"] == "portChange") { + int index = mixerState.inputs.indexWhere( + (element) => element.uuid == data["responseData"]["UUID"]); + int index2 = mixerState.outputs.indexWhere( + (element) => element.uuid == data["responseData"]["UUID"]); + + mixer.Port newPort = mixer.Port.fromJson(data["responseData"]); + setState(() { + if (index >= 0) { + mixerState.inputs[index] = newPort; + } else if (index2 >= 0) { + mixerState.outputs[index2] = newPort; + } + }); + } + }, onError: (err) { + connectionError.value = err.toString(); + }); + + // Request the current port list + ws?.sink.add(json.encode({"method": "listPorts"})); + // Get notified about port changes + ws?.sink.add(json.encode({"method": "enableUpdates"})); + } + + void sendAction(Map data) { + ws?.sink.add(json.encode(data)); + } @override Widget build(BuildContext context) { final ThemeData theme = Theme.of(context); - return Scaffold( - bottomNavigationBar: NavigationBar( - onDestinationSelected: (int index) { - setState(() { - currentPageIndex = index; - }); - }, - selectedIndex: currentPageIndex, - indicatorColor: theme.colorScheme.secondaryContainer, - destinations: [ - NavigationDestination( - icon: Transform.rotate( + return FutureBuilder( + future: prefsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Scaffold( + body: Center(child: CircularProgressIndicator())); + } + + return Scaffold( + bottomNavigationBar: NavigationBar( + onDestinationSelected: (int index) { + setState(() { + currentPageIndex = index; + }); + }, + selectedIndex: currentPageIndex, + indicatorColor: theme.colorScheme.secondaryContainer, + destinations: [ + NavigationDestination( + icon: Transform.rotate( angle: 90 * pi / 180, child: const Icon(Icons.tune_outlined)), - label: 'Mixing', + label: 'Mixing', tooltip: "", - ), - const NavigationDestination( - icon: Icon(Icons.settings_input_component_outlined), - label: 'Ports', + ), + const NavigationDestination( + icon: Icon(Icons.settings_input_component_outlined), + label: 'Ports', tooltip: "", - ), - const NavigationDestination( - icon: Icon(Icons.settings_outlined), - label: 'Settings', + ), + const NavigationDestination( + icon: Icon(Icons.settings_outlined), + label: 'Settings', tooltip: "", - ), - ], - ), - appBar: AppBar( - title: const Text('go-mix Audio Mixer'), - centerTitle: true, - leading: IconButton( - icon: const Icon(Icons.check_circle_outline), - color: theme.colorScheme.inversePrimary, - tooltip: 'Connected', - onPressed: () {}, - ), - actions: [ - Padding( - padding: const EdgeInsets.all(8), - child: IconButton( - icon: const Icon(Icons.supervised_user_circle_outlined), - tooltip: 'Select Server', - onPressed: () { - Navigator.push(context, MaterialPageRoute( - builder: (BuildContext context) { - return Scaffold( - appBar: AppBar( - title: const Text('Next page'), - ), - body: const Center( - child: Text( - 'This is the next page', - style: TextStyle(fontSize: 24), - ), - ), - ); - }, - )); - }, + ), + ], ), - ), - ], - ), - body: [ - const MixingTab(), - const PortsTab(), - const SettingsTab(), - ][currentPageIndex], - ); + appBar: AppBar( + title: const Text('go-mix Audio Mixer'), + centerTitle: true, + leading: IconButton( + icon: const Icon(Icons.check_circle_outline), + color: theme.colorScheme.inversePrimary, + tooltip: 'Connected', + onPressed: () {}, + ), + actions: [ + Padding( + padding: const EdgeInsets.all(8), + child: IconButton( + icon: const Icon(Icons.supervised_user_circle_outlined), + tooltip: 'Select Server', + onPressed: () => showDialog( + context: context, + builder: (BuildContext context) => AlertDialog( + title: const Text('Select Instance'), + content: const Text("WIP"), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context, 'Cancel'), + child: const Text('Cancel'), + ), + ], + ), + ), + ), + ), + ], + ), + body: Column(children: [ + Expanded( + child: FutureBuilder( + future: ws?.ready, + builder: (context, snapshot) { + if (connectionError.value != "") { + return const ErrorPage( + message: + "Could not connect to the GoMix instance!"); + } + + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + + return [ + MixingTab( + mixerState: mixerState, + sendAction: sendAction, + ), + const PortsTab(), + const SettingsTab(), + ][currentPageIndex]; + }), + ), + ValueListenableBuilder( + valueListenable: connectionError, + builder: (ctx, value, child) { + if (connectionError.value == "") { + return const SizedBox(); + } + + Future.delayed(const Duration(seconds: 0), () { + showDialog( + context: ctx, + builder: (ctx) { + return AlertDialog( + title: const Text('Connection error'), + content: Text(connectionError.value), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Ok'), + ), + ], + ); + }); + }); + + return const SizedBox(); + }), + ]), + ); + }); } } diff --git a/lib/mixer_state.dart b/lib/mixer_state.dart new file mode 100644 index 0000000..a2d3274 --- /dev/null +++ b/lib/mixer_state.dart @@ -0,0 +1,108 @@ +// Converted with: https://app.quicktype.io/?l=dart + +import 'dart:convert'; + +MixerState mixerStateFromJson(String str) => + MixerState.fromJson(json.decode(str)); + +String mixerStateToJson(MixerState data) => json.encode(data.toJson()); + +class MixerState { + List inputs; + List outputs; + + MixerState({ + required this.inputs, + required this.outputs, + }); + + factory MixerState.fromJson(Map json) => MixerState( + inputs: List.from(json["inputs"].map((x) => Port.fromJson(x))), + outputs: List.from(json["outputs"].map((x) => Port.fromJson(x))), + ); + + Map toJson() => { + "inputs": List.from(inputs.map((x) => x.toJson())), + "outputs": List.from(outputs.map((x) => x.toJson())), + }; +} + +class Port { + String uuid; + String name; + Properties properties; + State state; + List route; + + Port({ + required this.uuid, + required this.name, + required this.properties, + required this.state, + required this.route, + }); + + factory Port.fromJson(Map json) => Port( + uuid: json["UUID"], + name: json["name"], + properties: Properties.fromJson(json["properties"]), + state: State.fromJson(json["state"]), + route: List.from(json["route"].map((x) => State.fromJson(x))), + ); + + Map toJson() => { + "UUID": uuid, + "name": name, + "properties": properties.toJson(), + "state": state.toJson(), + "route": List.from(route.map((x) => x.toJson())), + }; +} + +class Properties { + String backend; + int channels; + + Properties({ + required this.backend, + required this.channels, + }); + + factory Properties.fromJson(Map json) => Properties( + backend: json["backend"], + channels: json["channels"], + ); + + Map toJson() => { + "backend": backend, + "channels": channels, + }; +} + +class State { + String? toUuid; + bool mute; + double volume; + int balance; + + State({ + this.toUuid, + required this.mute, + required this.volume, + required this.balance, + }); + + factory State.fromJson(Map json) => State( + toUuid: json["toUUID"], + mute: json["mute"], + volume: json["volume"]?.toDouble(), + balance: json["balance"], + ); + + Map toJson() => { + "toUUID": toUuid, + "mute": mute, + "volume": volume, + "balance": balance, + }; +} diff --git a/lib/mixing_card.dart b/lib/mixing_card.dart index 08f79b1..734ccd5 100644 --- a/lib/mixing_card.dart +++ b/lib/mixing_card.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; +import 'package:gomix_flutter/mixer_state.dart' as mixer; class MixingCard extends StatefulWidget { - final String name; - const MixingCard({super.key, this.name = "Unknown"}); + final mixer.Port port; + final Function sendAction; + const MixingCard({super.key, required this.port, required this.sendAction}); @override State createState() => _MixingCardState(); @@ -10,6 +12,21 @@ class MixingCard extends StatefulWidget { class _MixingCardState extends State { double _sliderValue = 0; + int _debounceTimer = 0; + bool _sliderActive = false; + + @override + void initState() { + super.initState(); + _sliderValue = widget.port.state.volume; + } + + @override + void didUpdateWidget(MixingCard oldWidget) { + if (_sliderActive) return; + _sliderValue = widget.port.state.volume; + super.didUpdateWidget(oldWidget); + } @override Widget build(BuildContext context) { @@ -26,34 +43,75 @@ class _MixingCardState extends State { padding: const EdgeInsets.only(left: 15, right: 15, top: 15), child: Row( children: [ - Expanded(child: Text(widget.name, style: labelStyle)), - IconButton.filledTonal( - isSelected: true, - onPressed: () {}, - icon: const Icon(Icons.mic_off_outlined)), + Expanded(child: Text(widget.port.name, style: labelStyle)), + SizedBox( + height: 40, + width: 40, + child: IconButton.filledTonal( + iconSize: 20, + isSelected: widget.port.state.mute, + onPressed: () { + widget.sendAction({ + "method": "setPortState", + "UUID": widget.port.uuid, + "stateData": {"mute": !widget.port.state.mute} + }); + }, + icon: widget.port.state.mute + ? const Icon(Icons.mic_off_outlined) + : const Icon(Icons.mic_none_outlined))), const SizedBox(width: 5), - IconButton.outlined( - isSelected: false, - onPressed: () {}, - icon: const Icon(Icons.unfold_more)) + SizedBox( + height: 40, + width: 40, + child: IconButton.outlined( + iconSize: 20, + isSelected: false, + onPressed: () {}, + icon: const Icon(Icons.unfold_more))) ], ), ), - Transform.translate( - offset: Offset.fromDirection(0, 0), - child: SizedBox( - width: MediaQuery.of(context).size.width + 180, - child: Slider( - value: _sliderValue, - secondaryTrackValue: 1, - min: 0, - max: 4, - label: _sliderValue.toStringAsFixed(2), - onChanged: (val) { - setState(() { - _sliderValue = val; - }); - }), + SizedBox( + width: MediaQuery.of(context).size.width + 180, + child: Slider( + value: _sliderValue, + min: 0, + max: 4, + label: _sliderValue.toStringAsFixed(2), + onChangeStart: ((val) { + _sliderActive = true; + }), + onChangeEnd: ((val) { + // Send the current state to make sure we + // don't miss the last value due to the debounce + widget.sendAction({ + "method": "setPortState", + "UUID": widget.port.uuid, + "stateData": {"volume": val} + }); + // Make sure the slider value is still correct + // after suppressing updates + _sliderActive = false; + setState(() { + _sliderValue = widget.port.state.volume; + }); + }), + onChanged: (val) { + setState(() { + _sliderValue = val; + }); + if (DateTime.now().millisecondsSinceEpoch - _debounceTimer < + 30) { + return; + } + _debounceTimer = DateTime.now().millisecondsSinceEpoch; + widget.sendAction({ + "method": "setPortState", + "UUID": widget.port.uuid, + "stateData": {"volume": val} + }); + }, ), ), ], diff --git a/lib/mixing_tab.dart b/lib/mixing_tab.dart index c8b2666..df17e4e 100644 --- a/lib/mixing_tab.dart +++ b/lib/mixing_tab.dart @@ -1,19 +1,40 @@ import 'package:flutter/material.dart'; import 'package:gomix_flutter/mixing_card.dart'; +import 'package:gomix_flutter/mixer_state.dart' as mixer; class MixingTab extends StatefulWidget { - const MixingTab({super.key}); + final mixer.MixerState mixerState; + final Function sendAction; + const MixingTab( + {super.key, required this.mixerState, required this.sendAction}); @override State createState() => _MixingTabState(); } class _MixingTabState extends State { + Widget buildCardGrid( + List from, int cols, double cardGridAspectRatio) { + return GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + childAspectRatio: cardGridAspectRatio, + crossAxisCount: cols, + mainAxisSpacing: 10, + crossAxisSpacing: 10, + children: from + .map((p) => MixingCard(port: p, sendAction: widget.sendAction)) + .toList(), + ); + } + @override Widget build(BuildContext context) { double screenWidth = MediaQuery.of(context).size.width; int cols = 1 + (screenWidth ~/ 550); int cardHeight = 110; + double cardGridAspectRatio = + (screenWidth - 32 - (10 * (cols - 1))) / cardHeight / cols; return SizedBox.expand( child: SingleChildScrollView( @@ -24,17 +45,13 @@ class _MixingTabState extends State { children: [ Text("Inputs", style: Theme.of(context).textTheme.titleLarge), const SizedBox(height: 10), - GridView.count( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - childAspectRatio: - (screenWidth - 32 - (10 * (cols - 1))) / cardHeight / cols, - crossAxisCount: cols, - mainAxisSpacing: 10, - crossAxisSpacing: 10, - children: List.generate( - 6, (i) => MixingCard(name: "Port ${i.toString()}")), - ), + buildCardGrid( + widget.mixerState.inputs, cols, cardGridAspectRatio), + const SizedBox(height: 20), + Text("Outputs", style: Theme.of(context).textTheme.titleLarge), + const SizedBox(height: 10), + buildCardGrid( + widget.mixerState.outputs, cols, cardGridAspectRatio), ], ), ), diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index cccf817..724bb2a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -5,6 +5,8 @@ import FlutterMacOS import Foundation +import shared_preferences_foundation func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index f18cfd8..6b7b017 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.18.0" + crypto: + dependency: transitive + description: + name: crypto + sha256: ff625774173754681d66daaf4a448684fb04b78f902da9cb3d308c19cc5e8bab + url: "https://pub.dev" + source: hosted + version: "3.0.3" fake_async: dependency: transitive description: @@ -49,6 +57,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "493f37e7df1804778ff3a53bd691d8692ddf69702cf4c1c1096a2e41b4779e21" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + file: + dependency: transitive + description: + name: file + sha256: "5fc22d7c25582e38ad9a8515372cd9a93834027aacf1801cf01164dac0ffa08c" + url: "https://pub.dev" + source: hosted + version: "7.0.0" flutter: dependency: "direct main" description: flutter @@ -67,6 +91,11 @@ packages: description: flutter source: sdk version: "0.0.0" + flutter_web_plugins: + dependency: transitive + description: flutter + source: sdk + version: "0.0.0" leak_tracker: dependency: transitive description: @@ -131,6 +160,102 @@ packages: url: "https://pub.dev" source: hosted version: "1.9.0" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.dev" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.dev" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.dev" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.dev" + source: hosted + version: "2.1.8" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: "81429e4481e1ccfb51ede496e916348668fd0921627779233bd24cc3ff6abd02" + url: "https://pub.dev" + source: hosted + version: "2.2.2" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "8568a389334b6e83415b6aae55378e158fbc2314e074983362d20c562780fb06" + url: "https://pub.dev" + source: hosted + version: "2.2.1" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "7708d83064f38060c7b39db12aefe449cb8cdc031d6062280087bc4cdb988f5c" + url: "https://pub.dev" + source: hosted + version: "2.3.5" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "9f2cbcf46d4270ea8be39fa156d86379077c8a5228d9dfdb1164ae0bb93f1faa" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "22e2ecac9419b4246d7c22bfbbda589e3acf5c0351137d87dd2939d984d37c3b" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: "9aee1089b36bd2aafe06582b7d7817fd317ef05fc30e6ba14bff247d0933042a" + url: "https://pub.dev" + source: hosted + version: "2.3.0" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "841ad54f3c8381c480d0c9b508b89a34036f512482c407e6df7a9c4aa2ef8f59" + url: "https://pub.dev" + source: hosted + version: "2.3.2" sky_engine: dependency: transitive description: flutter @@ -184,6 +309,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.dev" + source: hosted + version: "1.3.2" vector_math: dependency: transitive description: @@ -200,5 +333,38 @@ packages: url: "https://pub.dev" source: hosted version: "13.0.0" + web: + dependency: transitive + description: + name: web + sha256: "1d9158c616048c38f712a6646e317a3426da10e884447626167240d45209cbad" + url: "https://pub.dev" + source: hosted + version: "0.5.0" + web_socket_channel: + dependency: "direct main" + description: + name: web_socket_channel + sha256: "1d8e795e2a8b3730c41b8a98a2dff2e0fb57ae6f0764a1c46ec5915387d257b2" + url: "https://pub.dev" + source: hosted + version: "2.4.4" + win32: + dependency: transitive + description: + name: win32 + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + url: "https://pub.dev" + source: hosted + version: "5.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.dev" + source: hosted + version: "1.0.4" sdks: dart: ">=3.3.0 <4.0.0" + flutter: ">=3.19.0" diff --git a/pubspec.yaml b/pubspec.yaml index 259c296..29e197d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -9,6 +9,8 @@ environment: dependencies: flutter: sdk: flutter + shared_preferences: ^2.2.2 + web_socket_channel: ^2.4.4 dev_dependencies: flutter_test: