flutter_midi_command 1.0.3 copy "flutter_midi_command: ^1.0.3" to clipboard
flutter_midi_command: ^1.0.3 copied to clipboard

A Flutter plugin for sending and receiving MIDI messages between Flutter and physical and virtual MIDI devices.

example/lib/main.dart

import 'dart:async';

import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_midi_command_ble/flutter_midi_command_ble.dart';
import 'package:flutter_midi_command/flutter_midi_command.dart';

import 'controller.dart';

void main() => runExampleApp(enableBle: !kIsWeb);

void runExampleApp({
  bool enableBle = true,
  MidiCommand? midiCommand,
  MidiBleTransport? bleTransport,
}) {
  runApp(
    MyApp(
      enableBle: enableBle,
      midiCommand: midiCommand,
      bleTransport: bleTransport,
    ),
  );
}

String connectionErrorMessage(Object error) {
  if (error is MidiConnectionException) {
    return error.message;
  }
  if (error is PlatformException) {
    return error.message ?? error.code;
  }
  return error.toString();
}

class MyApp extends StatefulWidget {
  const MyApp({
    super.key,
    this.enableBle = true,
    this.midiCommand,
    this.bleTransport,
  });

  final bool enableBle;
  final MidiCommand? midiCommand;
  final MidiBleTransport? bleTransport;

  @override
  MyAppState createState() => MyAppState();
}

class MyAppState extends State<MyApp> {
  StreamSubscription<MidiSetupChange>? _setupSubscription;
  StreamSubscription<BluetoothState>? _bluetoothStateSubscription;
  late final MidiCommand _midiCommand;

  bool _bleTransportEnabled = false;
  bool _virtualDeviceActivated = false;
  bool _networkTransportSupported = true;
  bool _networkTransportEnabled = false;
  bool _loadingDevices = true;
  bool _scanningBleDevices = false;
  int _deviceRefreshGeneration = 0;
  List<MidiDevice> _devices = <MidiDevice>[];

  bool _didAskForBluetoothPermissions = false;

  @override
  void initState() {
    super.initState();
    _midiCommand = widget.midiCommand ?? MidiCommand();
    _bleTransportEnabled = widget.enableBle;

    if (widget.enableBle) {
      _midiCommand.configureBleTransport(
        widget.bleTransport ?? UniversalBleMidiTransport(),
      );
    } else {
      _midiCommand.configureBleTransport(null);
    }

    _setupSubscription = _midiCommand.onMidiSetupChanged?.listen((data) async {
      if (kDebugMode) {
        print("setup changed $data");
      }
      await _refreshDevices(showLoading: false);
    });

    _bluetoothStateSubscription =
        _midiCommand.onBluetoothStateChanged.listen((data) {
      if (kDebugMode) {
        print("bluetooth state change $data");
      }
      if (_scanningBleDevices && data != BluetoothState.poweredOn) {
        _scanningBleDevices = false;
      }
      setState(() {});
    });

    unawaited(_initializeExampleState());
  }

  @override
  void dispose() {
    _setupSubscription?.cancel();
    _bluetoothStateSubscription?.cancel();
    _midiCommand.configureBleTransport(null);
    super.dispose();
  }

  Future<void> _initializeExampleState() async {
    await _updateNetworkSessionState();
    _applyTransportPolicy();
    await _refreshDevices();
  }

  Future<void> _updateNetworkSessionState() async {
    try {
      var nse = await _midiCommand.isNetworkSessionEnabled;
      if (!mounted) {
        return;
      }
      setState(() {
        _networkTransportSupported = true;
        _networkTransportEnabled = nse ?? false;
      });
    } on StateError {
      if (!mounted) {
        return;
      }
      setState(() {
        _networkTransportSupported = false;
        _networkTransportEnabled = false;
      });
    }
  }

  void _applyTransportPolicy() {
    final excludedTransports = <MidiTransport>{};
    if (!_bleTransportEnabled) {
      excludedTransports.add(MidiTransport.ble);
    }
    if (!_networkTransportEnabled) {
      excludedTransports.add(MidiTransport.network);
    }
    if (!_virtualDeviceActivated) {
      excludedTransports.add(MidiTransport.virtual);
    }
    _midiCommand.configureTransportPolicy(
      MidiTransportPolicy(
        excludedTransports: excludedTransports,
      ),
    );
  }

  Future<void> _refreshDevices({bool showLoading = true}) async {
    final generation = ++_deviceRefreshGeneration;
    if (showLoading && mounted) {
      setState(() {
        _loadingDevices = true;
      });
    }

    final devices = await _midiCommand.devices ?? <MidiDevice>[];
    if (!mounted || generation != _deviceRefreshGeneration) {
      return;
    }

    setState(() {
      _devices = devices;
      _loadingDevices = false;
    });
  }

  Future<void> _setBleTransportEnabled(bool enabled) async {
    if (_bleTransportEnabled == enabled) {
      return;
    }

    if (!enabled && _midiCommand.bluetoothState == BluetoothState.poweredOn) {
      try {
        _midiCommand.stopScanningForBluetoothDevices();
      } catch (_) {}
    }

    setState(() {
      _bleTransportEnabled = enabled;
      _scanningBleDevices = false;
    });
    _applyTransportPolicy();
    await _refreshDevices(showLoading: false);
  }

  Future<void> _setNetworkTransportEnabled(bool enabled) async {
    if (_networkTransportEnabled == enabled) {
      return;
    }

    if (!enabled) {
      try {
        _midiCommand.setNetworkSessionEnabled(false);
      } catch (_) {}
    }

    setState(() {
      _networkTransportEnabled = enabled;
    });
    _applyTransportPolicy();

    if (enabled) {
      try {
        _midiCommand.setNetworkSessionEnabled(true);
      } catch (_) {}
    }

    await _refreshDevices(showLoading: false);
  }

  Future<void> _setVirtualTransportEnabled(bool enabled) async {
    if (_virtualDeviceActivated == enabled) {
      return;
    }

    if (!enabled) {
      try {
        _midiCommand.removeVirtualDevice(name: "Flutter MIDI Command");
      } catch (_) {}
    }

    setState(() {
      _virtualDeviceActivated = enabled;
    });
    _applyTransportPolicy();

    if (enabled) {
      try {
        _midiCommand.addVirtualDevice(name: "Flutter MIDI Command");
      } catch (_) {}
    }

    await _refreshDevices(showLoading: false);
  }

  Future<void> _startBleScan(BuildContext context) async {
    if (!_bleTransportEnabled) {
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
        content: Text('Enable BLE transport first.'),
      ));
      return;
    }

    await _informUserAboutBluetoothPermissions(context);

    if (kDebugMode) {
      print("start bluetooth");
    }
    await _midiCommand.startBluetooth().catchError((err) {
      if (!context.mounted) return;
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        content: Text(err.toString()),
      ));
    });

    if (kDebugMode) {
      print("wait for init");
    }
    await _midiCommand
        .waitUntilBluetoothIsInitialized()
        .timeout(const Duration(seconds: 5), onTimeout: () {
      if (kDebugMode) {
        print("Failed to initialize Bluetooth");
      }
    });

    if (!context.mounted) {
      return;
    }

    if (_midiCommand.bluetoothState == BluetoothState.poweredOn) {
      await _midiCommand.startScanningForBluetoothDevices().catchError((err) {
        if (kDebugMode) {
          print("Error $err");
        }
      });
      if (!context.mounted) {
        return;
      }
      setState(() {
        _scanningBleDevices = true;
      });
      ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
        content: Text('Scanning for bluetooth devices ...'),
      ));
    } else {
      final messages = {
        BluetoothState.unsupported:
            'Bluetooth is not supported on this device.',
        BluetoothState.poweredOff: 'Please switch on bluetooth and try again.',
        BluetoothState.poweredOn: 'Everything is fine.',
        BluetoothState.resetting: 'Currently resetting. Try again later.',
        BluetoothState.unauthorized:
            'This app needs bluetooth permissions. Please open settings, find your app and assign bluetooth access rights and start your app again.',
        BluetoothState.unknown: 'Bluetooth is not ready yet. Try again later.',
        BluetoothState.other:
            'This should never happen. Please inform the developer of your app.',
      };
      ScaffoldMessenger.of(context).showSnackBar(SnackBar(
        backgroundColor: Colors.red,
        content: Text(messages[_midiCommand.bluetoothState] ??
            'Unknown bluetooth state: ${_midiCommand.bluetoothState}'),
      ));
    }

    if (kDebugMode) {
      print("done");
    }
  }

  IconData _deviceIconForType(MidiDeviceType type) {
    switch (type) {
      case MidiDeviceType.serial:
        return Icons.devices;
      case MidiDeviceType.network:
        return Icons.language;
      case MidiDeviceType.ble:
        return Icons.bluetooth;
      default:
        return Icons.device_unknown;
    }
  }

  String _transportLabelForType(MidiDeviceType type) {
    switch (type) {
      case MidiDeviceType.serial:
        return 'Native';
      case MidiDeviceType.network:
        return 'Network';
      case MidiDeviceType.ble:
        return 'BLE';
      case MidiDeviceType.virtual:
        return 'Virtual';
      case MidiDeviceType.ownVirtual:
        return 'Own Virtual';
      case MidiDeviceType.unknown:
        return 'Unknown';
    }
  }

  Future<void> _sendTestNote(
    BuildContext context,
    MidiDevice device,
  ) async {
    final noteOn = Uint8List.fromList(<int>[0x90, 60, 100]);
    final noteOff = Uint8List.fromList(<int>[0x80, 60, 0]);

    try {
      _midiCommand.sendData(noteOn, deviceId: device.id);
      await Future<void>.delayed(const Duration(milliseconds: 150));
      _midiCommand.sendData(noteOff, deviceId: device.id);
    } catch (err) {
      if (!context.mounted) {
        return;
      }
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text('Unable to send test note: $err')),
      );
    }
  }

  IconData _connectionIconForState(MidiConnectionState state) {
    switch (state) {
      case MidiConnectionState.connected:
        return Icons.radio_button_on;
      case MidiConnectionState.connecting:
      case MidiConnectionState.disconnecting:
        return Icons.sync;
      case MidiConnectionState.disconnected:
        return Icons.radio_button_off;
    }
  }

  Future<void> _informUserAboutBluetoothPermissions(
      BuildContext context) async {
    if (_didAskForBluetoothPermissions) {
      return;
    }

    await showDialog<void>(
      context: context,
      barrierDismissible: false,
      builder: (BuildContext context) {
        return AlertDialog(
          title: const Text(
              'Please Grant Bluetooth Permissions to discover BLE MIDI Devices.'),
          content: const Text(
              'In the next dialog we might ask you for bluetooth permissions.\n'
              'Please grant permissions to make bluetooth MIDI possible.'),
          actions: <Widget>[
            TextButton(
              child: const Text('Ok. I got it!'),
              onPressed: () {
                Navigator.of(context).pop();
              },
            ),
          ],
        );
      },
    );

    _didAskForBluetoothPermissions = true;

    return;
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Builder(
        builder: (appContext) => Scaffold(
          appBar: AppBar(
            title: const Text('FlutterMidiCommand Example'),
          ),
          bottomNavigationBar: Container(
            padding: const EdgeInsets.all(24.0),
            child: const Text(
              "Tap to connect/disconnect, long press to control.",
              textAlign: TextAlign.center,
            ),
          ),
          body: Column(
            children: [
              Padding(
                padding: const EdgeInsets.fromLTRB(12, 12, 12, 0),
                child: LayoutBuilder(
                  builder: (context, constraints) {
                    final horizontalLayout = constraints.maxWidth >= 760;
                    final transportCard = _ControlCard(
                      title: 'Transports',
                      child: Wrap(
                        spacing: 12,
                        runSpacing: 8,
                        children: [
                          _LabeledAppBarSwitch(
                            label: 'RTP',
                            tooltip: _networkTransportSupported
                                ? 'Enable network MIDI session (RTP-MIDI) when available.'
                                : 'RTP-MIDI is not supported on this platform.',
                            value: _networkTransportEnabled,
                            enabled: _networkTransportSupported,
                            onChanged: (newValue) {
                              unawaited(
                                _setNetworkTransportEnabled(newValue),
                              );
                            },
                          ),
                          _LabeledAppBarSwitch(
                            label: 'Virtual',
                            tooltip:
                                'Expose this app as a virtual MIDI device.',
                            value: _virtualDeviceActivated,
                            onChanged: (newValue) {
                              unawaited(
                                _setVirtualTransportEnabled(newValue),
                              );
                            },
                          ),
                          if (widget.enableBle)
                            _LabeledAppBarSwitch(
                              label: 'BLE',
                              tooltip: 'Enable BLE MIDI transport.',
                              value: _bleTransportEnabled,
                              onChanged: (newValue) {
                                unawaited(_setBleTransportEnabled(newValue));
                              },
                            ),
                        ],
                      ),
                    );

                    final actions = <Widget>[
                      FilledButton.icon(
                        onPressed: () {
                          unawaited(_refreshDevices());
                        },
                        icon: const Icon(Icons.refresh),
                        label: const Text('Refresh Devices'),
                      ),
                    ];

                    if (widget.enableBle) {
                      actions.add(
                        FilledButton.tonalIcon(
                          onPressed: _bleTransportEnabled
                              ? () {
                                  unawaited(_startBleScan(appContext));
                                }
                              : null,
                          icon: const Icon(Icons.bluetooth_searching),
                          label: Text(
                            _scanningBleDevices
                                ? 'Scanning BLE...'
                                : 'Scan BLE',
                          ),
                        ),
                      );
                    }

                    final bleCard = _ControlCard(
                      title: 'Discovery',
                      child: Column(
                        crossAxisAlignment: CrossAxisAlignment.start,
                        children: [
                          Wrap(
                            spacing: 12,
                            runSpacing: 8,
                            children: actions,
                          ),
                          if (widget.enableBle) ...[
                            const SizedBox(height: 8),
                            Text(
                              'Bluetooth: ${_midiCommand.bluetoothState.name}',
                              style: Theme.of(appContext).textTheme.bodyMedium,
                            ),
                          ],
                        ],
                      ),
                    );

                    if (horizontalLayout) {
                      return IntrinsicHeight(
                        child: Row(
                          crossAxisAlignment: CrossAxisAlignment.stretch,
                          children: [
                            Expanded(child: transportCard),
                            const SizedBox(width: 12),
                            Expanded(child: bleCard),
                          ],
                        ),
                      );
                    }

                    return Column(
                      children: [
                        transportCard,
                        const SizedBox(height: 12),
                        bleCard,
                      ],
                    );
                  },
                ),
              ),
              Expanded(
                child: Center(
                  child: _buildDeviceList(appContext),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

  Widget _buildDeviceList(BuildContext context) {
    if (_loadingDevices) {
      return const CircularProgressIndicator();
    }

    if (_devices.isEmpty) {
      return const Text('No MIDI devices found.');
    }

    return ListView.builder(
      itemCount: _devices.length,
      itemBuilder: (context, index) {
        MidiDevice device = _devices[index];

        return ListTile(
          title: Text(
            device.name,
            style: Theme.of(context).textTheme.headlineSmall,
          ),
          subtitle: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Wrap(
                spacing: 8,
                runSpacing: 6,
                children: [
                  Chip(
                    avatar: Icon(
                      _deviceIconForType(device.type),
                      size: 16,
                    ),
                    label: Text(_transportLabelForType(device.type)),
                    visualDensity: VisualDensity.compact,
                    materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
                  ),
                ],
              ),
              const SizedBox(height: 4),
              Text(
                "ins:${device.inputPorts.length} outs:${device.outputPorts.length}, ${device.id}, ${device.connectionState.name}",
              ),
            ],
          ),
          leading: Icon(
            _connectionIconForState(device.connectionState),
          ),
          trailing: device.connected
              ? IconButton(
                  icon: const Icon(Icons.music_note),
                  tooltip: 'Send test note',
                  onPressed: () {
                    unawaited(_sendTestNote(context, device));
                  },
                )
              : null,
          onLongPress: () {
            if (_midiCommand.isTransportEnabled(
                  MidiTransport.ble,
                ) &&
                _midiCommand.bluetoothState == BluetoothState.poweredOn) {
              try {
                _midiCommand.stopScanningForBluetoothDevices();
              } catch (_) {
                // Ignore BLE shutdown issues when opening the
                // controller page for non-BLE workflows.
              }
            }
            Navigator.of(context)
                .push(MaterialPageRoute<void>(
              builder: (_) => ControllerPage(device),
            ))
                .then((value) {
              setState(() {});
            });
          },
          onTap: () {
            if (device.connectionState == MidiConnectionState.connecting ||
                device.connectionState == MidiConnectionState.disconnecting) {
              return;
            }

            if (device.connected) {
              if (kDebugMode) {
                print("disconnect");
              }
              _midiCommand.disconnectDevice(device);
              setState(() {});
            } else {
              if (kDebugMode) {
                print("connect");
              }
              _midiCommand.connectToDevice(device).then((_) {
                if (kDebugMode) {
                  print("device connected async");
                }
              }).catchError((err) {
                if (!context.mounted) return;
                ScaffoldMessenger.of(context).showSnackBar(SnackBar(
                  content: Text("Error: ${connectionErrorMessage(err)}"),
                ));
              });
              setState(() {});
            }
          },
        );
      },
    );
  }
}

class _ControlCard extends StatelessWidget {
  const _ControlCard({
    required this.title,
    required this.child,
  });

  final String title;
  final Widget child;

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Padding(
        padding: const EdgeInsets.all(12),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              title,
              style: Theme.of(context).textTheme.titleLarge,
            ),
            const SizedBox(height: 8),
            child,
          ],
        ),
      ),
    );
  }
}

class _LabeledAppBarSwitch extends StatelessWidget {
  const _LabeledAppBarSwitch({
    required this.label,
    required this.tooltip,
    required this.value,
    required this.onChanged,
    this.enabled = true,
  });

  final String label;
  final String tooltip;
  final bool value;
  final bool enabled;
  final ValueChanged<bool> onChanged;

  @override
  Widget build(BuildContext context) {
    final labelStyle = Theme.of(context).textTheme.labelSmall;

    return Tooltip(
      message: tooltip,
      child: Padding(
        padding: const EdgeInsets.symmetric(horizontal: 4.0),
        child: Row(
          mainAxisSize: MainAxisSize.min,
          children: [
            Text(label, style: labelStyle),
            Switch(
              value: value,
              onChanged: enabled ? onChanged : null,
              materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
            ),
          ],
        ),
      ),
    );
  }
}