Implement WebSocket API client

- List ports
- Read and write mute state
- Read and write volume
This commit is contained in:
2024-02-29 11:49:49 +01:00
parent 280bee632d
commit 959796210d
8 changed files with 617 additions and 103 deletions

32
lib/error_page.dart Normal file
View File

@ -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<ErrorPage> createState() => _ErrorPageState();
}
class _ErrorPageState extends State<ErrorPage> {
@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),
)
],
),
);
}
}

View File

@ -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<GoMixHome> {
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<String> connectionError = ValueNotifier("");
void initWs() {
setState(() {
connectionError.value = "";
});
var connections = jsonDecode(prefs.getString("connections") ?? "{}")
as Map<String, dynamic>;
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<String, dynamic>;
// 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<String, Object> 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: <Widget>[
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: <Widget>[
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: <Widget>[
Padding(
padding: const EdgeInsets.all(8),
child: IconButton(
icon: const Icon(Icons.supervised_user_circle_outlined),
tooltip: 'Select Server',
onPressed: () {
Navigator.push(context, MaterialPageRoute<void>(
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: <Widget>[
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: <Widget>[
Padding(
padding: const EdgeInsets.all(8),
child: IconButton(
icon: const Icon(Icons.supervised_user_circle_outlined),
tooltip: 'Select Server',
onPressed: () => showDialog<String>(
context: context,
builder: (BuildContext context) => AlertDialog(
title: const Text('Select Instance'),
content: const Text("WIP"),
actions: <Widget>[
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 <Widget>[
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();
}),
]),
);
});
}
}

108
lib/mixer_state.dart Normal file
View File

@ -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<Port> inputs;
List<Port> outputs;
MixerState({
required this.inputs,
required this.outputs,
});
factory MixerState.fromJson(Map<String, dynamic> json) => MixerState(
inputs: List<Port>.from(json["inputs"].map((x) => Port.fromJson(x))),
outputs: List<Port>.from(json["outputs"].map((x) => Port.fromJson(x))),
);
Map<String, dynamic> toJson() => {
"inputs": List<dynamic>.from(inputs.map((x) => x.toJson())),
"outputs": List<dynamic>.from(outputs.map((x) => x.toJson())),
};
}
class Port {
String uuid;
String name;
Properties properties;
State state;
List<State> route;
Port({
required this.uuid,
required this.name,
required this.properties,
required this.state,
required this.route,
});
factory Port.fromJson(Map<String, dynamic> json) => Port(
uuid: json["UUID"],
name: json["name"],
properties: Properties.fromJson(json["properties"]),
state: State.fromJson(json["state"]),
route: List<State>.from(json["route"].map((x) => State.fromJson(x))),
);
Map<String, dynamic> toJson() => {
"UUID": uuid,
"name": name,
"properties": properties.toJson(),
"state": state.toJson(),
"route": List<dynamic>.from(route.map((x) => x.toJson())),
};
}
class Properties {
String backend;
int channels;
Properties({
required this.backend,
required this.channels,
});
factory Properties.fromJson(Map<String, dynamic> json) => Properties(
backend: json["backend"],
channels: json["channels"],
);
Map<String, dynamic> 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<String, dynamic> json) => State(
toUuid: json["toUUID"],
mute: json["mute"],
volume: json["volume"]?.toDouble(),
balance: json["balance"],
);
Map<String, dynamic> toJson() => {
"toUUID": toUuid,
"mute": mute,
"volume": volume,
"balance": balance,
};
}

View File

@ -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<MixingCard> createState() => _MixingCardState();
@ -10,6 +12,21 @@ class MixingCard extends StatefulWidget {
class _MixingCardState extends State<MixingCard> {
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<MixingCard> {
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}
});
},
),
),
],

View File

@ -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<MixingTab> createState() => _MixingTabState();
}
class _MixingTabState extends State<MixingTab> {
Widget buildCardGrid(
List<mixer.Port> 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<MixingTab> {
children: <Widget>[
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<Widget>.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),
],
),
),

View File

@ -5,6 +5,8 @@
import FlutterMacOS
import Foundation
import shared_preferences_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
}

View File

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

View File

@ -9,6 +9,8 @@ environment:
dependencies:
flutter:
sdk: flutter
shared_preferences: ^2.2.2
web_socket_channel: ^2.4.4
dev_dependencies:
flutter_test: