MyWhoosh Link implementation #2

This commit is contained in:
Jonas Bark
2025-10-22 09:35:41 +02:00
parent a74471b9f8
commit 1284499c25
21 changed files with 345 additions and 110 deletions

View File

@@ -1,3 +1,10 @@
### 3.2.0 (2025-10-22)
- a brand-new way of controlling MyWhoosh:
- device pairing no longer required as mouse emulation is no longer needed
- SwiftControl can now stay in the background
- more devices can be controlled
- do more, such as define Emotes, Camera angles and steering
### 3.1.0 (2025-10-17)
- new app icon
- adjusted MyWhoosh keyboard navigation mapping (thanks @bin101)

1
INSTRUCTIONS_ANDROID.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

1
INSTRUCTIONS_IOS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

1
INSTRUCTIONS_MACOS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

1
INSTRUCTIONS_WINDOWS.md Normal file
View File

@@ -0,0 +1 @@
Instructions will be added soon

View File

@@ -36,3 +36,15 @@ switch the setting to None, then back to Single-Tap and it should work again
## SwiftControl crashes on Windows when searching for the device
You're probably running into [this](https://github.com/jonasbark/swiftcontrol/issues/70) issue. Disconnect your controller device (e.g. Zwift Play) from Windows Bluetooth settings.
## Link requirement for MyWhoosh stuck at "Waiting for MyWhoosh"
The same network restrictions apply for SwiftControl as it applies to MyWhoosh Link app. Please verify with the MyWhoosh Link app if connection is possible at all.
Here are some instructions that can help:
https://mywhoosh.com/troubleshoot/
https://www.facebook.com/groups/mywhoosh/posts/1323791068858873/
In essence:
- your two devices (phone, tablet) need to be on the same WiFi network
- on iOS you have to turn off "Private Wi-Fi Address" in the WiFi settings
- Limit IP Address Tracking may need to be disabled
- mesh networks may not work

View File

@@ -8,9 +8,31 @@ class WhooshLink {
Socket? _socket;
ServerSocket? _server;
static final List<InGameAction> supportedActions = [
InGameAction.shiftUp,
InGameAction.shiftDown,
InGameAction.cameraAngle,
InGameAction.emote,
InGameAction.uturn,
InGameAction.steerLeft,
InGameAction.steerRight,
];
final ValueNotifier<bool> isConnected = ValueNotifier(false);
final ValueNotifier<bool> isStarted = ValueNotifier(false);
void stopServer() async {
if (isStarted.value) {
await _socket?.close();
await _server?.close();
isConnected.value = false;
isStarted.value = false;
if (kDebugMode) {
print('Server stopped.');
}
}
}
Future<void> startServer() async {
// Create and bind server socket
_server = await ServerSocket.bind(
@@ -51,7 +73,7 @@ class WhooshLink {
});
}
String sendAction(InGameAction action) {
String sendAction(InGameAction action, int? value) {
if (!isConnected.value) {
return 'Not connected to MyWhoosh.';
}
@@ -71,13 +93,13 @@ class WhooshLink {
InGameAction.cameraAngle => {
'MessageType': 'Controls',
'InGameControls': {
'CameraAngle': '1',
'CameraAngle': '$value',
},
},
InGameAction.emote => {
'MessageType': 'Controls',
'InGameControls': {
'Emote': '1',
'Emote': '$value',
},
},
InGameAction.uturn => {
@@ -86,10 +108,16 @@ class WhooshLink {
'UTurn': 'true',
},
},
InGameAction.steering => {
InGameAction.steerLeft => {
'MessageType': 'Controls',
'InGameControls': {
'Steering': '0',
'Steering': '-1',
},
},
InGameAction.steerRight => {
'MessageType': 'Controls',
'InGameControls': {
'Steering': '1',
},
},
InGameAction.increaseResistance => null,

View File

@@ -31,12 +31,16 @@ void main() async {
}
enum ConnectionType {
unknown,
local,
remote,
link,
}
Future<void> initializeActions(ConnectionType connectionType) async {
if (connectionType != ConnectionType.link) {
whooshLink.stopServer();
}
if (kIsWeb) {
actionHandler = StubActions();
} else if (Platform.isAndroid) {
@@ -44,18 +48,21 @@ Future<void> initializeActions(ConnectionType connectionType) async {
ConnectionType.local => AndroidActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.link => LinkActions(),
ConnectionType.unknown => StubActions(),
};
} else if (Platform.isIOS) {
actionHandler = switch (connectionType) {
ConnectionType.local => throw UnimplementedError('Local actions are not supported on iOS'),
ConnectionType.local => StubActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.link => LinkActions(),
ConnectionType.unknown => StubActions(),
};
} else {
actionHandler = switch (connectionType) {
ConnectionType.local => DesktopActions(),
ConnectionType.remote => RemoteActions(),
ConnectionType.link => LinkActions(),
ConnectionType.unknown => StubActions(),
};
}
}

View File

@@ -13,6 +13,7 @@ import 'package:swift_control/pages/touch_area.dart';
import 'package:swift_control/utils/actions/desktop.dart';
import 'package:swift_control/utils/actions/link.dart';
import 'package:swift_control/utils/keymap/manager.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:swift_control/widgets/ingameactions_customizer.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
import 'package:swift_control/widgets/loading_widget.dart';
@@ -170,25 +171,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
device.device.name?.screenshot ?? device.runtimeType.toString(),
style: TextStyle(fontWeight: FontWeight.bold),
),
if (device.isBeta)
Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'BETA',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
),
if (device.isBeta) BetaPill(),
if (device.batteryLevel != null) ...[
Icon(switch (device.batteryLevel!) {
>= 80 => Icons.battery_full,
@@ -493,7 +476,7 @@ class _DevicePageState extends State<DevicePage> with WidgetsBindingObserver {
],
),
if (actionHandler is LinkActions)
IngameactionsCustomizer()
InGameActionsCustomizer()
else if (actionHandler.supportedApp != null)
KeymapExplanation(
key: Key(actionHandler.supportedApp!.keymap.runtimeType.toString()),

View File

@@ -158,7 +158,7 @@ class _RequirementsPageState extends State<RequirementsPage> with WidgetsBinding
}
void _reloadRequirements() {
getRequirements(settings.getLastTarget()?.connectionType ?? ConnectionType.local).then((req) {
getRequirements(settings.getLastTarget()?.connectionType ?? ConnectionType.unknown).then((req) {
_requirements = req;
_currentStep = req.indexWhere((req) => !req.status);
if (mounted) {

View File

@@ -11,6 +11,7 @@ class LinkActions extends BaseActions {
if (inGameAction == null) {
return 'No action defined for button: $action';
}
return whooshLink.sendAction(inGameAction);
final value = settings.getInGameActionForButtonValue(action);
return whooshLink.sendAction(inGameAction, value);
}
}

View File

@@ -1,22 +1,27 @@
import 'package:flutter/material.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
enum InGameAction {
shiftUp,
shiftDown,
navigateLeft,
navigateRight,
increaseResistance,
decreaseResistance,
toggleUi,
cameraAngle,
emote,
uturn,
steering;
shiftUp('Shift Up'),
shiftDown('Shift Down'),
navigateLeft('Navigate Left'),
navigateRight('Navigate Right'),
increaseResistance('Increase Resistance'),
decreaseResistance('Decrease Resistance'),
toggleUi('Toggle UI'),
cameraAngle('Change Camera Angle', possibleValues: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]),
emote('Emote', possibleValues: [1, 2, 3, 4, 5, 6]),
uturn('U-Turn'),
steerLeft('Steer Left'),
steerRight('Steer Right');
final String title;
final List<int>? possibleValues;
const InGameAction(this.title, {this.possibleValues});
@override
String toString() {
return name.splitByUpperCase();
return title;
}
}

View File

@@ -4,10 +4,11 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:keypress_simulator/keypress_simulator.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/scan.dart';
import 'package:swift_control/utils/requirements/platform.dart';
import 'package:swift_control/utils/requirements/remote.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import 'package:swift_control/widgets/beta_pill.dart';
import 'package:swift_control/widgets/link.dart';
import 'package:swift_control/widgets/scan.dart';
import 'package:universal_ble/universal_ble.dart';
class KeyboardRequirement extends PlatformRequirement {
@@ -84,38 +85,54 @@ class BluetoothScanning extends PlatformRequirement {
typedef BoolFunction = bool Function();
enum Target {
thisDevice(title: 'This device', description: 'Trainer app runs on this device', icon: Icons.devices),
thisDevice(
title: 'This device',
description: 'Trainer app runs on this device',
icon: Icons.devices,
),
myWhooshLink(
title: 'MyWhoosh Link',
description: 'Control MyWhoosh directly on another device',
description: 'Control MyWhoosh directly on another device, such as a tablet or a TV',
icon: Icons.link,
),
iPad(
title: 'iPad',
description: 'Remotely control the trainer app on an iPad',
description: 'Remotely control any trainer app on an iPad by acting as a Mouse',
icon: Icons.settings_remote_outlined,
isBeta: true,
),
android(
title: 'Android Device',
description: 'Remotely control the trainer app on an Android device',
description: 'Remotely control any trainer app on an Android device',
icon: Icons.settings_remote_outlined,
isBeta: true,
),
macOS(
title: 'Mac',
description: 'Remotely control the trainer app on a Mac',
description: 'Remotely control any trainer app on a Mac',
icon: Icons.settings_remote_outlined,
isBeta: true,
),
windows(
title: 'Windows PC',
description: 'Remotely control the trainer app on a Windows PC',
description: 'Remotely control any trainer app on a Windows PC',
icon: Icons.settings_remote_outlined,
isBeta: true,
);
final String title;
final String description;
final IconData icon;
final bool isBeta;
const Target({required this.title, required this.description, required this.icon});
const Target({required this.title, required this.description, required this.icon, this.isBeta = false});
bool get isCompatible {
return switch (this) {
Target.thisDevice => !Platform.isIOS,
_ => true,
};
}
String? get warning {
return switch (this) {
@@ -165,19 +182,34 @@ class TargetRequirement extends PlatformRequirement {
return DropdownMenuEntry(
value: target,
label: target.title,
enabled: target.isCompatible,
trailingIcon: Icon(target.icon),
labelWidget: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
Text(
target == Target.myWhooshLink && Platform.isAndroid
? 'Control MyWhoosh directly on this or another device'
: target.description,
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (target == Target.myWhooshLink) Divider(),
],
labelWidget: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(target.title, style: TextStyle(fontWeight: FontWeight.bold)),
if (target.isBeta) BetaPill(),
],
),
Text(
target == Target.myWhooshLink && Platform.isAndroid
? 'Control MyWhoosh directly on this or another device'
: target.isCompatible
? target.description
: 'Due to iOS restrictions only controlling trainer apps on other devices is supported.',
style: TextStyle(fontSize: 12, color: Colors.grey),
),
if (target == Target.myWhooshLink)
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Divider(),
),
],
),
),
);
}).toList(),
@@ -240,30 +272,23 @@ class LinkRequirement extends PlatformRequirement {
@override
Widget? build(BuildContext context, VoidCallback onUpdate) {
return ValueListenableBuilder(
valueListenable: whooshLink.isStarted,
builder: (BuildContext context, value, Widget? child) {
return Row(
spacing: 8,
children: [
ElevatedButton(
onPressed: value
? null
: () async {
await whooshLink.startServer();
onUpdate();
},
child: Text(value ? 'Waiting for MyWhoosh...' : 'Start MyWhoosh Link Server'),
),
if (value) SmallProgressIndicator(),
],
);
},
);
return LinkWidget(onUpdate: onUpdate);
}
@override
Future<void> getStatus() async {
status = whooshLink.isConnected.value || kDebugMode;
status = whooshLink.isConnected.value;
}
}
class PlaceholderRequirement extends PlatformRequirement {
PlaceholderRequirement() : super('Requirement');
@override
Future<void> call(BuildContext context, VoidCallback onUpdate) async {}
@override
Future<void> getStatus() async {
status = false;
}
}

View File

@@ -46,6 +46,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
ConnectionType.local => KeyboardRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.link => LinkRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
BluetoothScanning(),
];
@@ -53,7 +54,12 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
list = [
TargetRequirement(),
BluetoothTurnedOn(),
RemoteRequirement(),
switch (connectionType) {
ConnectionType.local => RemoteRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.link => LinkRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
BluetoothScanning(),
];
} else if (Platform.isWindows) {
@@ -64,6 +70,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
ConnectionType.local => KeyboardRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.link => LinkRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
BluetoothScanning(),
];
@@ -84,6 +91,7 @@ Future<List<PlatformRequirement>> getRequirements(ConnectionType connectionType)
ConnectionType.local => AccessibilityRequirement(),
ConnectionType.remote => RemoteRequirement(),
ConnectionType.link => LinkRequirement(),
ConnectionType.unknown => PlaceholderRequirement(),
},
BluetoothScanning(),
];

View File

@@ -17,7 +17,7 @@ class Settings {
Future<void> init() async {
prefs = await SharedPreferences.getInstance();
initializeActions(settings.getLastTarget()?.connectionType ?? ConnectionType.local);
initializeActions(getLastTarget()?.connectionType ?? ConnectionType.unknown);
if (actionHandler is DesktopActions) {
// Must add this line.
@@ -215,4 +215,14 @@ class Settings {
if (actionName == null) return button.action;
return InGameAction.values.firstOrNullWhere((e) => e.name == actionName) ?? button.action;
}
void setInGameActionForButtonValue(ControllerButton button, InGameAction inGameAction, int value) {
final key = 'ingameaction_${button.name}_value';
prefs.setInt(key, value);
}
int? getInGameActionForButtonValue(ControllerButton button) {
final key = 'ingameaction_${button.name}_value';
return prefs.getInt(key);
}
}

View File

@@ -0,0 +1,27 @@
import 'package:flutter/material.dart';
class BetaPill extends StatelessWidget {
const BetaPill({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Container(
padding: EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(4),
),
child: Text(
'BETA',
style: TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.bold,
),
),
),
);
}
}

View File

@@ -1,18 +1,19 @@
import 'package:dartx/dartx.dart';
import 'package:flutter/material.dart';
import 'package:swift_control/link/link.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/device.dart';
import 'package:swift_control/utils/keymap/buttons.dart';
import 'package:swift_control/widgets/keymap_explanation.dart';
class IngameactionsCustomizer extends StatefulWidget {
const IngameactionsCustomizer({super.key});
class InGameActionsCustomizer extends StatefulWidget {
const InGameActionsCustomizer({super.key});
@override
State<IngameactionsCustomizer> createState() => _IngameactionsCustomizerState();
State<InGameActionsCustomizer> createState() => _InGameActionsCustomizerState();
}
class _IngameactionsCustomizerState extends State<IngameactionsCustomizer> {
class _InGameActionsCustomizerState extends State<InGameActionsCustomizer> {
@override
Widget build(BuildContext context) {
final connectedDevice = connection.devices.firstOrNull;
@@ -58,7 +59,9 @@ class _IngameactionsCustomizerState extends State<IngameactionsCustomizer> {
padding: const EdgeInsets.all(6),
child: Row(
children: [
IntrinsicWidth(child: ButtonWidget(button: button)),
IntrinsicWidth(
child: ButtonWidget(button: button),
),
],
),
),
@@ -66,25 +69,10 @@ class _IngameactionsCustomizerState extends State<IngameactionsCustomizer> {
padding: const EdgeInsets.all(6),
child: Row(
children: [
DropdownButton<InGameAction>(
isDense: true,
items: InGameAction.values
.map(
(ingame) => DropdownMenuItem(
value: ingame,
child: Text(ingame.toString()),
),
)
.toList(),
value: settings.getInGameActionForButton(button),
onChanged: (action) {
settings.setInGameActionForButton(
button,
action!,
);
setState(() {});
},
),
if (MediaQuery.sizeOf(context).width < 1800)
Expanded(child: _buildDropdownButton(button, true))
else
_buildDropdownButton(button, false),
],
),
),
@@ -96,4 +84,52 @@ class _IngameactionsCustomizerState extends State<IngameactionsCustomizer> {
],
);
}
Widget _buildDropdownButton(ControllerButton button, bool expand) {
final value = WhooshLink.supportedActions.contains(settings.getInGameActionForButton(button))
? settings.getInGameActionForButton(button)
: null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButton<InGameAction>(
isExpanded: expand,
items: WhooshLink.supportedActions
.map(
(ingame) => DropdownMenuItem(
value: ingame,
child: Text(ingame.toString()),
),
)
.toList(),
padding: EdgeInsets.zero,
menuWidth: 250,
value: value,
onChanged: (action) {
settings.setInGameActionForButton(
button,
action!,
);
setState(() {});
},
),
if (value?.possibleValues != null)
DropdownButton<int>(
items: value!.possibleValues!
.map((val) => DropdownMenuItem<int>(value: val, child: Text(val.toString())))
.toList(),
value: settings.getInGameActionForButtonValue(button),
onChanged: (val) {
settings.setInGameActionForButtonValue(
button,
value,
val!,
);
setState(() {});
},
hint: Text('Value'),
),
],
);
}
}

62
lib/widgets/link.dart Normal file
View File

@@ -0,0 +1,62 @@
import 'package:flutter/material.dart';
import 'package:swift_control/main.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
class LinkWidget extends StatefulWidget {
final VoidCallback onUpdate;
const LinkWidget({super.key, required this.onUpdate});
@override
State<LinkWidget> createState() => _LinkWidgetState();
}
class _LinkWidgetState extends State<LinkWidget> {
@override
void initState() {
super.initState();
whooshLink.startServer();
whooshLink.isConnected.addListener(() {
widget.onUpdate();
});
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
ValueListenableBuilder(
valueListenable: whooshLink.isStarted,
builder: (BuildContext context, value, Widget? child) {
return Row(
spacing: 8,
children: [
ElevatedButton(
onPressed: value
? null
: () async {
await whooshLink.startServer();
},
child: Text(value ? 'Waiting for MyWhoosh...' : 'Start Listening for MyWhoosh'),
),
if (value) SmallProgressIndicator(),
],
);
},
),
Text('Verify with the MyWhoosh Link app if connection is possible.'),
TextButton(
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: 'TROUBLESHOOTING.md')),
);
},
child: const Text("Show Troubleshooting Guide"),
),
],
);
}
}

View File

@@ -54,6 +54,22 @@ List<Widget> buildMenuButtons() {
PopupMenuButton(
itemBuilder: (BuildContext context) {
return [
PopupMenuItem(
child: Text('Instructions'),
onTap: () {
final instructions = Platform.isAndroid
? 'INSTRUCTIONS_ANDROID.md'
: Platform.isIOS
? 'INSTRUCTIONS_IOS.md'
: Platform.isMacOS
? 'INSTRUCTIONS_MACOS.md'
: 'INSTRUCTIONS_WINDOWS.md';
Navigator.push(
context,
MaterialPageRoute(builder: (c) => MarkdownPage(assetPath: instructions)),
);
},
),
PopupMenuItem(
child: Text('Troubleshooting Guide'),
onTap: () {

View File

@@ -4,7 +4,7 @@ import 'package:swift_control/main.dart';
import 'package:swift_control/pages/markdown.dart';
import 'package:swift_control/widgets/small_progress_indicator.dart';
import '../widgets/logviewer.dart';
import 'logviewer.dart';
class ScanWidget extends StatefulWidget {
const ScanWidget({super.key});

View File

@@ -58,6 +58,10 @@ flutter:
assets:
- CHANGELOG.md
- TROUBLESHOOTING.md
- INSTRUCTIONS_ANDROID.md
- INSTRUCTIONS_IOS.md
- INSTRUCTIONS_WINDOWS.md
- INSTRUCTIONS_MACOS.md
- shorebird.yaml
msix_config: