From 960f9e76cf6bb889d7ea6679117fa3cf9f7ae764 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 6 Jan 2026 14:43:09 +0100 Subject: [PATCH 01/34] refactor recorder: extract controls into separate widget --- .../local_recorder_dialogs.dart | 50 ++++ .../local_recorder/local_recorder_files.dart | 53 ++++ .../local_recorder/local_recorder_view.dart | 15 +- .../local_recorder/recording_controls.dart | 262 ++++++++++++++++++ open_wearable/pubspec.lock | 12 +- open_wearable/pubspec.yaml | 1 + 6 files changed, 388 insertions(+), 5 deletions(-) create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart new file mode 100644 index 00000000..ffa6c76e --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_dialogs.dart @@ -0,0 +1,50 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; + +class LocalRecorderDialogs { + static Future askOverwriteConfirmation( + BuildContext context, + String dirPath, + ) async { + return await showPlatformDialog( + context: context, + builder: (ctx) => AlertDialog( + title: PlatformText('Directory not empty'), + content: PlatformText( + '"$dirPath" already contains files or folders.\n\n' + 'New sensor files will be added; existing files with the same ' + 'names will be overwritten. Continue anyway?'), + actions: [ + PlatformTextButton( + onPressed: () => Navigator.pop(ctx, false), + child: PlatformText('Cancel'), + ), + PlatformTextButton( + onPressed: () => Navigator.pop(ctx, true), + child: PlatformText('Continue'), + ), + ], + ), + ) ?? + false; + } + + static Future showErrorDialog( + BuildContext context, + String message, + ) async { + await showPlatformDialog( + context: context, + builder: (_) => PlatformAlertDialog( + title: PlatformText('Error'), + content: PlatformText(message), + actions: [ + PlatformDialogAction( + child: PlatformText('OK'), + onPressed: () => Navigator.pop(context), + ), + ], + ), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart new file mode 100644 index 00000000..db4b5bf4 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart @@ -0,0 +1,53 @@ +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:path_provider/path_provider.dart'; + +class Files { + static Future pickDirectory() async { + if (!Platform.isIOS && !kIsWeb) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + Directory? appDir = await getExternalStorageDirectory(); + if (appDir == null) return null; + + String dirPath = '${appDir.path}/$recordingName'; + Directory dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + if (Platform.isIOS) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + String dirPath = '${(await getIOSDirectory()).path}/$recordingName'; + Directory dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + return null; + } + + static Future getIOSDirectory() async { + Directory appDocDir = await getApplicationDocumentsDirectory(); + final dirPath = '${appDocDir.path}/Recordings'; + final dir = Directory(dirPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + return dir; + } + + static Future isDirectoryEmpty(String path) async { + final dir = Directory(path); + if (!await dir.exists()) return true; + return await dir.list(followLinks: false).isEmpty; + } +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index c6b22012..430ff4b7 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -5,8 +5,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:logger/logger.dart'; -import 'package:open_file/open_file.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; import 'package:provider/provider.dart'; +import 'package:open_file/open_file.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_empty_state_card.dart'; @@ -234,7 +235,11 @@ class _LocalRecorderViewState extends State { await localRecorderShareFile(file); } catch (e) { _logger.e('Error sharing file: $e'); - await _showErrorDialog('Failed to share file: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to share file: $e', + ); } } @@ -243,7 +248,11 @@ class _LocalRecorderViewState extends State { await localRecorderShareFolder(folder); } catch (e) { _logger.e('Error sharing folder: $e'); - await _showErrorDialog('Failed to share folder: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to share folder: $e', + ); } } diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart new file mode 100644 index 00000000..0fc3b322 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -0,0 +1,262 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:logger/logger.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/wearables_provider.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart'; +import 'package:provider/provider.dart'; + +Logger _logger = Logger(); + +class RecordingControls extends StatefulWidget { + const RecordingControls({ + super.key, + required this.canStartRecording, + required this.isRecording, + required this.recorder, + required this.updateRecordingsList, + }); + + final bool canStartRecording; + final bool isRecording; + final SensorRecorderProvider recorder; + + final Future Function() updateRecordingsList; + + @override + State createState() => _RecordingControls(); +} + +class _RecordingControls extends State { + Duration _elapsedRecording = Duration.zero; + Timer? _recordingTimer; + bool _isHandlingStopAction = false; + bool _lastRecordingState = false; + SensorRecorderProvider? _recorder; + DateTime? _activeRecordingStart; + + String _formatDuration(Duration d) { + String twoDigits(int n) => n.toString().padLeft(2, '0'); + final hours = twoDigits(d.inHours); + final minutes = twoDigits(d.inMinutes.remainder(60)); + final seconds = twoDigits(d.inSeconds.remainder(60)); + return '$hours:$minutes:$seconds'; + } + + Future _handleStopRecording( + SensorRecorderProvider recorder, { + required bool turnOffSensors, + }) async { + if (_isHandlingStopAction) return; + setState(() { + _isHandlingStopAction = true; + }); + + try { + recorder.stopRecording(); + if (turnOffSensors) { + final wearablesProvider = context.read(); + final futures = wearablesProvider.sensorConfigurationProviders.values + .map((provider) => provider.turnOffAllSensors()); + await Future.wait(futures); + } + await widget.updateRecordingsList(); + } catch (e) { + _logger.e('Error stopping recording: $e'); + if (!mounted) return; + await LocalRecorderDialogs.showErrorDialog( + context, + 'Failed to stop recording: $e', + ); + } finally { + if (mounted) { + setState(() { + _isHandlingStopAction = false; + }); + } + } + } + + @override + void didUpdateWidget(covariant RecordingControls oldWidget) { + super.didUpdateWidget(oldWidget); + + // Start timer if parent says recording started + if (widget.isRecording && !oldWidget.isRecording) { + _startRecordingTimer(widget.recorder.recordingStart); + } + + // Stop timer if parent says recording stopped + if (!widget.isRecording && oldWidget.isRecording) { + _stopRecordingTimer(); + } + } + + @override + void dispose() { + _recordingTimer?.cancel(); + _recorder?.removeListener(_handleRecorderUpdate); + super.dispose(); + } + + void _handleRecorderUpdate() { + final recorder = _recorder; + if (recorder == null) return; + final isRecording = recorder.isRecording; + final start = recorder.recordingStart; + if (isRecording && !_lastRecordingState) { + _startRecordingTimer(start); + } else if (!isRecording && _lastRecordingState) { + _stopRecordingTimer(); + } else if (isRecording && + _lastRecordingState && + start != null && + _activeRecordingStart != null && + start != _activeRecordingStart) { + _startRecordingTimer(start); + } + _lastRecordingState = isRecording; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final nextRecorder = context.watch(); + if (!identical(_recorder, nextRecorder)) { + _recorder?.removeListener(_handleRecorderUpdate); + _recorder = nextRecorder; + _recorder?.addListener(_handleRecorderUpdate); + _handleRecorderUpdate(); + } + } + + void _startRecordingTimer(DateTime? start) { + final reference = start ?? DateTime.now(); + _activeRecordingStart = reference; + _recordingTimer?.cancel(); + setState(() { + _elapsedRecording = DateTime.now().difference(reference); + }); + _recordingTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (!mounted) return; + setState(() { + final base = _activeRecordingStart ?? reference; + _elapsedRecording = DateTime.now().difference(base); + }); + }); + } + + void _stopRecordingTimer() { + _recordingTimer?.cancel(); + _recordingTimer = null; + _activeRecordingStart = null; + if (!mounted) return; + setState(() { + _elapsedRecording = Duration.zero; + }); + } + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: !widget.isRecording + ? ElevatedButton.icon( + icon: const Icon(Icons.play_arrow), + style: ElevatedButton.styleFrom( + backgroundColor: widget.canStartRecording + ? Colors.green.shade600 + : Colors.grey.shade400, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Start Recording', + style: TextStyle(fontSize: 18), + ), + onPressed: !widget.canStartRecording + ? null + : () async { + final dir = await Files.pickDirectory(); + if (dir == null) return; + + // Check if directory is empty + if (!await Files.isDirectoryEmpty(dir)) { + if (!context.mounted) return; + final proceed = + await LocalRecorderDialogs.askOverwriteConfirmation( + context, + dir, + ); + if (!proceed) return; + } + + widget.recorder.startRecording(dir); + await widget.updateRecordingsList(); // Refresh list + }, + ) + : Column( + children: [ + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Expanded( + child: ElevatedButton.icon( + icon: const Icon(Icons.stop), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Stop Recording', + style: TextStyle(fontSize: 18), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + widget.recorder, + turnOffSensors: false, + ), + ), + ), + const SizedBox(width: 8), + ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 90, + ), + child: Text( + _formatDuration(_elapsedRecording), + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + const SizedBox(height: 12), + ElevatedButton.icon( + icon: const Icon(Icons.power_settings_new), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.red[800], + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(48), + ), + label: const Text( + 'Stop & Turn Off Sensors', + style: TextStyle(fontSize: 18), + ), + onPressed: _isHandlingStopAction + ? null + : () => _handleStopRecording( + widget.recorder, + turnOffSensors: true, + ), + ), + ], + ), + ); + } +} diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index fb9cafb7..56fc3dd2 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -540,10 +540,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -824,6 +824,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + playback_capture: + dependency: "direct main" + description: + name: playback_capture + sha256: b2766b8741c00b51e0140660e9503d493a38cf5c9b0b9c5127c1d61f07a8e5e3 + url: "https://pub.dev" + source: hosted + version: "0.0.4" plugin_platform_interface: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 79bd286b..38a2cf36 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -62,6 +62,7 @@ dependencies: sensors_plus: ^7.0.0 device_info_plus: ^12.3.0 pub_semver: ^2.2.0 + playback_capture: ^0.0.4 dev_dependencies: flutter_test: From 6a293e52ae06b6a94ba30a4e43fb22f541bc7eb2 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 16 Jan 2026 17:10:03 +0100 Subject: [PATCH 02/34] add custom audio waveform to the Sensors tab with device selector --- .../android/app/src/main/AndroidManifest.xml | 2 + open_wearable/ios/Podfile.lock | 6 + .../sensors/values/sensor_values_page.dart | 376 ++++++++++++++++-- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 1 + .../Flutter/GeneratedPluginRegistrant.swift | 2 + open_wearable/pubspec.lock | 78 +++- open_wearable/pubspec.yaml | 3 + .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 1 + 10 files changed, 438 insertions(+), 38 deletions(-) diff --git a/open_wearable/android/app/src/main/AndroidManifest.xml b/open_wearable/android/app/src/main/AndroidManifest.xml index 8cb9f463..eb49e63d 100644 --- a/open_wearable/android/app/src/main/AndroidManifest.xml +++ b/open_wearable/android/app/src/main/AndroidManifest.xml @@ -40,6 +40,8 @@ + + diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 1e2cdfa2..e5d0411d 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,4 +1,6 @@ PODS: + - audio_waveforms (0.0.1): + - Flutter - audioplayers_darwin (0.0.1): - Flutter - FlutterMacOS @@ -80,6 +82,7 @@ PODS: - ZIPFoundation (0.9.19) DEPENDENCIES: + - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -109,6 +112,8 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: + audio_waveforms: + :path: ".symlinks/plugins/audio_waveforms/ios" audioplayers_darwin: :path: ".symlinks/plugins/audioplayers_darwin/darwin" device_info_plus: @@ -143,6 +148,7 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: + audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index f847afa7..b91e31b5 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -7,7 +7,11 @@ import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; +import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; +import 'package:record/record.dart'; +import 'dart:async'; +import 'package:path_provider/path_provider.dart'; class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -30,11 +34,183 @@ class _SensorValuesPageState extends State bool get _ownsProviders => widget.sharedProviders == null; + // Audio State + late final AudioRecorder _audioRecorder; + bool _isRecording = false; + String? _errorMessage; + List _devices = []; + InputDevice? _selectedDevice; + StreamSubscription? _recordSub; + StreamSubscription? _amplitudeSub; + RecordState _recordState = RecordState.stop; + List _waveformData = []; + Amplitude? _amplitude; + @override bool get wantKeepAlive => true; + @override + void initState() { + super.initState(); + _audioRecorder = AudioRecorder(); + + // Only subscribe to state changes initially + _recordSub = _audioRecorder.onStateChanged().listen((recordState) { + if (mounted) { + setState(() => _recordState = recordState); + + // Clean up amplitude subscription when recording stops + if (recordState == RecordState.stop) { + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } + } + }); + + _initRecording(); + } + + Future _initRecording() async { + print("Initializing audio recorder"); + + try { + if (await _audioRecorder.hasPermission()) { + print("Permission granted"); + await _loadDevices(); + await _startRecording(); + } else { + print("No permission, requesting..."); + final status = await Permission.microphone.request(); + if (status.isGranted) { + await _loadDevices(); + await _startRecording(); + } else { + if (mounted) { + setState(() => _errorMessage = 'Microphone permission denied'); + } + } + } + } catch (e) { + print("Init error: $e"); + if (mounted) { + setState(() => _errorMessage = 'Failed to initialize: $e'); + } + } + } + + Future _loadDevices() async { + try { + final devs = await _audioRecorder.listInputDevices(); + if (mounted) { + setState(() { + _devices = devs; + if (_selectedDevice == null && _devices.isNotEmpty) { + _selectedDevice = _devices.first; + print("Selected device: ${_selectedDevice?.label}"); + } + }); + } + } catch (e) { + print("Error loading devices: $e"); + } + } + + Future _getRecordingPath() async { + final directory = await getTemporaryDirectory(); + return '${directory.path}/temp_recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; + } + + Future _startRecording() async { + try { + const encoder = AudioEncoder.aacLc; + + if (!await _audioRecorder.isEncoderSupported(encoder)) { + if (mounted) { + setState(() => _errorMessage = 'Encoder not supported'); + } + return; + } + + final path = await _getRecordingPath(); + + final config = RecordConfig( + encoder: encoder, + numChannels: 1, + device: _selectedDevice, + ); + + await _audioRecorder.start(config, path: path); + + // Wait a bit to ensure recording is active + await Future.delayed(Duration(milliseconds: 100)); + + _amplitudeSub?.cancel(); + // Subscribe to amplitude changes after recording started + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen( + (amp) { + if (mounted) { + setState(() { + _amplitude = amp; + // Add normalized amplitude to waveform data + final normalized = (amp.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + + // Keep only last 100 samples + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + }); + } + }, + onError: (error) { + print("Amplitude stream error: $error"); + }, + ); + + if (mounted) { + setState(() { + _isRecording = true; + _errorMessage = null; + }); + } + } catch (e) { + print("Recording start error: $e"); + if (mounted) { + setState(() => _errorMessage = 'Failed to start recording: $e'); + } + } + } + + Future _changeDevice(InputDevice? device) async { + if (device == null) return; + + // Stop current recording + if (_recordState != RecordState.stop) { + await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } + + // Update selected device and restart + if (mounted) { + setState(() { + _selectedDevice = device; + _waveformData.clear(); + _isRecording = false; + }); + } + + await _startRecording(); + } + @override void dispose() { + _audioRecorder.stop(); + _recordSub?.cancel(); + _amplitudeSub?.cancel(); + _audioRecorder.dispose(); if (_ownsProviders) { for (final provider in _ownedProviders.values) { provider.dispose(); @@ -262,31 +438,77 @@ class _SensorValuesPageState extends State return ordered; } + Widget _buildAudioUI() { + return Column( + children: [ + if (_devices.isNotEmpty) + Card( + child: Padding( + padding: const EdgeInsets.all(8.0), + child: Row( + children: [ + const Text('Input: ', + style: TextStyle(fontWeight: FontWeight.bold)), + const SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: _selectedDevice, + isExpanded: true, + items: _devices + .map((d) => + DropdownMenuItem(value: d, child: Text(d.label))) + .toList(), + onChanged: _changeDevice, + ), + ), + IconButton( + icon: const Icon(Icons.refresh), onPressed: _loadDevices), + ], + ), + ), + ), + if (_isRecording) + Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: CustomPaint( + size: const Size(double.infinity, 100), + painter: WaveformPainter(_waveformData), + ), + ), + ) + else if (_errorMessage != null) + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformText(_errorMessage!, + style: const TextStyle(color: Colors.red)), + ), + const SizedBox(height: 10), + ], + ); + } + Widget _buildSmallScreenLayout( BuildContext context, List charts, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { - if (charts.isEmpty) { - final emptyState = _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData, - ); - return Padding( - padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - child: Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500), - child: _buildEmptyStateCard(context, emptyState), - ), - ), - ); - } - return ListView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - children: charts, + children: [ + _buildAudioUI(), + ...charts, + if (charts.isEmpty) + Center( + child: _buildEmptyStateCard( + context, + _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData), + ), + ), + ], ); } @@ -296,26 +518,35 @@ class _SensorValuesPageState extends State required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { - final emptyState = _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData, - ); - - return GridView.builder( + return SingleChildScrollView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 1.5, - crossAxisSpacing: SensorPageSpacing.gridGap, - mainAxisSpacing: SensorPageSpacing.gridGap, + child: Column( + children: [ + _buildAudioUI(), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 500, + childAspectRatio: 1.5, + crossAxisSpacing: SensorPageSpacing.gridGap, + mainAxisSpacing: SensorPageSpacing.gridGap, + ), + itemCount: charts.isEmpty ? 1 : charts.length, + itemBuilder: (context, index) { + if (charts.isEmpty) { + return _buildEmptyStateCard( + context, + _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData), + ); + } + return charts[index]; + }, + ), + ], ), - itemCount: charts.isEmpty ? 1 : charts.length, - itemBuilder: (context, index) { - if (charts.isEmpty) { - return _buildEmptyStateCard(context, emptyState); - } - return charts[index]; - }, ); } @@ -435,3 +666,78 @@ class _SensorValuesEmptyState { this.removeCardBackground = false, }); } + +// Custom waveform painter with vertical bars +class WaveformPainter extends CustomPainter { + final List waveformData; + final Color waveColor; + final double spacing; + final double waveThickness; + final bool showMiddleLine; + + WaveformPainter( + this.waveformData, { + this.waveColor = Colors.blue, + this.spacing = 4.0, + this.waveThickness = 3.0, + this.showMiddleLine = true, + }); + + @override + void paint(Canvas canvas, Size size) { + if (waveformData.isEmpty) return; + + final double height = size.height; + final double centerY = height / 2; + + // Draw middle line first (behind the bars) + if (showMiddleLine) { + final centerLinePaint = Paint() + ..color = Colors.grey.withAlpha(75) + ..strokeWidth = 1.0; + canvas.drawLine( + Offset(0, centerY), + Offset(size.width, centerY), + centerLinePaint, + ); + } + + // Paint for the vertical bars + final paint = Paint() + ..color = waveColor + ..strokeWidth = waveThickness + ..strokeCap = StrokeCap.round + ..style = PaintingStyle.stroke; + + // Calculate starting position to align bars from right + final totalWaveformWidth = waveformData.length * spacing; + final startX = size.width - totalWaveformWidth; + + // Draw each amplitude value as a vertical bar + for (int i = 0; i < waveformData.length; i++) { + final x = startX + (i * spacing); + final amplitude = waveformData[i]; + + // Scale amplitude to fit within the canvas height + // Amplitude is normalized to 0-2 range, scale it to use 80% of half height + final barHeight = amplitude * centerY * 0.8; + + // Draw top half of the bar (above center line) + final topY = centerY - barHeight; + final bottomY = centerY + barHeight; + + // Draw the vertical line from top to bottom + canvas.drawLine( + Offset(x, topY), + Offset(x, bottomY), + paint, + ); + } + } + + @override + bool shouldRepaint(covariant WaveformPainter oldDelegate) { + return oldDelegate.waveformData.length != waveformData.length || + oldDelegate.waveColor != waveColor; + } +} diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index f547c379..b180d86f 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 6462693b..dcc28ba2 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux open_file_linux + record_linux url_launcher_linux ) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index 57be32d1..a608bccb 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -13,6 +13,7 @@ import flutter_archive import mcumgr_flutter import open_file_mac import package_info_plus +import record_macos import share_plus import shared_preferences_foundation import universal_ble @@ -28,6 +29,7 @@ func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { McumgrFlutterPlugin.register(with: registry.registrar(forPlugin: "McumgrFlutterPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 56fc3dd2..64db1b95 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -25,6 +25,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" + audio_waveforms: + dependency: "direct main" + description: + name: audio_waveforms + sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" + url: "https://pub.dev" + source: hosted + version: "2.0.2" audioplayers: dependency: "direct main" description: @@ -540,10 +548,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -761,7 +769,7 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: transitive + dependency: "direct main" description: name: permission_handler sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 @@ -872,6 +880,54 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" record_use: dependency: transitive description: @@ -880,6 +936,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 38a2cf36..f94e22f4 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -63,6 +63,9 @@ dependencies: device_info_plus: ^12.3.0 pub_semver: ^2.2.0 playback_capture: ^0.0.4 + audio_waveforms: ^2.0.2 + record: ^6.1.2 + permission_handler: ^12.0.1 dev_dependencies: flutter_test: diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index 37245d29..0eaaf699 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UniversalBlePluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index ed5b9c00..3689918f 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -6,6 +6,7 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows permission_handler_windows + record_windows share_plus universal_ble url_launcher_windows From 648a72c03a2f68cf66d48b25fa938cc909439814 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 19 Jan 2026 14:53:19 +0100 Subject: [PATCH 03/34] fix scrolling problems with charts screen --- .../sensors/values/sensor_values_page.dart | 15 ++++++++++----- open_wearable/pubspec.lock | 8 ++++++++ open_wearable/pubspec.yaml | 1 + 3 files changed, 19 insertions(+), 5 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index b91e31b5..801b1b9d 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -709,17 +709,22 @@ class WaveformPainter extends CustomPainter { ..strokeCap = StrokeCap.round ..style = PaintingStyle.stroke; - // Calculate starting position to align bars from right - final totalWaveformWidth = waveformData.length * spacing; + // Calculate how many bars can fit in the available width + final maxBars = (size.width / spacing).floor(); + final startIndex = + waveformData.length > maxBars ? waveformData.length - maxBars : 0; + + // Calculate starting position (always start at 0 or align right) + final visibleData = waveformData.sublist(startIndex); + final totalWaveformWidth = visibleData.length * spacing; final startX = size.width - totalWaveformWidth; // Draw each amplitude value as a vertical bar - for (int i = 0; i < waveformData.length; i++) { + for (int i = 0; i < visibleData.length; i++) { final x = startX + (i * spacing); - final amplitude = waveformData[i]; + final amplitude = visibleData[i]; // Scale amplitude to fit within the canvas height - // Amplitude is normalized to 0-2 range, scale it to use 80% of half height final barHeight = amplitude * centerY * 0.8; // Draw top half of the bar (above center line) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 64db1b95..44a0b7e4 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -366,6 +366,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + flutter_headset_detector: + dependency: "direct main" + description: + name: flutter_headset_detector + sha256: fe061eceb106a61b837ae58eda1575604db24299a4ebe13e34839dd3d30085df + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_lints: dependency: "direct dev" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index f94e22f4..b347d3ad 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -64,6 +64,7 @@ dependencies: pub_semver: ^2.2.0 playback_capture: ^0.0.4 audio_waveforms: ^2.0.2 + flutter_headset_detector: ^3.1.0 record: ^6.1.2 permission_handler: ^12.0.1 From 4f11d0d71ef552df60e2db48afab89d859338507 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 19 Jan 2026 15:42:05 +0100 Subject: [PATCH 04/34] auto select OpenEarable as audio source, improve UI of Chart tab, fix spacing --- open_wearable/ios/Podfile.lock | 6 ++++++ .../widgets/sensors/values/sensor_values_page.dart | 14 +++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index e5d0411d..2a1f5fbc 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -46,6 +46,8 @@ PODS: - flutter_archive (0.0.1): - Flutter - ZIPFoundation (= 0.9.19) + - flutter_headset_detector (3.1.0): + - Flutter - iOSMcuManagerLibrary (1.10.1): - SwiftCBOR (= 0.4.7) - ZIPFoundation (= 0.9.19) @@ -89,6 +91,7 @@ DEPENDENCIES: - file_selector_ios (from `.symlinks/plugins/file_selector_ios/ios`) - Flutter (from `Flutter`) - flutter_archive (from `.symlinks/plugins/flutter_archive/ios`) + - flutter_headset_detector (from `.symlinks/plugins/flutter_headset_detector/ios`) - mcumgr_flutter (from `.symlinks/plugins/mcumgr_flutter/ios`) - open_file_ios (from `.symlinks/plugins/open_file_ios/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -126,6 +129,8 @@ EXTERNAL SOURCES: :path: Flutter flutter_archive: :path: ".symlinks/plugins/flutter_archive/ios" + flutter_headset_detector: + :path: ".symlinks/plugins/flutter_headset_detector/ios" mcumgr_flutter: :path: ".symlinks/plugins/mcumgr_flutter/ios" open_file_ios: @@ -157,6 +162,7 @@ SPEC CHECKSUMS: file_selector_ios: ec57ec07954363dd730b642e765e58f199bb621a Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467 flutter_archive: ad8edfd7f7d1bb12058d05424ba93e27d9930efe + flutter_headset_detector: 37d2407c6c59aa6e8a9daecf732854862ff6dd4a iOSMcuManagerLibrary: e9555825af11a61744fe369c12e1e66621061b58 mcumgr_flutter: 969e99cc15e9fe658242669ce1075bf4612aef8a open_file_ios: 46184d802ee7959203f6392abcfa0dd49fdb5be0 diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 801b1b9d..8747566a 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -104,9 +104,17 @@ class _SensorValuesPageState extends State if (mounted) { setState(() { _devices = devs; - if (_selectedDevice == null && _devices.isNotEmpty) { - _selectedDevice = _devices.first; - print("Selected device: ${_selectedDevice?.label}"); + // Automatically select BLE headset + _selectedDevice = _devices.firstWhere( + (device) => + device.label.toLowerCase().contains('bluetooth') || + device.label.toLowerCase().contains('ble') || + device.label.toLowerCase().contains('headset'), + orElse: () => + _devices.isNotEmpty ? _devices.first : null as InputDevice, + ); + if (_selectedDevice != null) { + print("Auto-selected BLE device: ${_selectedDevice?.label}"); } }); } From 4800892917ba0e77656ac3bca50fb2ebeb770b5c Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Fri, 23 Jan 2026 17:17:19 +0100 Subject: [PATCH 05/34] add audio file to recording folder. Fixed some bugs: switching tabs does not cause audio recording to stop. Audio recording is only available on android. Audio recording only records from ble headset. --- open_wearable/ios/Podfile.lock | 6 - .../view_models/sensor_recorder_provider.dart | 121 +++++++- .../sensors/values/sensor_values_page.dart | 273 +++++++++++------- open_wearable/pubspec.lock | 8 - open_wearable/pubspec.yaml | 1 - 5 files changed, 281 insertions(+), 128 deletions(-) diff --git a/open_wearable/ios/Podfile.lock b/open_wearable/ios/Podfile.lock index 2a1f5fbc..82b7fdd3 100644 --- a/open_wearable/ios/Podfile.lock +++ b/open_wearable/ios/Podfile.lock @@ -1,6 +1,4 @@ PODS: - - audio_waveforms (0.0.1): - - Flutter - audioplayers_darwin (0.0.1): - Flutter - FlutterMacOS @@ -84,7 +82,6 @@ PODS: - ZIPFoundation (0.9.19) DEPENDENCIES: - - audio_waveforms (from `.symlinks/plugins/audio_waveforms/ios`) - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`) - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`) - file_picker (from `.symlinks/plugins/file_picker/ios`) @@ -115,8 +112,6 @@ SPEC REPOS: - ZIPFoundation EXTERNAL SOURCES: - audio_waveforms: - :path: ".symlinks/plugins/audio_waveforms/ios" audioplayers_darwin: :path: ".symlinks/plugins/audioplayers_darwin/darwin" device_info_plus: @@ -153,7 +148,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/wakelock_plus/ios" SPEC CHECKSUMS: - audio_waveforms: a6dde7fe7c0ea05f06ffbdb0f7c1b2b2ba6cedcf audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index d79e2627..00884db5 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:record/record.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; @@ -32,18 +33,50 @@ class SensorRecorderProvider with ChangeNotifier { bool _hasSensorsConnected = false; String? _currentDirectory; DateTime? _recordingStart; + final AudioRecorder _audioRecorder = AudioRecorder(); + bool _isAudioRecording = false; + String? _currentAudioPath; + StreamSubscription? _amplitudeSub; bool get isRecording => _isRecording; bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; + final List _waveformData = []; + List get waveformData => List.unmodifiable(_waveformData); + + InputDevice? _selectedBLEDevice; + + Future _selectBLEDevice() async { + try { + final devices = await _audioRecorder.listInputDevices(); + + try { + _selectedBLEDevice = devices.firstWhere( + (device) => + device.label.toLowerCase().contains('bluetooth') || + device.label.toLowerCase().contains('ble') || + device.label.toLowerCase().contains('headset') || + device.label.toLowerCase().contains('openearable'), + ); + logger.i("Selected audio input device: ${_selectedBLEDevice!.label}"); + } catch (e) { + _selectedBLEDevice = null; + logger.w("No BLE headset found"); + } + } catch (e) { + logger.e("Error selecting BLE device: $e"); + _selectedBLEDevice = null; + } + } + Future startRecording(String dirname) async { if (_isRecording) { return; } - _recordingFilepathsBySensorIdentity.clear(); + _isRecording = true; _currentDirectory = dirname; _recordingStart = DateTime.now(); @@ -63,13 +96,90 @@ class SensorRecorderProvider with ChangeNotifier { notifyListeners(); rethrow; } + + await _startAudioRecording( + dirname, + ); + + notifyListeners(); + } + + Future _startAudioRecording(String recordingFolderPath) async { + if (_selectedBLEDevice == null) { + logger.w("No BLE headset detected, skipping audio recording"); + return; + } + if (!Platform.isAndroid) return; + try { + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for recording"); + return; + } + + await _selectBLEDevice(); + + const encoder = AudioEncoder.wav; + if (!await _audioRecorder.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + return; + } + + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final audioPath = '$recordingFolderPath/audio_$timestamp.wav'; + + final config = RecordConfig( + encoder: encoder, + sampleRate: 48000, // Set to 48kHz for BLE audio quality + bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps + numChannels: 1, + device: _selectedBLEDevice, + ); + + await _audioRecorder.start(config, path: audioPath); + _currentAudioPath = audioPath; + _isAudioRecording = true; + + logger.i( + "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}", + ); + + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen((amp) { + final normalized = (amp.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + + notifyListeners(); + }); + } catch (e) { + logger.e("Failed to start audio recording: $e"); + _isAudioRecording = false; + } } - void stopRecording() { + void stopRecording() async { _isRecording = false; _recordingStart = null; _recordingFilepathsBySensorIdentity.clear(); _stopAllRecorderStreams(); + try { + if (_isAudioRecording) { + final path = await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isAudioRecording = false; + _waveformData.clear(); + + logger.i("Audio recording saved to: $path"); + _currentAudioPath = null; + } + } catch (e) { + logger.e("Error stopping audio recording: $e"); + } notifyListeners(); } @@ -307,6 +417,13 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { _disposed = true; + _audioRecorder.stop().then((_) { + _audioRecorder.dispose(); + }).catchError((e) { + logger.e("Error stopping audio in dispose: $e"); + }); + _amplitudeSub?.cancel(); + _waveformData.clear(); for (final wearable in _recorders.keys.toList()) { _disposeWearable(wearable); } diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 8747566a..e4b4434d 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,9 +1,13 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:logger/logger.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; @@ -13,6 +17,8 @@ import 'package:record/record.dart'; import 'dart:async'; import 'package:path_provider/path_provider.dart'; +Logger _logger = Logger(); + class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -36,15 +42,16 @@ class _SensorValuesPageState extends State // Audio State late final AudioRecorder _audioRecorder; + bool _isPreviewRecording = false; bool _isRecording = false; String? _errorMessage; - List _devices = []; InputDevice? _selectedDevice; StreamSubscription? _recordSub; StreamSubscription? _amplitudeSub; RecordState _recordState = RecordState.stop; - List _waveformData = []; - Amplitude? _amplitude; + final List _waveformData = []; + + bool _isInitializing = true; @override bool get wantKeepAlive => true; @@ -52,38 +59,52 @@ class _SensorValuesPageState extends State @override void initState() { super.initState(); - _audioRecorder = AudioRecorder(); + if (Platform.isAndroid) { + _audioRecorder = AudioRecorder(); - // Only subscribe to state changes initially - _recordSub = _audioRecorder.onStateChanged().listen((recordState) { - if (mounted) { - setState(() => _recordState = recordState); + _recordSub = _audioRecorder!.onStateChanged().listen((recordState) { + if (mounted) { + setState(() => _recordState = recordState); - // Clean up amplitude subscription when recording stops - if (recordState == RecordState.stop) { - _amplitudeSub?.cancel(); - _amplitudeSub = null; + if (recordState == RecordState.stop) { + _amplitudeSub?.cancel(); + _amplitudeSub = null; + } } - } - }); + }); + + _initRecording(); + } + } - _initRecording(); + // Add method to check if provider is recording + bool _isProviderRecording(BuildContext context) { + try { + final recorder = + Provider.of(context, listen: false); + return recorder.isRecording; + } catch (e) { + return false; + } } Future _initRecording() async { - print("Initializing audio recorder"); + if (!Platform.isAndroid || _audioRecorder == null) return; + + if (_isProviderRecording(context)) { + if (mounted) setState(() => _isInitializing = false); + return; + } try { - if (await _audioRecorder.hasPermission()) { - print("Permission granted"); - await _loadDevices(); - await _startRecording(); + if (await _audioRecorder!.hasPermission()) { + await _selectBLEDevice(); + await _startPreview(); } else { - print("No permission, requesting..."); final status = await Permission.microphone.request(); if (status.isGranted) { - await _loadDevices(); - await _startRecording(); + await _selectBLEDevice(); + await _startPreview(); } else { if (mounted) { setState(() => _errorMessage = 'Microphone permission denied'); @@ -91,81 +112,97 @@ class _SensorValuesPageState extends State } } } catch (e) { - print("Init error: $e"); if (mounted) { setState(() => _errorMessage = 'Failed to initialize: $e'); } + } finally { + if (mounted) { + setState(() => _isInitializing = false); + } } } - Future _loadDevices() async { + Future _selectBLEDevice() async { + if (!Platform.isAndroid || _audioRecorder == null) return; try { - final devs = await _audioRecorder.listInputDevices(); - if (mounted) { - setState(() { - _devices = devs; - // Automatically select BLE headset - _selectedDevice = _devices.firstWhere( - (device) => - device.label.toLowerCase().contains('bluetooth') || - device.label.toLowerCase().contains('ble') || - device.label.toLowerCase().contains('headset'), - orElse: () => - _devices.isNotEmpty ? _devices.first : null as InputDevice, - ); - if (_selectedDevice != null) { - print("Auto-selected BLE device: ${_selectedDevice?.label}"); - } - }); + final devices = await _audioRecorder!.listInputDevices(); + + // Try to find BLE device + try { + _selectedDevice = devices.firstWhere( + (device) => + device.label.toLowerCase().contains('bluetooth') || + device.label.toLowerCase().contains('ble') || + device.label.toLowerCase().contains('headset') || + device.label.toLowerCase().contains('openearable'), + ); + _logger.i( + "Auto-selected BLE device for preview: ${_selectedDevice!.label}"); + } catch (e) { + // No BLE device found + _selectedDevice = null; + _logger.e("No BLE headset found"); } } catch (e) { - print("Error loading devices: $e"); + _logger.e("Error selecting BLE device: $e"); + _selectedDevice = null; } } - Future _getRecordingPath() async { + Future _getTemporaryPath() async { final directory = await getTemporaryDirectory(); - return '${directory.path}/temp_recording_${DateTime.now().millisecondsSinceEpoch}.m4a'; + return '${directory.path}/preview_${DateTime.now().millisecondsSinceEpoch}.m4a'; } - Future _startRecording() async { + Future _startPreview() async { + if (!Platform.isAndroid || _audioRecorder == null) return; + + // Don't start if provider is recording + if (_isProviderRecording(context)) { + return; + } + + // Don't start if no BLE device selected + if (_selectedDevice == null) { + if (mounted) { + setState(() => _errorMessage = 'No BLE headset detected'); + } + return; + } + try { - const encoder = AudioEncoder.aacLc; + const encoder = AudioEncoder.wav; - if (!await _audioRecorder.isEncoderSupported(encoder)) { + if (!await _audioRecorder!.isEncoderSupported(encoder)) { if (mounted) { - setState(() => _errorMessage = 'Encoder not supported'); + setState(() => _errorMessage = 'WAV encoder not supported'); } return; } - final path = await _getRecordingPath(); + final path = await _getTemporaryPath(); final config = RecordConfig( encoder: encoder, + sampleRate: 48000, + bitRate: 768000, numChannels: 1, device: _selectedDevice, ); - await _audioRecorder.start(config, path: path); - - // Wait a bit to ensure recording is active + await _audioRecorder!.start(config, path: path); await Future.delayed(Duration(milliseconds: 100)); _amplitudeSub?.cancel(); - // Subscribe to amplitude changes after recording started - _amplitudeSub = _audioRecorder + _amplitudeSub = _audioRecorder! .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen( (amp) { if (mounted) { setState(() { - _amplitude = amp; - // Add normalized amplitude to waveform data final normalized = (amp.current + 50) / 50; _waveformData.add(normalized.clamp(0.0, 2.0)); - // Keep only last 100 samples if (_waveformData.length > 100) { _waveformData.removeAt(0); } @@ -173,57 +210,85 @@ class _SensorValuesPageState extends State } }, onError: (error) { - print("Amplitude stream error: $error"); + _logger.e("Amplitude stream error: $error"); }, ); if (mounted) { setState(() { - _isRecording = true; + _isPreviewRecording = true; _errorMessage = null; }); } } catch (e) { - print("Recording start error: $e"); + _logger.e("Preview start error: $e"); if (mounted) { - setState(() => _errorMessage = 'Failed to start recording: $e'); + setState(() => _errorMessage = 'Failed to start preview: $e'); } } } - Future _changeDevice(InputDevice? device) async { - if (device == null) return; + Future _stopPreview() async { + if (!Platform.isAndroid || _audioRecorder == null) return; + if (!_isPreviewRecording) return; - // Stop current recording - if (_recordState != RecordState.stop) { - await _audioRecorder.stop(); + try { + final tempPath = await _audioRecorder!.stop(); _amplitudeSub?.cancel(); _amplitudeSub = null; - } - // Update selected device and restart - if (mounted) { - setState(() { - _selectedDevice = device; - _waveformData.clear(); - _isRecording = false; - }); - } + if (tempPath != null) { + try { + final file = File(tempPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + _logger.e("Error deleting temp preview file: $e"); + } + } - await _startRecording(); + if (mounted) { + setState(() { + _isPreviewRecording = false; + _waveformData.clear(); + }); + } + } catch (e) { + _logger.e("Error stopping preview: $e"); + } } @override void dispose() { - _audioRecorder.stop(); - _recordSub?.cancel(); - _amplitudeSub?.cancel(); - _audioRecorder.dispose(); if (_ownsProviders) { for (final provider in _ownedProviders.values) { provider.dispose(); } _ownedProviders.clear(); + // Stop and clean up preview recording + if (Platform.isAndroid) { + if (_recordState != RecordState.stop) { + _audioRecorder.stop().then((tempPath) { + if (tempPath != null) { + try { + final file = File(tempPath); + file.exists().then((exists) { + if (exists) { + file.delete(); + } + }); + } catch (e) { + _logger.e("Error deleting temp preview file: $e"); + } + } + }); + } + + _recordSub?.cancel(); + _amplitudeSub?.cancel(); + _audioRecorder.dispose(); + } } super.dispose(); } @@ -240,8 +305,18 @@ class _SensorValuesPageState extends State builder: (context, hideCardsWithoutLiveData, __) { final shouldHideCardsWithoutLiveData = hideCardsWithoutLiveData && !disableLiveDataGraphs; - return Consumer( - builder: (context, wearablesProvider, child) { + return Consumer2( + builder: (context, wearablesProvider, recorderProvider, child) { + // Stop preview if provider starts recording + if (Platform.isAndroid && + recorderProvider.isRecording && + _isPreviewRecording) { + _stopPreview(); + } else if (Platform.isAndroid && + !recorderProvider.isRecording && + !_isPreviewRecording) { + _initRecording(); + } return FutureBuilder>( future: buildWearableDisplayGroups( wearablesProvider.wearables, @@ -449,32 +524,6 @@ class _SensorValuesPageState extends State Widget _buildAudioUI() { return Column( children: [ - if (_devices.isNotEmpty) - Card( - child: Padding( - padding: const EdgeInsets.all(8.0), - child: Row( - children: [ - const Text('Input: ', - style: TextStyle(fontWeight: FontWeight.bold)), - const SizedBox(width: 8), - Expanded( - child: DropdownButton( - value: _selectedDevice, - isExpanded: true, - items: _devices - .map((d) => - DropdownMenuItem(value: d, child: Text(d.label))) - .toList(), - onChanged: _changeDevice, - ), - ), - IconButton( - icon: const Icon(Icons.refresh), onPressed: _loadDevices), - ], - ), - ), - ), if (_isRecording) Card( child: Padding( @@ -488,8 +537,10 @@ class _SensorValuesPageState extends State else if (_errorMessage != null) Padding( padding: const EdgeInsets.all(8.0), - child: PlatformText(_errorMessage!, - style: const TextStyle(color: Colors.red)), + child: PlatformText( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), ), const SizedBox(height: 10), ], diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 44a0b7e4..7286a371 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -25,14 +25,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.13.1" - audio_waveforms: - dependency: "direct main" - description: - name: audio_waveforms - sha256: "03b3430ecf430a2e90185518a228c02be3d26653c62dd931e50d671213a6dbc8" - url: "https://pub.dev" - source: hosted - version: "2.0.2" audioplayers: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index b347d3ad..3bccff6a 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -63,7 +63,6 @@ dependencies: device_info_plus: ^12.3.0 pub_semver: ^2.2.0 playback_capture: ^0.0.4 - audio_waveforms: ^2.0.2 flutter_headset_detector: ^3.1.0 record: ^6.1.2 permission_handler: ^12.0.1 From cadb81f3e31dab7f56244684be666b73f8963219 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 26 Jan 2026 15:05:05 +0100 Subject: [PATCH 06/34] fix bug --- .../lib/view_models/sensor_recorder_provider.dart | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 00884db5..c3207daa 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -105,10 +105,6 @@ class SensorRecorderProvider with ChangeNotifier { } Future _startAudioRecording(String recordingFolderPath) async { - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, skipping audio recording"); - return; - } if (!Platform.isAndroid) return; try { if (!await _audioRecorder.hasPermission()) { @@ -118,6 +114,11 @@ class SensorRecorderProvider with ChangeNotifier { await _selectBLEDevice(); + if (_selectedBLEDevice == null) { + logger.w("No BLE headset detected, skipping audio recording"); + return; + } + const encoder = AudioEncoder.wav; if (!await _audioRecorder.isEncoderSupported(encoder)) { logger.w("WAV encoder not supported"); From 0095fe6982df1e5203abe7f1d7a7fd0b50c9d523 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 26 Jan 2026 17:46:02 +0100 Subject: [PATCH 07/34] starting of audio streaming is now manuallycontrolled --- .../view_models/sensor_recorder_provider.dart | 151 +++++++++++- .../ble_microphone_streaming_row.dart | 53 +++++ .../sensor_configuration_device_row.dart | 7 + .../sensors/values/sensor_values_page.dart | 223 ++---------------- 4 files changed, 225 insertions(+), 209 deletions(-) create mode 100644 open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index c3207daa..7ce7bf1d 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; import '../models/logger.dart'; @@ -48,6 +49,14 @@ class SensorRecorderProvider with ChangeNotifier { InputDevice? _selectedBLEDevice; + bool _isBLEMicrophoneStreamingEnabled = false; + bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; + + // Separate AudioRecorder for streaming + AudioRecorder? _streamingAudioRecorder; + bool _isStreamingActive = false; + StreamSubscription? _streamingAmplitudeSub; + Future _selectBLEDevice() async { try { final devices = await _audioRecorder.listInputDevices(); @@ -71,11 +80,120 @@ class SensorRecorderProvider with ChangeNotifier { } } - Future startRecording(String dirname) async { - if (_isRecording) { + Future startBLEMicrophoneStream() async { + if (!Platform.isAndroid) { + logger.w("BLE microphone streaming only supported on Android"); + return false; + } + + if (_isStreamingActive) { + logger.i("BLE microphone streaming already active"); + return true; + } + + try { + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for streaming"); + return false; + } + + await _selectBLEDevice(); + + if (_selectedBLEDevice == null) { + logger.w("No BLE headset detected, cannot start streaming"); + return false; + } + + _streamingAudioRecorder = AudioRecorder(); + + const encoder = AudioEncoder.wav; + if (!await _streamingAudioRecorder!.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + _streamingAudioRecorder = null; + return false; + } + + final tempDir = await getTemporaryDirectory(); + final tempPath = + '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; + + final config = RecordConfig( + encoder: encoder, + sampleRate: 48000, + bitRate: 768000, + numChannels: 1, + device: _selectedBLEDevice, + ); + + await _streamingAudioRecorder!.start(config, path: tempPath); + _isStreamingActive = true; + _isBLEMicrophoneStreamingEnabled = true; + + // Set up amplitude monitoring for waveform display + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = _streamingAudioRecorder! + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen((amp) { + final normalized = (amp.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + + notifyListeners(); + }); + + Future.delayed(const Duration(seconds: 1), () async { + try { + final file = File(tempPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + }); + + logger.i( + "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}", + ); + notifyListeners(); + return true; + } catch (e) { + logger.e("Failed to start BLE microphone streaming: $e"); + _isStreamingActive = false; + _isBLEMicrophoneStreamingEnabled = false; + _streamingAudioRecorder?.dispose(); + _streamingAudioRecorder = null; + notifyListeners(); + return false; + } + } + + Future stopBLEMicrophoneStream() async { + if (!_isStreamingActive) { return; } - _recordingFilepathsBySensorIdentity.clear(); + + try { + await _streamingAudioRecorder?.stop(); + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = null; + _streamingAudioRecorder?.dispose(); + _streamingAudioRecorder = null; + _isStreamingActive = false; + _isBLEMicrophoneStreamingEnabled = false; + _waveformData.clear(); + + logger.i("BLE microphone streaming stopped"); + notifyListeners(); + } catch (e) { + logger.e("Error stopping BLE microphone streaming: $e"); + } + } + + void startRecording(String dirname) async { _isRecording = true; _currentDirectory = dirname; _recordingStart = DateTime.now(); @@ -106,6 +224,22 @@ class SensorRecorderProvider with ChangeNotifier { Future _startAudioRecording(String recordingFolderPath) async { if (!Platform.isAndroid) return; + + // Only start recording if BLE microphone streaming is enabled + if (!_isBLEMicrophoneStreamingEnabled) { + logger + .w("BLE microphone streaming not enabled, skipping audio recording"); + return; + } + + // Stop streaming session before starting actual recording + if (_isStreamingActive) { + await _streamingAudioRecorder?.stop(); + _streamingAmplitudeSub?.cancel(); + _streamingAmplitudeSub = null; + _isStreamingActive = false; + } + try { if (!await _audioRecorder.hasPermission()) { logger.w("No microphone permission for recording"); @@ -181,6 +315,12 @@ class SensorRecorderProvider with ChangeNotifier { } catch (e) { logger.e("Error stopping audio recording: $e"); } + + // Restart streaming if it was enabled before recording + if (_isBLEMicrophoneStreamingEnabled && !_isStreamingActive) { + unawaited(startBLEMicrophoneStream()); + } + notifyListeners(); } @@ -417,7 +557,10 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { - _disposed = true; + // Stop streaming + stopBLEMicrophoneStream(); + + // Stop recording _audioRecorder.stop().then((_) { _audioRecorder.dispose(); }).catchError((e) { diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart new file mode 100644 index 00000000..e6efa864 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -0,0 +1,53 @@ +import 'dart:io'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:provider/provider.dart'; +import '../../../view_models/sensor_recorder_provider.dart'; + +/// Widget to control BLE microphone streaming +class BLEMicrophoneStreamingRow extends StatelessWidget { + const BLEMicrophoneStreamingRow({super.key}); + + @override + Widget build(BuildContext context) { + if (!Platform.isAndroid) { + return const SizedBox.shrink(); + } + + return Consumer( + builder: (context, recorderProvider, child) { + final isStreamingEnabled = recorderProvider.isBLEMicrophoneStreamingEnabled; + + return PlatformListTile( + title: PlatformText('BLE Microphone Streaming'), + subtitle: PlatformText( + isStreamingEnabled + ? 'Microphone stream is active' + : 'Enable to start microphone streaming', + ), + trailing: PlatformSwitch( + value: isStreamingEnabled, + onChanged: (value) async { + if (value) { + final success = await recorderProvider.startBLEMicrophoneStream(); + if (!success && context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: PlatformText( + 'Failed to start BLE microphone streaming. ' + 'Make sure a BLE headset is connected and microphone permission is granted.', + ), + backgroundColor: Colors.red, + ), + ); + } + } else { + await recorderProvider.stopBLEMicrophoneStream(); + } + }, + ), + ); + }, + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 4845f87a..e7db901c 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -7,6 +7,7 @@ import 'package:open_wearable/view_models/sensor_configuration_storage.dart'; import 'package:open_wearable/view_models/sensor_profile_service.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; +import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_profile_widgets.dart'; @@ -383,6 +384,12 @@ class _SensorConfigurationDeviceRowState ), ]; + // Add BLE microphone streaming control (Android only) + content.addAll([ + const Divider(), + const BLEMicrophoneStreamingRow(), + ]); + if (device.hasCapability()) { content.addAll([ const InsetSectionDivider(), diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index e4b4434d..55c3cc0e 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -2,7 +2,6 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:logger/logger.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; @@ -11,13 +10,9 @@ import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; -import 'package:permission_handler/permission_handler.dart'; import 'package:provider/provider.dart'; import 'package:record/record.dart'; import 'dart:async'; -import 'package:path_provider/path_provider.dart'; - -Logger _logger = Logger(); class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -60,202 +55,28 @@ class _SensorValuesPageState extends State void initState() { super.initState(); if (Platform.isAndroid) { - _audioRecorder = AudioRecorder(); - - _recordSub = _audioRecorder!.onStateChanged().listen((recordState) { - if (mounted) { - setState(() => _recordState = recordState); - - if (recordState == RecordState.stop) { - _amplitudeSub?.cancel(); - _amplitudeSub = null; - } - } - }); - - _initRecording(); - } - } - - // Add method to check if provider is recording - bool _isProviderRecording(BuildContext context) { - try { - final recorder = - Provider.of(context, listen: false); - return recorder.isRecording; - } catch (e) { - return false; + _checkStreamingStatus(); } } - Future _initRecording() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - - if (_isProviderRecording(context)) { - if (mounted) setState(() => _isInitializing = false); - return; - } - - try { - if (await _audioRecorder!.hasPermission()) { - await _selectBLEDevice(); - await _startPreview(); - } else { - final status = await Permission.microphone.request(); - if (status.isGranted) { - await _selectBLEDevice(); - await _startPreview(); - } else { - if (mounted) { - setState(() => _errorMessage = 'Microphone permission denied'); - } - } - } - } catch (e) { - if (mounted) { - setState(() => _errorMessage = 'Failed to initialize: $e'); - } - } finally { - if (mounted) { - setState(() => _isInitializing = false); - } - } - } - - Future _selectBLEDevice() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - try { - final devices = await _audioRecorder!.listInputDevices(); - - // Try to find BLE device - try { - _selectedDevice = devices.firstWhere( - (device) => - device.label.toLowerCase().contains('bluetooth') || - device.label.toLowerCase().contains('ble') || - device.label.toLowerCase().contains('headset') || - device.label.toLowerCase().contains('openearable'), - ); - _logger.i( - "Auto-selected BLE device for preview: ${_selectedDevice!.label}"); - } catch (e) { - // No BLE device found - _selectedDevice = null; - _logger.e("No BLE headset found"); - } - } catch (e) { - _logger.e("Error selecting BLE device: $e"); - _selectedDevice = null; - } - } - - Future _getTemporaryPath() async { - final directory = await getTemporaryDirectory(); - return '${directory.path}/preview_${DateTime.now().millisecondsSinceEpoch}.m4a'; - } - - Future _startPreview() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - - // Don't start if provider is recording - if (_isProviderRecording(context)) { - return; - } - - // Don't start if no BLE device selected - if (_selectedDevice == null) { - if (mounted) { - setState(() => _errorMessage = 'No BLE headset detected'); - } - return; - } - - try { - const encoder = AudioEncoder.wav; - - if (!await _audioRecorder!.isEncoderSupported(encoder)) { - if (mounted) { - setState(() => _errorMessage = 'WAV encoder not supported'); - } - return; - } - - final path = await _getTemporaryPath(); - - final config = RecordConfig( - encoder: encoder, - sampleRate: 48000, - bitRate: 768000, - numChannels: 1, - device: _selectedDevice, - ); - - await _audioRecorder!.start(config, path: path); - await Future.delayed(Duration(milliseconds: 100)); - - _amplitudeSub?.cancel(); - _amplitudeSub = _audioRecorder! - .onAmplitudeChanged(const Duration(milliseconds: 100)) - .listen( - (amp) { - if (mounted) { - setState(() { - final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - }); - } - }, - onError: (error) { - _logger.e("Amplitude stream error: $error"); - }, - ); - + void _checkStreamingStatus() { + final recorderProvider = + Provider.of(context, listen: false); + if (!recorderProvider.isBLEMicrophoneStreamingEnabled) { if (mounted) { setState(() { - _isPreviewRecording = true; - _errorMessage = null; + _isInitializing = false; + _errorMessage = + 'BLE microphone streaming not enabled. Enable it in sensor configuration.'; }); } - } catch (e) { - _logger.e("Preview start error: $e"); - if (mounted) { - setState(() => _errorMessage = 'Failed to start preview: $e'); - } - } - } - - Future _stopPreview() async { - if (!Platform.isAndroid || _audioRecorder == null) return; - if (!_isPreviewRecording) return; - - try { - final tempPath = await _audioRecorder!.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - - if (tempPath != null) { - try { - final file = File(tempPath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - _logger.e("Error deleting temp preview file: $e"); - } - } - + } else { if (mounted) { setState(() { - _isPreviewRecording = false; - _waveformData.clear(); + _isInitializing = false; + _errorMessage = null; }); } - } catch (e) { - _logger.e("Error stopping preview: $e"); } } @@ -279,7 +100,7 @@ class _SensorValuesPageState extends State } }); } catch (e) { - _logger.e("Error deleting temp preview file: $e"); + //_logger.e("Error deleting temp preview file: $e"); } } }); @@ -307,16 +128,6 @@ class _SensorValuesPageState extends State hideCardsWithoutLiveData && !disableLiveDataGraphs; return Consumer2( builder: (context, wearablesProvider, recorderProvider, child) { - // Stop preview if provider starts recording - if (Platform.isAndroid && - recorderProvider.isRecording && - _isPreviewRecording) { - _stopPreview(); - } else if (Platform.isAndroid && - !recorderProvider.isRecording && - !_isPreviewRecording) { - _initRecording(); - } return FutureBuilder>( future: buildWearableDisplayGroups( wearablesProvider.wearables, @@ -563,8 +374,9 @@ class _SensorValuesPageState extends State child: _buildEmptyStateCard( context, _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData), + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ), ), ), ], @@ -597,8 +409,9 @@ class _SensorValuesPageState extends State return _buildEmptyStateCard( context, _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData), + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ), ); } return charts[index]; From f6a8d0b2d34f64299cc20ad59ad5ddc86f5c6511 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 27 Jan 2026 15:49:43 +0100 Subject: [PATCH 08/34] microphone stream can now be turned on and off together with the sensor configuration. OpenEarable firmware needs adjustment for this to work. Haven't disabled it for iOS yet --- .../ble_microphone_streaming_row.dart | 7 ++++--- .../sensor_configuration_device_row.dart | 11 +++++------ .../configuration/sensor_configuration_view.dart | 16 ++++++++++++++++ 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart index e6efa864..16c0bb5e 100644 --- a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -4,7 +4,6 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; import '../../../view_models/sensor_recorder_provider.dart'; -/// Widget to control BLE microphone streaming class BLEMicrophoneStreamingRow extends StatelessWidget { const BLEMicrophoneStreamingRow({super.key}); @@ -16,7 +15,8 @@ class BLEMicrophoneStreamingRow extends StatelessWidget { return Consumer( builder: (context, recorderProvider, child) { - final isStreamingEnabled = recorderProvider.isBLEMicrophoneStreamingEnabled; + final isStreamingEnabled = + recorderProvider.isBLEMicrophoneStreamingEnabled; return PlatformListTile( title: PlatformText('BLE Microphone Streaming'), @@ -29,7 +29,8 @@ class BLEMicrophoneStreamingRow extends StatelessWidget { value: isStreamingEnabled, onChanged: (value) async { if (value) { - final success = await recorderProvider.startBLEMicrophoneStream(); + final success = + await recorderProvider.startBLEMicrophoneStream(); if (!success && context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index e7db901c..cdc66924 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -6,6 +6,7 @@ import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/view_models/sensor_configuration_storage.dart'; import 'package:open_wearable/view_models/sensor_profile_service.dart'; import 'package:open_wearable/widgets/app_toast.dart'; +import 'package:open_wearable/widgets/devices/device_detail/stereo_pos_label.dart'; import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; @@ -140,6 +141,10 @@ class _SensorConfigurationDeviceRowState child: tabBar, ), ], + if (device.hasCapability()) + StereoPosLabel( + device: device.requireCapability(), + ), ], ), ), @@ -384,12 +389,6 @@ class _SensorConfigurationDeviceRowState ), ]; - // Add BLE microphone streaming control (Android only) - content.addAll([ - const Divider(), - const BLEMicrophoneStreamingRow(), - ]); - if (device.hasCapability()) { content.addAll([ const InsetSectionDivider(), diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index cc9f6411..e1634df6 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -4,6 +4,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -274,6 +275,10 @@ class SensorConfigurationView extends StatelessWidget { int actionableCount = 0; + final recorderProvider = + Provider.of(context, listen: false); + bool shouldEnableMicrophoneStreaming = false; + for (final target in targets) { final primaryEntriesToApply = _entriesToApplyForProvider(target.provider); final mirroredEntriesToApply = _entriesToApplyForMirroredTarget(target); @@ -286,6 +291,9 @@ class SensorConfigurationView extends StatelessWidget { for (final entry in primaryEntriesToApply) { final SensorConfiguration config = entry.$1; final SensorConfigurationValue value = entry.$2; + if (config.name.toLowerCase().contains('microphone')) { + shouldEnableMicrophoneStreaming = true; + } // Always push the selected canonical value to the primary device on // apply. This also heals primary-side drift/unknown states. config.setConfiguration(value); @@ -297,6 +305,14 @@ class SensorConfigurationView extends StatelessWidget { config.setConfiguration(value); } + if (shouldEnableMicrophoneStreaming && + !recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.startBLEMicrophoneStream(); + } else if (!shouldEnableMicrophoneStreaming && + recorderProvider.isBLEMicrophoneStreamingEnabled) { + await recorderProvider.stopBLEMicrophoneStream(); + } + logger.d( "Applied ${primaryEntriesToApply.length} primary and ${mirroredEntriesToApply.length} mirrored sensor settings for ${target.primaryDevice.name}", ); From d2c65d02de52e0107254c03d4026287a50e783a8 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 28 Jan 2026 15:21:14 +0100 Subject: [PATCH 09/34] microphone now also stops when the recording is stopeed with the 'turn off all sensors' button --- .../view_models/sensor_recorder_provider.dart | 6 ++++-- .../local_recorder/recording_controls.dart | 3 ++- .../sensors/values/sensor_values_page.dart | 18 +++++++++++------- 3 files changed, 17 insertions(+), 10 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index 7ce7bf1d..c33eb380 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -296,7 +296,7 @@ class SensorRecorderProvider with ChangeNotifier { } } - void stopRecording() async { + void stopRecording(bool turnOffMic) async { _isRecording = false; _recordingStart = null; _recordingFilepathsBySensorIdentity.clear(); @@ -317,7 +317,9 @@ class SensorRecorderProvider with ChangeNotifier { } // Restart streaming if it was enabled before recording - if (_isBLEMicrophoneStreamingEnabled && !_isStreamingActive) { + if (!turnOffMic && + _isBLEMicrophoneStreamingEnabled && + !_isStreamingActive) { unawaited(startBLEMicrophoneStream()); } diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart index 0fc3b322..59b12d27 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -55,12 +55,13 @@ class _RecordingControls extends State { }); try { - recorder.stopRecording(); + recorder.stopRecording(turnOffSensors); if (turnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); await Future.wait(futures); + await recorder.stopBLEMicrophoneStream(); } await widget.updateRecordingsList(); } catch (e) { diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 55c3cc0e..b7291547 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -167,6 +167,7 @@ class _SensorValuesPageState extends State return _buildSmallScreenLayout( context, charts, + recorderProvider, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -175,6 +176,7 @@ class _SensorValuesPageState extends State return _buildLargeScreenLayout( context, charts, + recorderProvider, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -332,16 +334,16 @@ class _SensorValuesPageState extends State return ordered; } - Widget _buildAudioUI() { + Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { return Column( children: [ - if (_isRecording) + if (recorderProvider.isBLEMicrophoneStreamingEnabled) Card( child: Padding( padding: const EdgeInsets.all(16), child: CustomPaint( size: const Size(double.infinity, 100), - painter: WaveformPainter(_waveformData), + painter: WaveformPainter(recorderProvider.waveformData), ), ), ) @@ -360,14 +362,15 @@ class _SensorValuesPageState extends State Widget _buildSmallScreenLayout( BuildContext context, - List charts, { + List charts, + SensorRecorderProvider recorderProvider, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { return ListView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), children: [ - _buildAudioUI(), + _buildAudioUI(recorderProvider), ...charts, if (charts.isEmpty) Center( @@ -385,7 +388,8 @@ class _SensorValuesPageState extends State Widget _buildLargeScreenLayout( BuildContext context, - List charts, { + List charts, + SensorRecorderProvider recorderProvider, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { @@ -393,7 +397,7 @@ class _SensorValuesPageState extends State padding: SensorPageSpacing.pagePaddingWithBottomInset(context), child: Column( children: [ - _buildAudioUI(), + _buildAudioUI(recorderProvider), GridView.builder( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), From bd4dd92654e1797529d14dfa0b6d78c4401e3c69 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 2 Feb 2026 15:19:35 +0100 Subject: [PATCH 10/34] disable streaming option for iOS, remove audio recorder app because it is not needed --- .../sensor_configuration_detail_view.dart | 17 +++++++++++++---- .../sensor_configuration_view.dart | 7 ++++++- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart index 0f7d46d1..549a13c1 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/foundation.dart' show setEquals; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; @@ -38,7 +39,16 @@ class SensorConfigurationDetailView extends StatelessWidget { final targetOptions = sensorConfiguration is ConfigurableSensorConfiguration ? (sensorConfiguration as ConfigurableSensorConfiguration) .availableOptions - .toList(growable: false) + .where((option) { + // If on Android, show everything. + if (Platform.isAndroid) return true; + + // If on iOS, hide 'microphone' + 'stream' combination + final isMic = + sensorConfiguration.name.toLowerCase().contains('microphone'); + final isStream = option is StreamSensorConfigOption; + return !(isMic && isStream); + }).toList(growable: false) : const []; return ListView( @@ -350,9 +360,8 @@ class _OptionToggleTile extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), decoration: BoxDecoration( - color: selected - ? accentColor.withValues(alpha: 0.06) - : Colors.transparent, + color: + selected ? accentColor.withValues(alpha: 0.06) : Colors.transparent, borderRadius: BorderRadius.circular(10), border: Border.all( color: (selected ? accentColor : colorScheme.outlineVariant) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index e1634df6..778ba641 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -1,3 +1,4 @@ +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; @@ -292,7 +293,11 @@ class SensorConfigurationView extends StatelessWidget { final SensorConfiguration config = entry.$1; final SensorConfigurationValue value = entry.$2; if (config.name.toLowerCase().contains('microphone')) { - shouldEnableMicrophoneStreaming = true; + final options = + target.provider.getSelectedConfigurationOptions(config); + if (options.any((opt) => opt is StreamSensorConfigOption)) { + shouldEnableMicrophoneStreaming = true; + } } // Always push the selected canonical value to the primary device on // apply. This also heals primary-side drift/unknown states. From 29b6c6eb9de2bca9fc84cada1a4b039b5854be42 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Mon, 2 Feb 2026 15:23:44 +0100 Subject: [PATCH 11/34] add missing trailing commas --- .../sensor_configuration_device_row.dart | 8 +++ .../sensors/values/sensor_values_page.dart | 57 ++++++++++++++++--- 2 files changed, 57 insertions(+), 8 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index cdc66924..7320b4b0 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -349,9 +349,17 @@ class _SensorConfigurationDeviceRowState if (!mounted) return; setState(() { _content = [ +<<<<<<< HEAD const Padding( padding: EdgeInsets.all(12), child: Text('This device does not support sensor configuration.'), +======= + Padding( + padding: const EdgeInsets.all(8.0), + child: PlatformText( + "This device does not support configuring sensors.", + ), +>>>>>>> 445a3da (add missing trailing commas) ), ]; }); diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index b7291547..8fa4c83c 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -335,24 +335,65 @@ class _SensorValuesPageState extends State } Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { + // If initializing, show a loading card + if (_isInitializing && Platform.isAndroid) { + return Card( + child: Container( + height: 100, + alignment: Alignment.center, + child: const CircularProgressIndicator(), + ), + ); + } + return Column( children: [ if (recorderProvider.isBLEMicrophoneStreamingEnabled) Card( child: Padding( padding: const EdgeInsets.all(16), - child: CustomPaint( - size: const Size(double.infinity, 100), - painter: WaveformPainter(recorderProvider.waveformData), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon( + Icons.fiber_manual_record, + color: Colors.red, + size: 16, + ), + const SizedBox(width: 8), + Text( + 'AUDIO WAVEFORM ${recorderProvider.isRecording ? "(RECORDING)" : ""}', + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + const SizedBox(height: 8), + CustomPaint( + size: const Size(double.infinity, 100), + painter: WaveformPainter(recorderProvider.waveformData), + ), + ], ), ), ) else if (_errorMessage != null) - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - _errorMessage!, - style: const TextStyle(color: Colors.red), + Card( + child: Padding( + padding: const EdgeInsets.all(16.0), + child: Row( + children: [ + const Icon(Icons.error_outline, color: Colors.red), + const SizedBox(width: 12), + Expanded( + child: PlatformText( + _errorMessage!, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), ), ), const SizedBox(height: 10), From d424fc54624513bd7a15c270e338848eefef9afa Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 4 Feb 2026 12:50:07 +0100 Subject: [PATCH 12/34] only use one instance of AudioRecorder for both streaming and recording --- .../view_models/sensor_recorder_provider.dart | 73 ++++++++++--------- 1 file changed, 40 insertions(+), 33 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index c33eb380..ff4bdaff 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -52,10 +52,9 @@ class SensorRecorderProvider with ChangeNotifier { bool _isBLEMicrophoneStreamingEnabled = false; bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; - // Separate AudioRecorder for streaming - AudioRecorder? _streamingAudioRecorder; + // Path for temporary streaming file + String? _streamingPath; bool _isStreamingActive = false; - StreamSubscription? _streamingAmplitudeSub; Future _selectBLEDevice() async { try { @@ -104,17 +103,14 @@ class SensorRecorderProvider with ChangeNotifier { return false; } - _streamingAudioRecorder = AudioRecorder(); - const encoder = AudioEncoder.wav; - if (!await _streamingAudioRecorder!.isEncoderSupported(encoder)) { + if (!await _audioRecorder.isEncoderSupported(encoder)) { logger.w("WAV encoder not supported"); - _streamingAudioRecorder = null; return false; } final tempDir = await getTemporaryDirectory(); - final tempPath = + _streamingPath = '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; final config = RecordConfig( @@ -125,13 +121,13 @@ class SensorRecorderProvider with ChangeNotifier { device: _selectedBLEDevice, ); - await _streamingAudioRecorder!.start(config, path: tempPath); + await _audioRecorder.start(config, path: _streamingPath!); _isStreamingActive = true; _isBLEMicrophoneStreamingEnabled = true; // Set up amplitude monitoring for waveform display - _streamingAmplitudeSub?.cancel(); - _streamingAmplitudeSub = _streamingAudioRecorder! + _amplitudeSub?.cancel(); + _amplitudeSub = _audioRecorder .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen((amp) { final normalized = (amp.current + 50) / 50; @@ -144,17 +140,6 @@ class SensorRecorderProvider with ChangeNotifier { notifyListeners(); }); - Future.delayed(const Duration(seconds: 1), () async { - try { - final file = File(tempPath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Ignore cleanup errors - } - }); - logger.i( "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}", ); @@ -164,8 +149,7 @@ class SensorRecorderProvider with ChangeNotifier { logger.e("Failed to start BLE microphone streaming: $e"); _isStreamingActive = false; _isBLEMicrophoneStreamingEnabled = false; - _streamingAudioRecorder?.dispose(); - _streamingAudioRecorder = null; + _streamingPath = null; notifyListeners(); return false; } @@ -177,15 +161,26 @@ class SensorRecorderProvider with ChangeNotifier { } try { - await _streamingAudioRecorder?.stop(); - _streamingAmplitudeSub?.cancel(); - _streamingAmplitudeSub = null; - _streamingAudioRecorder?.dispose(); - _streamingAudioRecorder = null; + await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; _isStreamingActive = false; _isBLEMicrophoneStreamingEnabled = false; _waveformData.clear(); + // Clean up temporary streaming file + if (_streamingPath != null) { + try { + final file = File(_streamingPath!); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + _streamingPath = null; + } + logger.i("BLE microphone streaming stopped"); notifyListeners(); } catch (e) { @@ -234,10 +229,23 @@ class SensorRecorderProvider with ChangeNotifier { // Stop streaming session before starting actual recording if (_isStreamingActive) { - await _streamingAudioRecorder?.stop(); - _streamingAmplitudeSub?.cancel(); - _streamingAmplitudeSub = null; + await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; _isStreamingActive = false; + + // Clean up temporary streaming file + if (_streamingPath != null) { + try { + final file = File(_streamingPath!); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + _streamingPath = null; + } } try { @@ -307,7 +315,6 @@ class SensorRecorderProvider with ChangeNotifier { _amplitudeSub?.cancel(); _amplitudeSub = null; _isAudioRecording = false; - _waveformData.clear(); logger.i("Audio recording saved to: $path"); _currentAudioPath = null; From 65494b35f86f9470cf1fc35b3c167d5d81185c83 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 31 Mar 2026 15:26:32 +0300 Subject: [PATCH 13/34] resolve merge conflict --- .../lib/view_models/sensor_recorder_provider.dart | 6 +++++- .../configuration/sensor_configuration_device_row.dart | 8 -------- .../sensors/local_recorder/local_recorder_view.dart | 2 +- 3 files changed, 6 insertions(+), 10 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index ff4bdaff..c0b1bc43 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -188,7 +188,10 @@ class SensorRecorderProvider with ChangeNotifier { } } - void startRecording(String dirname) async { + Future startRecording(String dirname) async { + if (_isRecording) { + return; + } _isRecording = true; _currentDirectory = dirname; _recordingStart = DateTime.now(); @@ -566,6 +569,7 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { + _disposed = true; // Stop streaming stopBLEMicrophoneStream(); diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 7320b4b0..cdc66924 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -349,17 +349,9 @@ class _SensorConfigurationDeviceRowState if (!mounted) return; setState(() { _content = [ -<<<<<<< HEAD const Padding( padding: EdgeInsets.all(12), child: Text('This device does not support sensor configuration.'), -======= - Padding( - padding: const EdgeInsets.all(8.0), - child: PlatformText( - "This device does not support configuring sensors.", - ), ->>>>>>> 445a3da (add missing trailing commas) ), ]; }); diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index 430ff4b7..440649f0 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -138,7 +138,7 @@ class _LocalRecorderViewState extends State { }); try { - recorder.stopRecording(); + recorder.stopRecording(mode == _StopRecordingMode.stopAndTurnOffSensors); if (mode == _StopRecordingMode.stopAndTurnOffSensors) { final wearablesProvider = context.read(); final futures = wearablesProvider.sensorConfigurationProviders.values From eac29ae61475d3ee856e83e91c2528d4963d69e5 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 5 May 2026 13:40:46 +0200 Subject: [PATCH 14/34] fix(audio): keep waveform repainting --- .../view_models/sensor_recorder_provider.dart | 35 ++++++++------ .../sensors/values/sensor_values_page.dart | 46 ++++--------------- 2 files changed, 30 insertions(+), 51 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index c0b1bc43..a161391e 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -46,6 +46,14 @@ class SensorRecorderProvider with ChangeNotifier { final List _waveformData = []; List get waveformData => List.unmodifiable(_waveformData); + int _waveformRevision = 0; + + /// Monotonically increases whenever a new audio amplitude sample is recorded. + /// + /// The waveform keeps a capped rolling buffer, so its list length stops + /// changing once the buffer is full. Consumers can use this revision to + /// repaint when fresh samples shift through the fixed-size window. + int get waveformRevision => _waveformRevision; InputDevice? _selectedBLEDevice; @@ -130,13 +138,7 @@ class SensorRecorderProvider with ChangeNotifier { _amplitudeSub = _audioRecorder .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen((amp) { - final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - + _appendWaveformAmplitude(amp); notifyListeners(); }); @@ -292,13 +294,7 @@ class SensorRecorderProvider with ChangeNotifier { _amplitudeSub = _audioRecorder .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen((amp) { - final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - + _appendWaveformAmplitude(amp); notifyListeners(); }); } catch (e) { @@ -567,6 +563,17 @@ class SensorRecorderProvider with ChangeNotifier { } } + /// Appends a normalized amplitude sample to the fixed-size waveform window. + void _appendWaveformAmplitude(Amplitude amplitude) { + final normalized = (amplitude.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + _waveformRevision++; + + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + } + @override void dispose() { _disposed = true; diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 8fa4c83c..54cdc7fe 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -11,8 +11,6 @@ import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; import 'package:provider/provider.dart'; -import 'package:record/record.dart'; -import 'dart:async'; class SensorValuesPage extends StatefulWidget { final Map<(Wearable, Sensor), SensorDataProvider>? sharedProviders; @@ -35,16 +33,7 @@ class _SensorValuesPageState extends State bool get _ownsProviders => widget.sharedProviders == null; - // Audio State - late final AudioRecorder _audioRecorder; - bool _isPreviewRecording = false; - bool _isRecording = false; String? _errorMessage; - InputDevice? _selectedDevice; - StreamSubscription? _recordSub; - StreamSubscription? _amplitudeSub; - RecordState _recordState = RecordState.stop; - final List _waveformData = []; bool _isInitializing = true; @@ -87,29 +76,6 @@ class _SensorValuesPageState extends State provider.dispose(); } _ownedProviders.clear(); - // Stop and clean up preview recording - if (Platform.isAndroid) { - if (_recordState != RecordState.stop) { - _audioRecorder.stop().then((tempPath) { - if (tempPath != null) { - try { - final file = File(tempPath); - file.exists().then((exists) { - if (exists) { - file.delete(); - } - }); - } catch (e) { - //_logger.e("Error deleting temp preview file: $e"); - } - } - }); - } - - _recordSub?.cancel(); - _amplitudeSub?.cancel(); - _audioRecorder.dispose(); - } } super.dispose(); } @@ -372,7 +338,10 @@ class _SensorValuesPageState extends State const SizedBox(height: 8), CustomPaint( size: const Size(double.infinity, 100), - painter: WaveformPainter(recorderProvider.waveformData), + painter: WaveformPainter( + recorderProvider.waveformData, + sampleRevision: recorderProvider.waveformRevision, + ), ), ], ), @@ -584,9 +553,10 @@ class _SensorValuesEmptyState { }); } -// Custom waveform painter with vertical bars +/// Paints the live audio amplitude window as a horizontally scrolling waveform. class WaveformPainter extends CustomPainter { final List waveformData; + final int sampleRevision; final Color waveColor; final double spacing; final double waveThickness; @@ -594,6 +564,7 @@ class WaveformPainter extends CustomPainter { WaveformPainter( this.waveformData, { + required this.sampleRevision, this.waveColor = Colors.blue, this.spacing = 4.0, this.waveThickness = 3.0, @@ -659,7 +630,8 @@ class WaveformPainter extends CustomPainter { @override bool shouldRepaint(covariant WaveformPainter oldDelegate) { - return oldDelegate.waveformData.length != waveformData.length || + return oldDelegate.sampleRevision != sampleRevision || + oldDelegate.waveformData.length != waveformData.length || oldDelegate.waveColor != waveColor; } } From 3daa7c342b1fc5a64a637e31305c72ca9c81da79 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 5 May 2026 13:40:56 +0200 Subject: [PATCH 15/34] chore(analyzer): clean sensor configuration warnings --- .../configuration/sensor_configuration_device_row.dart | 1 - .../sensors/configuration/sensor_configuration_view.dart | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index cdc66924..904933f9 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -8,7 +8,6 @@ import 'package:open_wearable/view_models/sensor_profile_service.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/devices/device_detail/stereo_pos_label.dart'; import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; -import 'package:open_wearable/widgets/sensors/configuration/ble_microphone_streaming_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_profile_widgets.dart'; diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 778ba641..afe8a3d1 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -1,4 +1,3 @@ -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; @@ -323,6 +322,10 @@ class SensorConfigurationView extends StatelessWidget { ); } + if (!context.mounted) { + return; + } + if (actionableCount == 0) { AppToast.show( context, From 69a9edb22f904a3dd39a9bf8134d0d915dc26aa3 Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 13 May 2026 20:56:59 +0300 Subject: [PATCH 16/34] fix recorder on web --- open_wearable/lib/main.dart | 2 +- .../lib/models/bluetooth_auto_connector.dart | 17 +- .../view_models/sensor_recorder_provider.dart | 600 +----------------- .../sensor_recorder_provider_facade.dart | 2 + .../sensor_recorder_provider_io.dart | 593 +++++++++++++++++ .../sensor_recorder_provider_web.dart | 276 ++++++++ .../widgets/devices/connect_devices_page.dart | 9 + .../lib/widgets/home_page_overview.dart | 2 +- .../widgets/recording_activity_indicator.dart | 2 +- .../ble_microphone_streaming_row.dart | 5 +- .../sensor_configuration_detail_view.dart | 11 +- .../sensor_configuration_view.dart | 2 +- .../local_recorder_all_recordings_page.dart | 42 +- .../local_recorder_file_actions.dart | 49 +- .../local_recorder_file_actions_io.dart | 33 + .../local_recorder_file_actions_web.dart | 50 ++ .../local_recorder/local_recorder_files.dart | 53 +- .../local_recorder/local_recorder_models.dart | 57 ++ .../local_recorder_recording_folder_card.dart | 29 +- .../local_recorder_storage.dart | 107 +--- .../local_recorder_storage_io.dart | 121 ++++ .../local_recorder_storage_web.dart | 215 +++++++ .../local_recorder/local_recorder_view.dart | 103 +-- .../local_recorder/recording_controls.dart | 2 +- .../sensors/values/sensor_values_page.dart | 7 +- open_wearable/macos/Podfile.lock | 4 + open_wearable/pubspec.lock | 8 +- 27 files changed, 1494 insertions(+), 907 deletions(-) create mode 100644 open_wearable/lib/view_models/sensor_recorder_provider_facade.dart create mode 100644 open_wearable/lib/view_models/sensor_recorder_provider_io.dart create mode 100644 open_wearable/lib/view_models/sensor_recorder_provider_web.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart create mode 100644 open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart diff --git a/open_wearable/lib/main.dart b/open_wearable/lib/main.dart index 0c9e6108..1b42839c 100644 --- a/open_wearable/lib/main.dart +++ b/open_wearable/lib/main.dart @@ -18,7 +18,7 @@ import 'package:open_wearable/models/wearable_connector.dart' hide WearableEvent; import 'package:open_wearable/router.dart'; import 'package:open_wearable/theme/app_theme.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/widgets/app_banner.dart'; import 'package:open_wearable/widgets/global_app_banner_overlay.dart'; import 'package:open_wearable/widgets/app_toast.dart'; diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index 594a52c8..dbc5cf39 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; @@ -60,6 +61,14 @@ class BluetoothAutoConnector { }); void start() async { + if (kIsWeb) { + logger.i( + 'Bluetooth auto-connect is disabled on web because Web Bluetooth requires a direct user gesture.', + ); + _stopInternal(); + return; + } + final token = ++_sessionToken; _stopInternal(); _connectedDeviceIds.clear(); @@ -271,7 +280,7 @@ class BluetoothAutoConnector { } Future _applyIosScanCooldownIfNeeded() async { - if (!Platform.isIOS) { + if (kIsWeb || !Platform.isIOS) { return; } final stoppedAt = _lastScanStoppedAt; @@ -296,14 +305,16 @@ class BluetoothAutoConnector { } _isAttemptingConnection = true; - if (!Platform.isIOS) { + + if (!kIsWeb && Platform.isAndroid) { final hasPerm = await wearableManager.hasPermissions(); if (activeToken != _sessionToken) { _isAttemptingConnection = false; return; } if (!hasPerm) { - logger.w('Bluetooth permissions not granted. Showing permissions dialog.'); + logger.w( + 'Bluetooth permissions not granted. Showing permissions dialog.'); if (!_askedPermissionsThisSession) { _askedPermissionsThisSession = true; _showPermissionsDialog(); diff --git a/open_wearable/lib/view_models/sensor_recorder_provider.dart b/open_wearable/lib/view_models/sensor_recorder_provider.dart index a161391e..19fd68eb 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider.dart @@ -1,598 +1,2 @@ -import 'dart:async'; -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; -import 'package:path_provider/path_provider.dart'; -import 'package:record/record.dart'; - -import '../models/logger.dart'; -import '../models/sensor_streams.dart'; - -/// Runtime recorder state for connected wearables and sensors. -/// -/// Needs: -/// - Connected wearables (optionally with `SensorManager` capability). -/// - Writable target directory for CSV output. -/// -/// Does: -/// - Builds/owns per-wearable recorder maps. -/// - Starts/stops all active recorder streams. -/// - Keeps recording behavior consistent across wearable reconnects. -/// - Synchronizes recorder registration with the connected wearable set. -/// -/// Provides: -/// - Recording status (`isRecording`, `recordingStart`, etc.). -/// - Recorder access used by recorder UI pages. -class SensorRecorderProvider with ChangeNotifier { - final Map> _recorders = {}; - final Map _recordingFilepathsBySensorIdentity = {}; - Future _pendingSynchronization = Future.value(); - bool _disposed = false; - - bool _isRecording = false; - bool _hasSensorsConnected = false; - String? _currentDirectory; - DateTime? _recordingStart; - final AudioRecorder _audioRecorder = AudioRecorder(); - bool _isAudioRecording = false; - String? _currentAudioPath; - StreamSubscription? _amplitudeSub; - - bool get isRecording => _isRecording; - bool get hasSensorsConnected => _hasSensorsConnected; - String? get currentDirectory => _currentDirectory; - DateTime? get recordingStart => _recordingStart; - - final List _waveformData = []; - List get waveformData => List.unmodifiable(_waveformData); - int _waveformRevision = 0; - - /// Monotonically increases whenever a new audio amplitude sample is recorded. - /// - /// The waveform keeps a capped rolling buffer, so its list length stops - /// changing once the buffer is full. Consumers can use this revision to - /// repaint when fresh samples shift through the fixed-size window. - int get waveformRevision => _waveformRevision; - - InputDevice? _selectedBLEDevice; - - bool _isBLEMicrophoneStreamingEnabled = false; - bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; - - // Path for temporary streaming file - String? _streamingPath; - bool _isStreamingActive = false; - - Future _selectBLEDevice() async { - try { - final devices = await _audioRecorder.listInputDevices(); - - try { - _selectedBLEDevice = devices.firstWhere( - (device) => - device.label.toLowerCase().contains('bluetooth') || - device.label.toLowerCase().contains('ble') || - device.label.toLowerCase().contains('headset') || - device.label.toLowerCase().contains('openearable'), - ); - logger.i("Selected audio input device: ${_selectedBLEDevice!.label}"); - } catch (e) { - _selectedBLEDevice = null; - logger.w("No BLE headset found"); - } - } catch (e) { - logger.e("Error selecting BLE device: $e"); - _selectedBLEDevice = null; - } - } - - Future startBLEMicrophoneStream() async { - if (!Platform.isAndroid) { - logger.w("BLE microphone streaming only supported on Android"); - return false; - } - - if (_isStreamingActive) { - logger.i("BLE microphone streaming already active"); - return true; - } - - try { - if (!await _audioRecorder.hasPermission()) { - logger.w("No microphone permission for streaming"); - return false; - } - - await _selectBLEDevice(); - - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, cannot start streaming"); - return false; - } - - const encoder = AudioEncoder.wav; - if (!await _audioRecorder.isEncoderSupported(encoder)) { - logger.w("WAV encoder not supported"); - return false; - } - - final tempDir = await getTemporaryDirectory(); - _streamingPath = - '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; - - final config = RecordConfig( - encoder: encoder, - sampleRate: 48000, - bitRate: 768000, - numChannels: 1, - device: _selectedBLEDevice, - ); - - await _audioRecorder.start(config, path: _streamingPath!); - _isStreamingActive = true; - _isBLEMicrophoneStreamingEnabled = true; - - // Set up amplitude monitoring for waveform display - _amplitudeSub?.cancel(); - _amplitudeSub = _audioRecorder - .onAmplitudeChanged(const Duration(milliseconds: 100)) - .listen((amp) { - _appendWaveformAmplitude(amp); - notifyListeners(); - }); - - logger.i( - "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}", - ); - notifyListeners(); - return true; - } catch (e) { - logger.e("Failed to start BLE microphone streaming: $e"); - _isStreamingActive = false; - _isBLEMicrophoneStreamingEnabled = false; - _streamingPath = null; - notifyListeners(); - return false; - } - } - - Future stopBLEMicrophoneStream() async { - if (!_isStreamingActive) { - return; - } - - try { - await _audioRecorder.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - _isStreamingActive = false; - _isBLEMicrophoneStreamingEnabled = false; - _waveformData.clear(); - - // Clean up temporary streaming file - if (_streamingPath != null) { - try { - final file = File(_streamingPath!); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Ignore cleanup errors - } - _streamingPath = null; - } - - logger.i("BLE microphone streaming stopped"); - notifyListeners(); - } catch (e) { - logger.e("Error stopping BLE microphone streaming: $e"); - } - } - - Future startRecording(String dirname) async { - if (_isRecording) { - return; - } - _isRecording = true; - _currentDirectory = dirname; - _recordingStart = DateTime.now(); - - try { - for (Wearable wearable in _recorders.keys) { - await _startRecorderForWearable(wearable, dirname); - } - _isRecording = true; - notifyListeners(); - } catch (e, st) { - logger.e('Failed to start recording: $e\n$st'); - _stopAllRecorderStreams(); - _recordingFilepathsBySensorIdentity.clear(); - _currentDirectory = null; - _recordingStart = null; - _isRecording = false; - notifyListeners(); - rethrow; - } - - await _startAudioRecording( - dirname, - ); - - notifyListeners(); - } - - Future _startAudioRecording(String recordingFolderPath) async { - if (!Platform.isAndroid) return; - - // Only start recording if BLE microphone streaming is enabled - if (!_isBLEMicrophoneStreamingEnabled) { - logger - .w("BLE microphone streaming not enabled, skipping audio recording"); - return; - } - - // Stop streaming session before starting actual recording - if (_isStreamingActive) { - await _audioRecorder.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - _isStreamingActive = false; - - // Clean up temporary streaming file - if (_streamingPath != null) { - try { - final file = File(_streamingPath!); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Ignore cleanup errors - } - _streamingPath = null; - } - } - - try { - if (!await _audioRecorder.hasPermission()) { - logger.w("No microphone permission for recording"); - return; - } - - await _selectBLEDevice(); - - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, skipping audio recording"); - return; - } - - const encoder = AudioEncoder.wav; - if (!await _audioRecorder.isEncoderSupported(encoder)) { - logger.w("WAV encoder not supported"); - return; - } - - final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); - final audioPath = '$recordingFolderPath/audio_$timestamp.wav'; - - final config = RecordConfig( - encoder: encoder, - sampleRate: 48000, // Set to 48kHz for BLE audio quality - bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps - numChannels: 1, - device: _selectedBLEDevice, - ); - - await _audioRecorder.start(config, path: audioPath); - _currentAudioPath = audioPath; - _isAudioRecording = true; - - logger.i( - "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}", - ); - - _amplitudeSub = _audioRecorder - .onAmplitudeChanged(const Duration(milliseconds: 100)) - .listen((amp) { - _appendWaveformAmplitude(amp); - notifyListeners(); - }); - } catch (e) { - logger.e("Failed to start audio recording: $e"); - _isAudioRecording = false; - } - } - - void stopRecording(bool turnOffMic) async { - _isRecording = false; - _recordingStart = null; - _recordingFilepathsBySensorIdentity.clear(); - _stopAllRecorderStreams(); - try { - if (_isAudioRecording) { - final path = await _audioRecorder.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - _isAudioRecording = false; - - logger.i("Audio recording saved to: $path"); - _currentAudioPath = null; - } - } catch (e) { - logger.e("Error stopping audio recording: $e"); - } - - // Restart streaming if it was enabled before recording - if (!turnOffMic && - _isBLEMicrophoneStreamingEnabled && - !_isStreamingActive) { - unawaited(startBLEMicrophoneStream()); - } - - notifyListeners(); - } - - Recorder? getRecorder(Wearable wearable, Sensor sensor) { - if (!_recorders.containsKey(wearable)) { - return null; - } - return _recorders[wearable]?[sensor]; - } - - Map getRecorders(Wearable wearable) { - return _recorders[wearable] ?? {}; - } - - Future addWearable(Wearable wearable) async { - final Wearable? existing = _findWearableByDeviceId(wearable.deviceId); - - if (existing != null) { - _disposeWearable(existing); - _recorders.remove(existing); - } - - _recorders[wearable] = {}; - - wearable.addDisconnectListener(() { - removeWearable(wearable); - }); - - if (wearable.hasCapability()) { - for (Sensor sensor - in wearable.requireCapability().sensors) { - if (!_recorders[wearable]!.containsKey(sensor)) { - _recorders[wearable]![sensor] = Recorder(columns: sensor.axisNames); - } - } - } - - if (_isRecording && _currentDirectory != null) { - unawaited( - _startRecorderForWearable( - wearable, - _currentDirectory!, - resumed: true, - ), - ); - } - - _updateConnected(); - } - - /// Reconciles recorder state with the current connected wearable set. - /// - /// This keeps recorder registration derived from the authoritative - /// [WearablesProvider] connection state instead of relying on each caller to - /// remember a second side effect. - void synchronizeConnectedWearables(Iterable wearables) { - final desiredById = { - for (final wearable in wearables) wearable.deviceId: wearable, - }; - - _pendingSynchronization = _pendingSynchronization.then((_) async { - if (_disposed) { - return; - } - - final existingById = { - for (final wearable in _recorders.keys) wearable.deviceId: wearable, - }; - - for (final entry in existingById.entries) { - if (!desiredById.containsKey(entry.key)) { - removeWearable(entry.value); - } - } - - for (final entry in desiredById.entries) { - final existing = existingById[entry.key]; - if (existing == null || !identical(existing, entry.value)) { - await addWearable(entry.value); - } - } - }); - } - - void removeWearable(Wearable wearable) { - _disposeWearable(wearable); - _recorders.remove(wearable); - _updateConnected(); - } - - void _updateConnected() { - _hasSensorsConnected = !(_recorders.isEmpty || - _recorders.values.every((sensors) => sensors.isEmpty)); - logger.i('Has sensors connected: $_hasSensorsConnected'); - notifyListeners(); - } - - Wearable? _findWearableByDeviceId(String deviceId) { - for (final wearable in _recorders.keys) { - if (wearable.deviceId == deviceId) { - return wearable; - } - } - return null; - } - - void _disposeWearable(Wearable wearable) { - final recorderMap = _recorders[wearable]; - if (recorderMap == null) return; - for (final recorder in recorderMap.values) { - recorder.stop(); - } - } - - Future _startRecorderForWearable( - Wearable wearable, - String dirname, { - bool resumed = false, - }) async { - for (Sensor sensor in _recorders[wearable]!.keys) { - Recorder? recorder = _recorders[wearable]?[sensor]; - if (recorder == null) continue; - - final sensorIdentity = _sensorRecordingIdentity( - wearable: wearable, - sensor: sensor, - ); - final existingFilepath = - _recordingFilepathsBySensorIdentity[sensorIdentity]; - final append = resumed && existingFilepath != null; - final filepath = existingFilepath ?? - await _createRecordingFilepath( - wearable: wearable, - sensor: sensor, - dirname: dirname, - ); - _recordingFilepathsBySensorIdentity[sensorIdentity] = filepath; - - File file = await recorder.start( - filepath: filepath, - inputStream: SensorStreams.shared( - wearable: wearable, - sensor: sensor, - ), - append: append, - ); - - logger.i( - '${resumed ? 'Resumed' : 'Started'} recording for ' - '${wearable.name} - ${sensor.sensorName} to ${file.path}', - ); - } - } - - /// Builds a stable per-device/per-sensor identity for the current session. - /// - /// Reconnects replace the [Wearable] and [Sensor] object instances, so file - /// reuse must be keyed by semantic sensor identity instead of object - /// identity. - String _sensorRecordingIdentity({ - required Wearable wearable, - required Sensor sensor, - }) { - final axisNames = sensor.axisNames.join(','); - final axisUnits = sensor.axisUnits.join(','); - return '${wearable.deviceId}|${sensor.runtimeType}|${sensor.sensorName}|$axisNames|$axisUnits'; - } - - /// Resolves a new file path for a sensor without overwriting prior exports. - Future _createRecordingFilepath({ - required Wearable wearable, - required Sensor sensor, - required String dirname, - }) async { - final base = await _recordingFilenameStem( - wearable: wearable, - sensor: sensor, - ); - var name = base; - var counter = 1; - - while (await File('$dirname/$name.csv').exists()) { - name = '${base}_$counter'; - counter++; - } - - return '$dirname/$name.csv'; - } - - /// Builds the exported filename stem for a wearable sensor recording. - /// - /// Stereo-capable devices include their side marker so left/right files stay - /// distinguishable in shared recording folders. - Future _recordingFilenameStem({ - required Wearable wearable, - required Sensor sensor, - }) async { - if (!wearable.hasCapability()) { - return '${wearable.name}_${sensor.sensorName}'; - } - final stereoPositionLabel = await _stereoPositionLabel( - wearable.requireCapability(), - ); - if (stereoPositionLabel != null) { - return '${wearable.name}-$stereoPositionLabel-${sensor.sensorName}'; - } - return '${wearable.name}_${sensor.sensorName}'; - } - - /// Returns the short stereo side label used in exported filenames. - Future _stereoPositionLabel(StereoDevice wearable) async { - final position = await wearable.position; - return switch (position) { - DevicePosition.left => 'L', - DevicePosition.right => 'R', - _ => null, - }; - } - - void _stopAllRecorderStreams() { - for (Wearable wearable in _recorders.keys) { - for (Sensor sensor in _recorders[wearable]!.keys) { - final recorder = _recorders[wearable]?[sensor]; - if (recorder == null) { - continue; - } - recorder.stop(); - logger.i( - 'Stopped recording for ${wearable.name} - ${sensor.sensorName}', - ); - } - } - } - - /// Appends a normalized amplitude sample to the fixed-size waveform window. - void _appendWaveformAmplitude(Amplitude amplitude) { - final normalized = (amplitude.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); - _waveformRevision++; - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - } - - @override - void dispose() { - _disposed = true; - // Stop streaming - stopBLEMicrophoneStream(); - - // Stop recording - _audioRecorder.stop().then((_) { - _audioRecorder.dispose(); - }).catchError((e) { - logger.e("Error stopping audio in dispose: $e"); - }); - _amplitudeSub?.cancel(); - _waveformData.clear(); - for (final wearable in _recorders.keys.toList()) { - _disposeWearable(wearable); - } - _recordingFilepathsBySensorIdentity.clear(); - _recorders.clear(); - super.dispose(); - } -} +export 'sensor_recorder_provider_io.dart' + if (dart.library.html) 'sensor_recorder_provider_web.dart'; diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_facade.dart b/open_wearable/lib/view_models/sensor_recorder_provider_facade.dart new file mode 100644 index 00000000..19fd68eb --- /dev/null +++ b/open_wearable/lib/view_models/sensor_recorder_provider_facade.dart @@ -0,0 +1,2 @@ +export 'sensor_recorder_provider_io.dart' + if (dart.library.html) 'sensor_recorder_provider_web.dart'; diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart new file mode 100644 index 00000000..bf573ea3 --- /dev/null +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -0,0 +1,593 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:path_provider/path_provider.dart'; +import 'package:record/record.dart'; + +import '../models/logger.dart'; +import '../models/sensor_streams.dart'; + +/// Runtime recorder state for connected wearables and sensors. +/// +/// Needs: +/// - Connected wearables (optionally with `SensorManager` capability). +/// - Writable target directory for CSV output. +/// +/// Does: +/// - Builds/owns per-wearable recorder maps. +/// - Starts/stops all active recorder streams. +/// - Keeps recording behavior consistent across wearable reconnects. +/// - Synchronizes recorder registration with the connected wearable set. +/// +/// Provides: +/// - Recording status (`isRecording`, `recordingStart`, etc.). +/// - Recorder access used by recorder UI pages. +class SensorRecorderProvider with ChangeNotifier { + final Map> _recorders = {}; + final Map _recordingFilepathsBySensorIdentity = {}; + Future _pendingSynchronization = Future.value(); + bool _disposed = false; + + bool _isRecording = false; + bool _hasSensorsConnected = false; + String? _currentDirectory; + DateTime? _recordingStart; + final AudioRecorder _audioRecorder = AudioRecorder(); + bool _isAudioRecording = false; + String? _currentAudioPath; + StreamSubscription? _amplitudeSub; + + bool get isRecording => _isRecording; + bool get hasSensorsConnected => _hasSensorsConnected; + String? get currentDirectory => _currentDirectory; + DateTime? get recordingStart => _recordingStart; + + final List _waveformData = []; + final int _waveformRevision = 0; + int get waveformRevision => _waveformRevision; + List get waveformData => List.unmodifiable(_waveformData); + + InputDevice? _selectedBLEDevice; + + bool _isBLEMicrophoneStreamingEnabled = false; + bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; + + // Path for temporary streaming file + String? _streamingPath; + bool _isStreamingActive = false; + + Future _selectBLEDevice() async { + try { + final devices = await _audioRecorder.listInputDevices(); + + try { + _selectedBLEDevice = devices.firstWhere( + (device) => + device.label.toLowerCase().contains('bluetooth') || + device.label.toLowerCase().contains('ble') || + device.label.toLowerCase().contains('headset') || + device.label.toLowerCase().contains('openearable'), + ); + logger.i("Selected audio input device: ${_selectedBLEDevice!.label}"); + } catch (e) { + _selectedBLEDevice = null; + logger.w("No BLE headset found"); + } + } catch (e) { + logger.e("Error selecting BLE device: $e"); + _selectedBLEDevice = null; + } + } + + Future startBLEMicrophoneStream() async { + if (!kIsWeb && !Platform.isAndroid) { + logger.w("BLE microphone streaming only supported on Android"); + return false; + } + + if (_isStreamingActive) { + logger.i("BLE microphone streaming already active"); + return true; + } + + try { + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for streaming"); + return false; + } + + await _selectBLEDevice(); + + if (_selectedBLEDevice == null) { + logger.w("No BLE headset detected, cannot start streaming"); + return false; + } + + const encoder = AudioEncoder.wav; + if (!await _audioRecorder.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + return false; + } + + final tempDir = await getTemporaryDirectory(); + _streamingPath = + '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; + + final config = RecordConfig( + encoder: encoder, + sampleRate: 48000, + bitRate: 768000, + numChannels: 1, + device: _selectedBLEDevice, + ); + + await _audioRecorder.start(config, path: _streamingPath!); + _isStreamingActive = true; + _isBLEMicrophoneStreamingEnabled = true; + + // Set up amplitude monitoring for waveform display + _amplitudeSub?.cancel(); + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen((amp) { + final normalized = (amp.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + + notifyListeners(); + }); + + logger.i( + "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}", + ); + notifyListeners(); + return true; + } catch (e) { + logger.e("Failed to start BLE microphone streaming: $e"); + _isStreamingActive = false; + _isBLEMicrophoneStreamingEnabled = false; + _streamingPath = null; + notifyListeners(); + return false; + } + } + + Future stopBLEMicrophoneStream() async { + if (!_isStreamingActive) { + return; + } + + try { + await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isStreamingActive = false; + _isBLEMicrophoneStreamingEnabled = false; + _waveformData.clear(); + + // Clean up temporary streaming file + if (_streamingPath != null) { + try { + final file = File(_streamingPath!); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + _streamingPath = null; + } + + logger.i("BLE microphone streaming stopped"); + notifyListeners(); + } catch (e) { + logger.e("Error stopping BLE microphone streaming: $e"); + } + } + + Future startRecording(String dirname) async { + if (_isRecording) { + return; + } + _isRecording = true; + _currentDirectory = dirname; + _recordingStart = DateTime.now(); + + try { + for (Wearable wearable in _recorders.keys) { + await _startRecorderForWearable(wearable, dirname); + } + _isRecording = true; + notifyListeners(); + } catch (e, st) { + logger.e('Failed to start recording: $e\n$st'); + _stopAllRecorderStreams(); + _recordingFilepathsBySensorIdentity.clear(); + _currentDirectory = null; + _recordingStart = null; + _isRecording = false; + notifyListeners(); + rethrow; + } + + await _startAudioRecording( + dirname, + ); + + notifyListeners(); + } + + Future _startAudioRecording(String recordingFolderPath) async { + if (!kIsWeb && !Platform.isAndroid) return; + + // Only start recording if BLE microphone streaming is enabled + if (!_isBLEMicrophoneStreamingEnabled) { + logger + .w("BLE microphone streaming not enabled, skipping audio recording"); + return; + } + + // Stop streaming session before starting actual recording + if (_isStreamingActive) { + await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isStreamingActive = false; + + // Clean up temporary streaming file + if (_streamingPath != null) { + try { + final file = File(_streamingPath!); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + // Ignore cleanup errors + } + _streamingPath = null; + } + } + + try { + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for recording"); + return; + } + + await _selectBLEDevice(); + + if (_selectedBLEDevice == null) { + logger.w("No BLE headset detected, skipping audio recording"); + return; + } + + const encoder = AudioEncoder.wav; + if (!await _audioRecorder.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + return; + } + + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final audioPath = '$recordingFolderPath/audio_$timestamp.wav'; + + final config = RecordConfig( + encoder: encoder, + sampleRate: 48000, // Set to 48kHz for BLE audio quality + bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps + numChannels: 1, + device: _selectedBLEDevice, + ); + + await _audioRecorder.start(config, path: audioPath); + _currentAudioPath = audioPath; + _isAudioRecording = true; + + logger.i( + "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}", + ); + + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen((amp) { + final normalized = (amp.current + 50) / 50; + _waveformData.add(normalized.clamp(0.0, 2.0)); + + if (_waveformData.length > 100) { + _waveformData.removeAt(0); + } + + notifyListeners(); + }); + } catch (e) { + logger.e("Failed to start audio recording: $e"); + _isAudioRecording = false; + } + } + + void stopRecording(bool turnOffMic) async { + _isRecording = false; + _recordingStart = null; + _recordingFilepathsBySensorIdentity.clear(); + _stopAllRecorderStreams(); + try { + if (_isAudioRecording) { + final path = await _audioRecorder.stop(); + _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isAudioRecording = false; + + logger.i("Audio recording saved to: $path"); + _currentAudioPath = null; + } + } catch (e) { + logger.e("Error stopping audio recording: $e"); + } + + // Restart streaming if it was enabled before recording + if (!turnOffMic && + _isBLEMicrophoneStreamingEnabled && + !_isStreamingActive) { + unawaited(startBLEMicrophoneStream()); + } + + notifyListeners(); + } + + Recorder? getRecorder(Wearable wearable, Sensor sensor) { + if (!_recorders.containsKey(wearable)) { + return null; + } + return _recorders[wearable]?[sensor]; + } + + Map getRecorders(Wearable wearable) { + return _recorders[wearable] ?? {}; + } + + Future addWearable(Wearable wearable) async { + final Wearable? existing = _findWearableByDeviceId(wearable.deviceId); + + if (existing != null) { + _disposeWearable(existing); + _recorders.remove(existing); + } + + _recorders[wearable] = {}; + + wearable.addDisconnectListener(() { + removeWearable(wearable); + }); + + if (wearable.hasCapability()) { + for (Sensor sensor + in wearable.requireCapability().sensors) { + if (!_recorders[wearable]!.containsKey(sensor)) { + _recorders[wearable]![sensor] = Recorder(columns: sensor.axisNames); + } + } + } + + if (_isRecording && _currentDirectory != null) { + unawaited( + _startRecorderForWearable( + wearable, + _currentDirectory!, + resumed: true, + ), + ); + } + + _updateConnected(); + } + + /// Reconciles recorder state with the current connected wearable set. + /// + /// This keeps recorder registration derived from the authoritative + /// [WearablesProvider] connection state instead of relying on each caller to + /// remember a second side effect. + void synchronizeConnectedWearables(Iterable wearables) { + final desiredById = { + for (final wearable in wearables) wearable.deviceId: wearable, + }; + + _pendingSynchronization = _pendingSynchronization.then((_) async { + if (_disposed) { + return; + } + + final existingById = { + for (final wearable in _recorders.keys) wearable.deviceId: wearable, + }; + + for (final entry in existingById.entries) { + if (!desiredById.containsKey(entry.key)) { + removeWearable(entry.value); + } + } + + for (final entry in desiredById.entries) { + final existing = existingById[entry.key]; + if (existing == null || !identical(existing, entry.value)) { + await addWearable(entry.value); + } + } + }); + } + + void removeWearable(Wearable wearable) { + _disposeWearable(wearable); + _recorders.remove(wearable); + _updateConnected(); + } + + void _updateConnected() { + _hasSensorsConnected = !(_recorders.isEmpty || + _recorders.values.every((sensors) => sensors.isEmpty)); + logger.i('Has sensors connected: $_hasSensorsConnected'); + notifyListeners(); + } + + Wearable? _findWearableByDeviceId(String deviceId) { + for (final wearable in _recorders.keys) { + if (wearable.deviceId == deviceId) { + return wearable; + } + } + return null; + } + + void _disposeWearable(Wearable wearable) { + final recorderMap = _recorders[wearable]; + if (recorderMap == null) return; + for (final recorder in recorderMap.values) { + recorder.stop(); + } + } + + Future _startRecorderForWearable( + Wearable wearable, + String dirname, { + bool resumed = false, + }) async { + for (Sensor sensor in _recorders[wearable]!.keys) { + Recorder? recorder = _recorders[wearable]?[sensor]; + if (recorder == null) continue; + + final sensorIdentity = _sensorRecordingIdentity( + wearable: wearable, + sensor: sensor, + ); + final existingFilepath = + _recordingFilepathsBySensorIdentity[sensorIdentity]; + final append = resumed && existingFilepath != null; + final filepath = existingFilepath ?? + await _createRecordingFilepath( + wearable: wearable, + sensor: sensor, + dirname: dirname, + ); + _recordingFilepathsBySensorIdentity[sensorIdentity] = filepath; + + File file = await recorder.start( + filepath: filepath, + inputStream: SensorStreams.shared( + wearable: wearable, + sensor: sensor, + ), + append: append, + ); + + logger.i( + '${resumed ? 'Resumed' : 'Started'} recording for ' + '${wearable.name} - ${sensor.sensorName} to ${file.path}', + ); + } + } + + /// Builds a stable per-device/per-sensor identity for the current session. + /// + /// Reconnects replace the [Wearable] and [Sensor] object instances, so file + /// reuse must be keyed by semantic sensor identity instead of object + /// identity. + String _sensorRecordingIdentity({ + required Wearable wearable, + required Sensor sensor, + }) { + final axisNames = sensor.axisNames.join(','); + final axisUnits = sensor.axisUnits.join(','); + return '${wearable.deviceId}|${sensor.runtimeType}|${sensor.sensorName}|$axisNames|$axisUnits'; + } + + /// Resolves a new file path for a sensor without overwriting prior exports. + Future _createRecordingFilepath({ + required Wearable wearable, + required Sensor sensor, + required String dirname, + }) async { + final base = await _recordingFilenameStem( + wearable: wearable, + sensor: sensor, + ); + var name = base; + var counter = 1; + + while (await File('$dirname/$name.csv').exists()) { + name = '${base}_$counter'; + counter++; + } + + return '$dirname/$name.csv'; + } + + /// Builds the exported filename stem for a wearable sensor recording. + /// + /// Stereo-capable devices include their side marker so left/right files stay + /// distinguishable in shared recording folders. + Future _recordingFilenameStem({ + required Wearable wearable, + required Sensor sensor, + }) async { + if (!wearable.hasCapability()) { + return '${wearable.name}_${sensor.sensorName}'; + } + final stereoPositionLabel = await _stereoPositionLabel( + wearable.requireCapability(), + ); + if (stereoPositionLabel != null) { + return '${wearable.name}-$stereoPositionLabel-${sensor.sensorName}'; + } + return '${wearable.name}_${sensor.sensorName}'; + } + + /// Returns the short stereo side label used in exported filenames. + Future _stereoPositionLabel(StereoDevice wearable) async { + final position = await wearable.position; + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + } + + void _stopAllRecorderStreams() { + for (Wearable wearable in _recorders.keys) { + for (Sensor sensor in _recorders[wearable]!.keys) { + final recorder = _recorders[wearable]?[sensor]; + if (recorder == null) { + continue; + } + recorder.stop(); + logger.i( + 'Stopped recording for ${wearable.name} - ${sensor.sensorName}', + ); + } + } + } + + @override + void dispose() { + _disposed = true; + // Stop streaming + stopBLEMicrophoneStream(); + + // Stop recording + _audioRecorder.stop().then((_) { + _audioRecorder.dispose(); + }).catchError((e) { + logger.e("Error stopping audio in dispose: $e"); + }); + _amplitudeSub?.cancel(); + _waveformData.clear(); + for (final wearable in _recorders.keys.toList()) { + _disposeWearable(wearable); + } + _recordingFilepathsBySensorIdentity.clear(); + _recorders.clear(); + super.dispose(); + } +} diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart new file mode 100644 index 00000000..7c932d3a --- /dev/null +++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart @@ -0,0 +1,276 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; + +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage_web.dart'; + +import '../models/logger.dart'; +import '../models/sensor_streams.dart'; + +class SensorRecorderProvider with ChangeNotifier { + final Map _wearablesById = {}; + final Map> _sensorSubscriptions = {}; + final Map _sessions = {}; + bool _isRecording = false; + bool _hasSensorsConnected = false; + String? _currentDirectory; + DateTime? _recordingStart; + + bool get isRecording => _isRecording; + bool get hasSensorsConnected => _hasSensorsConnected; + String? get currentDirectory => _currentDirectory; + DateTime? get recordingStart => _recordingStart; + + final List _waveformData = []; + final int _waveformRevision = 0; + int get waveformRevision => _waveformRevision; + List get waveformData => List.unmodifiable(_waveformData); + + bool _isBLEMicrophoneStreamingEnabled = false; + bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; + + Future startBLEMicrophoneStream() async { + logger.w('BLE microphone streaming is not supported on web.'); + return false; + } + + Future stopBLEMicrophoneStream() async { + _isBLEMicrophoneStreamingEnabled = false; + _waveformData.clear(); + notifyListeners(); + } + + Future startRecording(String dirname) async { + if (_isRecording) { + return; + } + + _isRecording = true; + _currentDirectory = dirname; + _recordingStart = DateTime.now(); + _sessions.clear(); + + for (final wearable in _wearablesById.values) { + await _startRecordingForWearable(wearable, dirname); + } + + final initialDrafts = _sessions.values.map((session) { + return LocalRecorderDraftFile( + name: session.fileName, + content: session.content.toString(), // Contains the header + ); + }).toList(); + + if (initialDrafts.isNotEmpty) { + await persistRecordingFolderFiles(dirname, initialDrafts); + } + + notifyListeners(); + } + + void stopRecording(bool turnOffMic) async { + if (!_isRecording) { + return; + } + + _isRecording = false; + _recordingStart = null; + + final folderPath = _currentDirectory; + final sessions = _sessions.values.toList(growable: false); + _sessions.clear(); + + for (final session in sessions) { + await session.dispose(); + } + + if (folderPath != null) { + final draftFiles = sessions + .where((session) => session.content.isNotEmpty) + .map( + (session) => LocalRecorderDraftFile( + name: session.fileName, + content: session.content.toString(), + ), + ) + .toList(); + + if (draftFiles.isEmpty) { + await deleteRecordingFolder(folderPath); + } else { + await persistRecordingFolderFiles(folderPath, draftFiles); + } + } + + _currentDirectory = null; + + if (!turnOffMic && _isBLEMicrophoneStreamingEnabled) { + unawaited(startBLEMicrophoneStream()); + } + + notifyListeners(); + } + + Future addWearable(Wearable wearable) async { + _wearablesById[wearable.deviceId] = wearable; + + wearable.addDisconnectListener(() { + removeWearable(wearable); + }); + + if (_isRecording && _currentDirectory != null) { + await _startRecordingForWearable(wearable, _currentDirectory!); + } + + _updateConnected(); + } + + void synchronizeConnectedWearables(Iterable wearables) { + final desiredById = { + for (final wearable in wearables) wearable.deviceId: wearable, + }; + + final existingIds = _wearablesById.keys.toList(growable: false); + for (final deviceId in existingIds) { + if (!desiredById.containsKey(deviceId)) { + removeWearable(_wearablesById[deviceId]!); + } + } + + for (final entry in desiredById.entries) { + final existing = _wearablesById[entry.key]; + if (existing == null || !identical(existing, entry.value)) { + unawaited(addWearable(entry.value)); + } + } + } + + void removeWearable(Wearable wearable) { + _wearablesById.remove(wearable.deviceId); + final sessionKeys = _sessions.keys + .where((key) => key.startsWith('${wearable.deviceId}|')) + .toList(growable: false); + for (final key in sessionKeys) { + unawaited(_sessions.remove(key)?.dispose()); + _sensorSubscriptions.remove(key)?.cancel(); + } + _updateConnected(); + } + + void _updateConnected() { + _hasSensorsConnected = _wearablesById.isNotEmpty; + logger.i('Has sensors connected: $_hasSensorsConnected'); + notifyListeners(); + } + + Future _startRecordingForWearable( + Wearable wearable, + String dirname, + ) async { + if (!wearable.hasCapability()) { + return; + } + + for (final sensor in wearable.requireCapability().sensors) { + final key = _sensorRecordingKey(wearable: wearable, sensor: sensor); + if (_sessions.containsKey(key)) { + continue; + } + + final session = _WebRecordingSession( + fileName: + '${await _recordingFilenameStem(wearable: wearable, sensor: sensor)}.csv', + sensor: sensor, + ); + _sessions[key] = session; + + final subscription = SensorStreams.shared( + wearable: wearable, + sensor: sensor, + ).listen((sensorValue) { + session.append(sensorValue); + }); + _sensorSubscriptions[key] = subscription; + } + } + + String _sensorRecordingKey({ + required Wearable wearable, + required Sensor sensor, + }) { + final axisNames = sensor.axisNames.join(','); + final axisUnits = sensor.axisUnits.join(','); + return '${wearable.deviceId}|${sensor.runtimeType}|${sensor.sensorName}|$axisNames|$axisUnits'; + } + + Future _recordingFilenameStem({ + required Wearable wearable, + required Sensor sensor, + }) async { + if (!wearable.hasCapability()) { + return '${wearable.name}_${sensor.sensorName}'; + } + final stereoPositionLabel = await _stereoPositionLabel( + wearable.requireCapability(), + ); + if (stereoPositionLabel != null) { + return '${wearable.name}-$stereoPositionLabel-${sensor.sensorName}'; + } + return '${wearable.name}_${sensor.sensorName}'; + } + + Future _stereoPositionLabel(StereoDevice wearable) async { + final position = await wearable.position; + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + } + + @override + void dispose() { + for (final subscription in _sensorSubscriptions.values) { + subscription.cancel(); + } + _sensorSubscriptions.clear(); + for (final session in _sessions.values) { + unawaited(session.dispose()); + } + _sessions.clear(); + _wearablesById.clear(); + _waveformData.clear(); + super.dispose(); + } +} + +class _WebRecordingSession { + final String fileName; + final Sensor sensor; + final StringBuffer content = StringBuffer(); + + _WebRecordingSession({required this.fileName, required this.sensor}) { + content.writeln(_buildHeader()); + } + + void append(SensorValue value) { + if (value is SensorDoubleValue) { + content.writeln( + [value.timestamp, ...value.values].join(','), + ); + } else if (value is SensorIntValue) { + content.writeln( + [value.timestamp, ...value.values].join(','), + ); + } + } + + String _buildHeader() { + final axisNames = sensor.axisNames.join(','); + return axisNames.isEmpty ? 'timestamp' : 'timestamp,$axisNames'; + } + + Future dispose() async {} +} diff --git a/open_wearable/lib/widgets/devices/connect_devices_page.dart b/open_wearable/lib/widgets/devices/connect_devices_page.dart index bb453e12..2bf53acd 100644 --- a/open_wearable/lib/widgets/devices/connect_devices_page.dart +++ b/open_wearable/lib/widgets/devices/connect_devices_page.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'dart:typed_data'; +import 'package:flutter/foundation.dart' show kIsWeb; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; @@ -419,6 +420,9 @@ class _ConnectDevicesPageState extends State { final connector = context.read(); try { + if (_scanSnapshot.isScanning) { + await ConnectDevicesScanSession.stopScanning(); + } await connector.connect(device); ConnectDevicesScanSession.removeDiscoveredDevice(device.id); } catch (e, stackTrace) { @@ -495,6 +499,11 @@ class _ConnectDevicesPageState extends State { required DiscoveredDevice device, required WearableConnector connector, }) async { + // Skip stale connection recovery on web platform + if (kIsWeb) { + return false; + } + try { await UniversalBle.disconnect(device.id); } catch (error, stackTrace) { diff --git a/open_wearable/lib/widgets/home_page_overview.dart b/open_wearable/lib/widgets/home_page_overview.dart index 19edbdb5..0346d8f9 100644 --- a/open_wearable/lib/widgets/home_page_overview.dart +++ b/open_wearable/lib/widgets/home_page_overview.dart @@ -3,7 +3,7 @@ import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:go_router/go_router.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/connector_activity_indicator.dart'; import 'package:open_wearable/widgets/devices/device_detail/device_detail_page.dart'; diff --git a/open_wearable/lib/widgets/recording_activity_indicator.dart b/open_wearable/lib/widgets/recording_activity_indicator.dart index b273fb21..fef3c391 100644 --- a/open_wearable/lib/widgets/recording_activity_indicator.dart +++ b/open_wearable/lib/widgets/recording_activity_indicator.dart @@ -3,7 +3,7 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../view_models/sensor_recorder_provider.dart'; +import '../view_models/sensor_recorder_provider_facade.dart'; /// Shared pulse ticker so every recording indicator stays in sync. class _RecordingPulseTicker { diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart index 16c0bb5e..13dfb3e1 100644 --- a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart @@ -1,15 +1,16 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:provider/provider.dart'; -import '../../../view_models/sensor_recorder_provider.dart'; +import '../../../view_models/sensor_recorder_provider_facade.dart'; class BLEMicrophoneStreamingRow extends StatelessWidget { const BLEMicrophoneStreamingRow({super.key}); @override Widget build(BuildContext context) { - if (!Platform.isAndroid) { + if (kIsWeb || !Platform.isAndroid) { return const SizedBox.shrink(); } diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart index 549a13c1..c46baed5 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_detail_view.dart @@ -1,5 +1,5 @@ -import 'package:flutter/foundation.dart' show setEquals; -import 'dart:io'; +import 'package:flutter/foundation.dart' + show defaultTargetPlatform, kIsWeb, setEquals; import 'package:flutter/material.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; @@ -40,8 +40,11 @@ class SensorConfigurationDetailView extends StatelessWidget { ? (sensorConfiguration as ConfigurableSensorConfiguration) .availableOptions .where((option) { - // If on Android, show everything. - if (Platform.isAndroid) return true; + // On Android and web, show everything. The iOS-specific stream/mic + // restriction only applies to native iOS builds. + if (kIsWeb || defaultTargetPlatform == TargetPlatform.android) { + return true; + } // If on iOS, hide 'microphone' + 'stream' combination final isMic = diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index afe8a3d1..0c60af2a 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -4,7 +4,7 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_configuration_provider.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart index 257c7069..47b4c136 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_all_recordings_page.dart @@ -1,9 +1,7 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:open_file/open_file.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_file_actions.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; @@ -25,7 +23,8 @@ class _LocalRecorderAllRecordingsPageState extends State { final Set _expandedFolders = {}; final Set _selectedFolderPaths = {}; - List _recordings = []; + List _recordings = + []; bool _isLoading = true; bool _isBusy = false; bool _isSelectionMode = false; @@ -39,7 +38,7 @@ class _LocalRecorderAllRecordingsPageState } Future _loadRecordings() async { - final recordings = await listRecordingDirectories(); + final recordings = await listRecordingFolders(); if (!mounted) return; setState(() { _recordings = recordings; @@ -53,7 +52,7 @@ class _LocalRecorderAllRecordingsPageState }); } - Future _shareFolder(Directory folder) async { + Future _shareFolder(LocalRecorderRecordingFolder folder) async { try { await localRecorderShareFolder(folder); } catch (e) { @@ -61,7 +60,7 @@ class _LocalRecorderAllRecordingsPageState } } - Future _shareFile(File file) async { + Future _shareFile(LocalRecorderRecordingFile file) async { try { await localRecorderShareFile(file); } catch (e) { @@ -69,11 +68,8 @@ class _LocalRecorderAllRecordingsPageState } } - Future _openFile(File file) async { - final result = await localRecorderOpenRecordingFile(file); - if (result.type != ResultType.done) { - await _showErrorDialog('Could not open file: ${result.message}'); - } + Future _openFile(LocalRecorderRecordingFile file) async { + await localRecorderOpenRecordingFile(file); } Future _shareSelectedFolders() async { @@ -81,7 +77,8 @@ class _LocalRecorderAllRecordingsPageState setState(() => _isBusy = true); try { for (final path in _selectedFolderPaths) { - await localRecorderShareFolder(Directory(path)); + final folder = _recordings.firstWhere((entry) => entry.path == path); + await localRecorderShareFolder(folder); } } catch (e) { await _showErrorDialog('Failed to share selected recordings: $e'); @@ -125,10 +122,7 @@ class _LocalRecorderAllRecordingsPageState setState(() => _isBusy = true); try { for (final path in _selectedFolderPaths.toList()) { - final folder = Directory(path); - if (await folder.exists()) { - await folder.delete(recursive: true); - } + await deleteRecordingFolder(path); } _selectedFolderPaths.clear(); _isSelectionMode = false; @@ -142,8 +136,8 @@ class _LocalRecorderAllRecordingsPageState } } - Future _deleteSingleFolder(Directory folder) async { - final name = localRecorderBasename(folder.path); + Future _deleteSingleFolder(LocalRecorderRecordingFolder folder) async { + final name = folder.name; final shouldDelete = await showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( @@ -170,9 +164,7 @@ class _LocalRecorderAllRecordingsPageState if (!shouldDelete) return; try { - if (await folder.exists()) { - await folder.delete(recursive: true); - } + await deleteRecordingFolder(folder.path); if (!mounted) return; setState(() { _expandedFolders.remove(folder.path); @@ -259,8 +251,8 @@ class _LocalRecorderAllRecordingsPageState final isCurrent = widget.isRecording && index == 0; final isExpanded = _expandedFolders.contains(folder.path); final files = isExpanded - ? listFilesInRecordingFolder(folder) - : []; + ? folder.files + : []; final isSelected = _selectedFolderPaths.contains(folder.path); return LocalRecorderRecordingFolderCard( @@ -269,7 +261,7 @@ class _LocalRecorderAllRecordingsPageState isExpanded: isExpanded, files: files, updatedLabel: - 'Updated ${localRecorderFormatDateTime(folder.statSync().changed)}', + 'Updated ${localRecorderFormatDateTime(folder.updatedAt)}', selectionMode: _selectionMode, isSelected: isSelected, onSelectionToggle: isCurrent diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart index 5193dc17..24b21b16 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions.dart @@ -1,47 +1,2 @@ -import 'dart:io'; - -import 'package:flutter_archive/flutter_archive.dart'; -import 'package:open_file/open_file.dart'; -import 'package:path_provider/path_provider.dart'; -import 'package:share_plus/share_plus.dart'; - -Future localRecorderShareFile(File file) async { - await SharePlus.instance.share( - ShareParams( - files: [XFile(file.path)], - subject: 'OpenWearable Recording File', - ), - ); -} - -Future localRecorderShareFolder(Directory folder) async { - final tempDir = await getTemporaryDirectory(); - final zipPath = '${tempDir.path}/${folder.path.split(RegExp(r"[\\\\/]+")).last}.zip'; - final zipFile = File(zipPath); - - await ZipFile.createFromDirectory( - sourceDir: folder, - zipFile: zipFile, - recurseSubDirs: true, - ); - - try { - await SharePlus.instance.share( - ShareParams( - files: [XFile(zipFile.path)], - subject: 'OpenWearable Recording', - ), - ); - } finally { - if (await zipFile.exists()) { - await zipFile.delete(); - } - } -} - -Future localRecorderOpenRecordingFile(File file) { - return OpenFile.open( - file.path, - type: 'text/comma-separated-values', - ); -} +export 'local_recorder_file_actions_io.dart' + if (dart.library.html) 'local_recorder_file_actions_web.dart'; diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart new file mode 100644 index 00000000..0a935236 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart @@ -0,0 +1,33 @@ +import 'package:open_file/open_file.dart'; +import 'package:share_plus/share_plus.dart'; + +import 'local_recorder_models.dart'; + +Future localRecorderShareFile(LocalRecorderRecordingFile file) async { + await SharePlus.instance.share( + ShareParams( + files: [XFile(file.path)], + subject: 'OpenWearable Recording File', + ), + ); +} + +Future localRecorderShareFolder(LocalRecorderRecordingFolder folder) async { + await SharePlus.instance.share( + ShareParams( + files: folder.files.map((entry) => XFile(entry.path)).toList(), + subject: 'OpenWearable Recording', + ), + ); +} + +Future localRecorderOpenRecordingFile(LocalRecorderRecordingFile file) async { + final result = await OpenFile.open( + file.path, + type: 'text/comma-separated-values', + ); + + if (result.type != ResultType.done) { + throw StateError(result.message); + } +} \ No newline at end of file diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart new file mode 100644 index 00000000..1ba8ba32 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart @@ -0,0 +1,50 @@ +import 'dart:html' as html; + +import 'package:share_plus/share_plus.dart'; + +import 'local_recorder_models.dart'; +import 'local_recorder_storage.dart'; + +Future localRecorderShareFile(LocalRecorderRecordingFile file) async { + final bytes = await readRecordingFileBytes(file); + await SharePlus.instance.share( + ShareParams( + files: [ + XFile.fromData( + bytes, + name: file.name, + mimeType: file.mimeType, + ), + ], + subject: 'OpenWearable Recording File', + ), + ); +} + +Future localRecorderShareFolder(LocalRecorderRecordingFolder folder) async { + final files = []; + for (final file in folder.files) { + final bytes = await readRecordingFileBytes(file); + files.add( + XFile.fromData( + bytes, + name: file.name, + mimeType: file.mimeType, + ), + ); + } + + await SharePlus.instance.share( + ShareParams( + files: files, + subject: 'OpenWearable Recording', + ), + ); +} + +Future localRecorderOpenRecordingFile(LocalRecorderRecordingFile file) async { + final bytes = await readRecordingFileBytes(file); + final blob = html.Blob([bytes], file.mimeType); + final url = html.Url.createObjectUrlFromBlob(blob); + html.window.open(url, '_blank'); +} \ No newline at end of file diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart index db4b5bf4..d5584d52 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_files.dart @@ -1,53 +1,8 @@ -import 'dart:io'; - -import 'package:flutter/foundation.dart'; -import 'package:path_provider/path_provider.dart'; +import 'local_recorder_storage.dart'; class Files { - static Future pickDirectory() async { - if (!Platform.isIOS && !kIsWeb) { - final recordingName = - 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; - Directory? appDir = await getExternalStorageDirectory(); - if (appDir == null) return null; - - String dirPath = '${appDir.path}/$recordingName'; - Directory dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; - } - - if (Platform.isIOS) { - final recordingName = - 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; - String dirPath = '${(await getIOSDirectory()).path}/$recordingName'; - Directory dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; - } - - return null; - } - - static Future getIOSDirectory() async { - Directory appDocDir = await getApplicationDocumentsDirectory(); - final dirPath = '${appDocDir.path}/Recordings'; - final dir = Directory(dirPath); - - if (!await dir.exists()) { - await dir.create(recursive: true); - } - - return dir; - } + static Future pickDirectory() => pickRecordingDirectory(); - static Future isDirectoryEmpty(String path) async { - final dir = Directory(path); - if (!await dir.exists()) return true; - return await dir.list(followLinks: false).isEmpty; - } + static Future isDirectoryEmpty(String path) => + isRecordingDirectoryEmpty(path); } diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart new file mode 100644 index 00000000..f8ee3aee --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart @@ -0,0 +1,57 @@ +class LocalRecorderRecordingFile { + final String path; + final String name; + final int sizeBytes; + final DateTime updatedAt; + final String mimeType; + + const LocalRecorderRecordingFile({ + required this.path, + required this.name, + required this.sizeBytes, + required this.updatedAt, + this.mimeType = 'text/csv', + }); +} + +class LocalRecorderRecordingFolder { + final String path; + final String name; + final DateTime updatedAt; + final List files; + + const LocalRecorderRecordingFolder({ + required this.path, + required this.name, + required this.updatedAt, + required this.files, + }); +} + +class LocalRecorderDraftFile { + final String name; + final String content; + final String mimeType; + + const LocalRecorderDraftFile({ + required this.name, + required this.content, + this.mimeType = 'text/csv', + }); +} + +String localRecorderBasename(String path) => + path.split(RegExp(r'[\\/]+')).last; + +String localRecorderFormatFileSize(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; +} + +String localRecorderFormatDateTime(DateTime value) { + final local = value.toLocal(); + String twoDigits(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' + '${twoDigits(local.hour)}:${twoDigits(local.minute)}'; +} \ No newline at end of file diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart index 849fc190..031205d3 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart @@ -1,20 +1,19 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; class LocalRecorderRecordingFolderCard extends StatelessWidget { - final Directory folder; + final LocalRecorderRecordingFolder folder; final bool isCurrentRecording; final bool isExpanded; - final List files; + final List files; final String updatedLabel; final VoidCallback onToggleExpanded; final VoidCallback onShareFolder; final VoidCallback onDeleteFolder; - final void Function(File file) onShareFile; - final void Function(File file) onOpenFile; - final String Function(File file) formatFileSize; + final void Function(LocalRecorderRecordingFile file) onShareFile; + final void Function(LocalRecorderRecordingFile file) onOpenFile; + final String Function(int bytes) formatFileSize; final bool selectionMode; final bool isSelected; final VoidCallback? onSelectionToggle; @@ -37,13 +36,10 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget { this.onSelectionToggle, }); - String _basename(String path) => path.split(RegExp(r'[\\/]+')).last; - @override Widget build(BuildContext context) { final theme = Theme.of(context); final colorScheme = theme.colorScheme; - final folderName = _basename(folder.path); return Card( margin: const EdgeInsets.only(bottom: SensorPageSpacing.sectionGap), @@ -57,7 +53,7 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget { : colorScheme.onSurfaceVariant, ), title: Text( - folderName, + folder.name, maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -152,7 +148,7 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget { ...files.map( (file) => _LocalRecorderRecordingFileTile( file: file, - fileSize: formatFileSize(file), + fileSize: formatFileSize(file.sizeBytes), onShare: () => onShareFile(file), onOpen: () => onOpenFile(file), ), @@ -164,7 +160,7 @@ class LocalRecorderRecordingFolderCard extends StatelessWidget { } class _LocalRecorderRecordingFileTile extends StatelessWidget { - final File file; + final LocalRecorderRecordingFile file; final String fileSize; final VoidCallback onShare; final VoidCallback onOpen; @@ -176,12 +172,9 @@ class _LocalRecorderRecordingFileTile extends StatelessWidget { required this.onOpen, }); - String _basename(String path) => path.split(RegExp(r'[\\/]+')).last; - @override Widget build(BuildContext context) { - final fileName = _basename(file.path); - final isCsv = fileName.toLowerCase().endsWith('.csv'); + final isCsv = file.name.toLowerCase().endsWith('.csv'); return ListTile( contentPadding: const EdgeInsets.fromLTRB(58, 2, 10, 2), @@ -191,7 +184,7 @@ class _LocalRecorderRecordingFileTile extends StatelessWidget { size: 20, ), title: Text( - fileName, + file.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: Theme.of(context).textTheme.bodyMedium, diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart index 15d92210..b567ddf4 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart @@ -1,105 +1,4 @@ -import 'dart:io'; +export 'local_recorder_models.dart'; -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:path_provider/path_provider.dart'; - -String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last; - -String localRecorderFormatFileSize(File file) { - final bytes = file.lengthSync(); - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; -} - -String localRecorderFormatDateTime(DateTime value) { - final local = value.toLocal(); - String twoDigits(int n) => n.toString().padLeft(2, '0'); - return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' - '${twoDigits(local.hour)}:${twoDigits(local.minute)}'; -} - -Future getIOSDirectory() async { - final appDocDir = await getApplicationDocumentsDirectory(); - final dirPath = '${appDocDir.path}/Recordings'; - final dir = Directory(dirPath); - - if (!await dir.exists()) { - await dir.create(recursive: true); - } - - return dir; -} - -Future getRecordingsRootDirectory() async { - if (kIsWeb) return null; - if (Platform.isAndroid) { - return getExternalStorageDirectory(); - } - if (Platform.isIOS) { - return getIOSDirectory(); - } - return null; -} - -Future> listRecordingDirectories() async { - final recordingsDir = await getRecordingsRootDirectory(); - if (recordingsDir == null || !await recordingsDir.exists()) { - return []; - } - - final entities = recordingsDir.listSync(); - final recordings = entities - .whereType() - .where((entity) => entity.path.contains('OpenWearable_Recording')) - .toList(); - - recordings.sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed)); - return recordings; -} - -List listFilesInRecordingFolder(Directory folder) { - try { - return folder.listSync(recursive: false).whereType().toList() - ..sort((a, b) => localRecorderBasename(a.path).compareTo(localRecorderBasename(b.path))); - } catch (_) { - return []; - } -} - -Future isDirectoryEmpty(String path) async { - final dir = Directory(path); - if (!await dir.exists()) return true; - return dir.list(followLinks: false).isEmpty; -} - -Future pickRecordingDirectory() async { - if (kIsWeb) return null; - - if (Platform.isAndroid) { - final recordingName = - 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; - final appDir = await getExternalStorageDirectory(); - if (appDir == null) return null; - - final dirPath = '${appDir.path}/$recordingName'; - final dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; - } - - if (Platform.isIOS) { - final recordingName = - 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; - final dirPath = '${(await getIOSDirectory()).path}/$recordingName'; - final dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; - } - - return null; -} +export 'local_recorder_storage_io.dart' + if (dart.library.html) 'local_recorder_storage_web.dart'; diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart new file mode 100644 index 00000000..71ec76ac --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart @@ -0,0 +1,121 @@ +import 'dart:io'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:path_provider/path_provider.dart'; + +import 'local_recorder_models.dart'; + +Future _ensureIOSDirectory() async { + final appDocDir = await getApplicationDocumentsDirectory(); + final dirPath = '${appDocDir.path}/Recordings'; + final dir = Directory(dirPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + return dir; +} + +Future pickRecordingDirectory() async { + if (kIsWeb) return null; + + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + + if (Platform.isAndroid) { + final appDir = await getExternalStorageDirectory(); + if (appDir == null) return null; + + final dirPath = '${appDir.path}/$recordingName'; + final dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + if (Platform.isIOS) { + final dirPath = '${(await _ensureIOSDirectory()).path}/$recordingName'; + final dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + return null; +} + +Future> listRecordingFolders() async { + final root = await _getRecordingsRootDirectory(); + if (root == null || !await root.exists()) { + return []; + } + + final folders = root + .listSync() + .whereType() + .where((entity) => entity.path.contains('OpenWearable_Recording')) + .map(_directoryToFolder) + .toList() + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + + return folders; +} + +Future isRecordingDirectoryEmpty(String path) async { + final dir = Directory(path); + if (!await dir.exists()) return true; + return dir.list(followLinks: false).isEmpty; +} + +Future deleteRecordingFolder(String path) async { + final dir = Directory(path); + if (await dir.exists()) { + await dir.delete(recursive: true); + } +} + +Future readRecordingFileBytes(LocalRecorderRecordingFile file) { + return File(file.path).readAsBytes(); +} + +Future readRecordingFileText(LocalRecorderRecordingFile file) { + return File(file.path).readAsString(); +} + +Future _getRecordingsRootDirectory() async { + if (kIsWeb) return null; + if (Platform.isAndroid) { + return getExternalStorageDirectory(); + } + if (Platform.isIOS) { + return _ensureIOSDirectory(); + } + return null; +} + +LocalRecorderRecordingFolder _directoryToFolder(Directory directory) { + final files = directory + .listSync(recursive: false) + .whereType() + .map( + (file) => LocalRecorderRecordingFile( + path: file.path, + name: localRecorderBasename(file.path), + sizeBytes: file.lengthSync(), + updatedAt: file.statSync().modified, + ), + ) + .toList() + ..sort((a, b) => a.name.compareTo(b.name)); + + return LocalRecorderRecordingFolder( + path: directory.path, + name: localRecorderBasename(directory.path), + updatedAt: directory.statSync().changed, + files: files, + ); +} \ No newline at end of file diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart new file mode 100644 index 00000000..66806f22 --- /dev/null +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart @@ -0,0 +1,215 @@ +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'local_recorder_models.dart'; + +const String _storageKey = 'open_wearable.local_recorder.web_recordings.v1'; + +Future pickRecordingDirectory() async { + final prefs = await SharedPreferences.getInstance(); + final folder = _createFolder(name: _recordingFolderName()); + final folders = _readFolders(prefs); + folders.removeWhere((entry) => entry.path == folder.path); + folders.add(folder); + await _writeFolders(prefs, folders); + return folder.path; +} + +Future> listRecordingFolders() async { + final prefs = await SharedPreferences.getInstance(); + final folders = _readFolders(prefs) + ..sort((a, b) => b.updatedAt.compareTo(a.updatedAt)); + return folders; +} + +Future isRecordingDirectoryEmpty(String path) async { + final prefs = await SharedPreferences.getInstance(); + final folders = _readFolders(prefs); + final match = folders.where((entry) => entry.path == path).toList(); + return match.isEmpty || match.first.files.isEmpty; +} + +Future deleteRecordingFolder(String path) async { + final prefs = await SharedPreferences.getInstance(); + final folders = _readFolders(prefs) + ..removeWhere((entry) => entry.path == path); + await _writeFolders(prefs, folders); +} + +Future persistRecordingFolderFiles( + String path, + List files, +) async { + final prefs = await SharedPreferences.getInstance(); + final folders = _readFolders(prefs); + final folderIndex = folders.indexWhere((entry) => entry.path == path); + final updatedFolder = _createFolder( + name: _recordingFolderName(), + path: path, + files: files, + ); + + if (folderIndex == -1) { + folders.add(updatedFolder); + } else { + folders[folderIndex] = updatedFolder; + } + + await _writeFolders(prefs, folders); +} + +Future readRecordingFileBytes( + LocalRecorderRecordingFile file) async { + final prefs = await SharedPreferences.getInstance(); + final folders = _readFolders(prefs); + for (final folder in folders) { + final selected = folder.files.where((entry) => entry.path == file.path); + if (selected.isNotEmpty) { + final recordingFile = selected.first; + if (recordingFile is _WebRecordingFile) { + return base64Decode(recordingFile.contentBase64); + } + break; + } + } + throw StateError('Recording file not found'); +} + +Future readRecordingFileText(LocalRecorderRecordingFile file) async { + return utf8.decode(await readRecordingFileBytes(file)); +} + +LocalRecorderRecordingFolder _createFolder({ + required String name, + String? path, + List files = const [], +}) { + final folderPath = path ?? 'web-${DateTime.now().microsecondsSinceEpoch}'; + final now = DateTime.now(); + final recordingFiles = files + .map( + (file) => _WebRecordingFile( + path: '$folderPath/${file.name}', + name: file.name, + sizeBytes: utf8.encode(file.content).length, + updatedAt: now, + mimeType: file.mimeType, + contentBase64: base64Encode(utf8.encode(file.content)), + ), + ) + .toList(); + + return LocalRecorderRecordingFolder( + path: folderPath, + name: name, + updatedAt: now, + files: recordingFiles, + ); +} + +String _recordingFolderName() { + final timestamp = DateTime.now().toIso8601String(); + return 'OpenWearable_Recording_$timestamp'; +} + +List _readFolders(SharedPreferences prefs) { + final raw = prefs.getString(_storageKey); + if (raw == null || raw.isEmpty) { + return []; + } + + final decoded = jsonDecode(raw); + if (decoded is! Map) { + return []; + } + + final folders = decoded['folders']; + if (folders is! List) { + return []; + } + + return folders.whereType().map(_folderFromJson).toList(); +} + +Future _writeFolders( + SharedPreferences prefs, + List folders, +) async { + final payload = { + 'folders': folders.map(_folderToJson).toList(), + }; + await prefs.setString(_storageKey, jsonEncode(payload)); +} + +Map _folderToJson(LocalRecorderRecordingFolder folder) { + return { + 'path': folder.path, + 'name': folder.name, + 'updatedAt': folder.updatedAt.toIso8601String(), + 'files': folder.files.map(_fileToJson).toList(), + }; +} + +Map _fileToJson(LocalRecorderRecordingFile file) { + if (file is _WebRecordingFile) { + return { + 'path': file.path, + 'name': file.name, + 'sizeBytes': file.sizeBytes, + 'updatedAt': file.updatedAt.toIso8601String(), + 'mimeType': file.mimeType, + 'contentBase64': file.contentBase64, + }; + } + + return { + 'path': file.path, + 'name': file.name, + 'sizeBytes': file.sizeBytes, + 'updatedAt': file.updatedAt.toIso8601String(), + 'mimeType': file.mimeType, + 'contentBase64': '', + }; +} + +LocalRecorderRecordingFolder _folderFromJson(Map json) { + final files = (json['files'] as List? ?? const []) + .whereType() + .map(_fileFromJson) + .toList(); + + return LocalRecorderRecordingFolder( + path: json['path'] as String? ?? 'unknown', + name: json['name'] as String? ?? 'Recording', + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + files: files, + ); +} + +LocalRecorderRecordingFile _fileFromJson(Map json) { + return _WebRecordingFile( + path: json['path'] as String? ?? 'unknown/file.csv', + name: json['name'] as String? ?? 'file.csv', + sizeBytes: (json['sizeBytes'] as num?)?.toInt() ?? 0, + updatedAt: DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? + DateTime.fromMillisecondsSinceEpoch(0), + mimeType: json['mimeType'] as String? ?? 'text/csv', + contentBase64: json['contentBase64'] as String? ?? '', + ); +} + +class _WebRecordingFile extends LocalRecorderRecordingFile { + final String contentBase64; + + const _WebRecordingFile({ + required super.path, + required super.name, + required super.sizeBytes, + required super.updatedAt, + required this.contentBase64, + super.mimeType = 'text/csv', + }); +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index 440649f0..fe6e6644 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; @@ -7,11 +6,12 @@ import 'package:go_router/go_router.dart'; import 'package:logger/logger.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; import 'package:provider/provider.dart'; -import 'package:open_file/open_file.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_empty_state_card.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_file_actions.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_recording_card.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_recording_folder_card.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_see_all_recordings_card.dart'; @@ -35,7 +35,7 @@ class LocalRecorderView extends StatefulWidget { } class _LocalRecorderViewState extends State { - List _recordings = []; + List _recordings = []; final Set _expandedFolders = {}; // Track which folders are expanded Timer? _recordingTimer; Duration _elapsedRecording = Duration.zero; @@ -70,21 +70,25 @@ class _LocalRecorderViewState extends State { /// Load the list of recording folders from the device's storage, filtering and sorting them appropriately. Future _listRecordings() async { - final recordings = await listRecordingDirectories(); + final recordings = await listRecordingFolders(); if (!mounted) return; setState(() { - _recordings = recordings.cast(); + _recordings = recordings; }); } - List _getFilesInFolder(Directory folder) { - return listFilesInRecordingFolder(folder); + List _getFilesInFolder( + LocalRecorderRecordingFolder folder, + ) { + return folder.files; } /// Show a confirmation dialog before deleting a recording folder or file, and handle the deletion if confirmed. - Future _confirmAndDeleteRecording(FileSystemEntity entity) async { + Future _confirmAndDeleteRecording( + LocalRecorderRecordingFolder entity, + ) async { if (!mounted) return false; - final name = localRecorderBasename(entity.path); + final name = entity.name; final shouldDelete = await showPlatformDialog( context: context, builder: (_) => PlatformAlertDialog( @@ -111,19 +115,13 @@ class _LocalRecorderViewState extends State { if (!shouldDelete) return false; - if (entity.existsSync()) { - try { - if (entity is Directory) { - entity.deleteSync(recursive: true); - } else { - entity.deleteSync(); - } - _listRecordings(); - } catch (e) { - _logger.e('Error deleting recording: $e'); - _showErrorDialog('Failed to delete recording: $e'); - return false; - } + try { + await deleteRecordingFolder(entity.path); + _listRecordings(); + } catch (e) { + _logger.e('Error deleting recording: $e'); + await _showErrorDialog('Failed to delete recording: $e'); + return false; } return true; } @@ -230,7 +228,7 @@ class _LocalRecorderViewState extends State { } } - Future _shareFile(File file) async { + Future _shareFile(LocalRecorderRecordingFile file) async { try { await localRecorderShareFile(file); } catch (e) { @@ -243,7 +241,7 @@ class _LocalRecorderViewState extends State { } } - Future _shareFolder(Directory folder) async { + Future _shareFolder(LocalRecorderRecordingFolder folder) async { try { await localRecorderShareFolder(folder); } catch (e) { @@ -263,7 +261,7 @@ class _LocalRecorderViewState extends State { return; } - if (!await isDirectoryEmpty(dir)) { + if (!await Files.isDirectoryEmpty(dir)) { if (!mounted) return; final proceed = await _askOverwriteConfirmation(context, dir); if (!proceed) return; @@ -278,11 +276,8 @@ class _LocalRecorderViewState extends State { } } - Future _openRecordingFile(File file) async { - final result = await localRecorderOpenRecordingFile(file); - if (result.type != ResultType.done) { - await _showErrorDialog('Could not open file: ${result.message}'); - } + Future _openRecordingFile(LocalRecorderRecordingFile file) async { + await localRecorderOpenRecordingFile(file); } Future _openAllRecordingsPage({required bool isRecording}) async { @@ -297,8 +292,7 @@ class _LocalRecorderViewState extends State { final isRecording = recorder.isRecording; final canStartRecording = recorder.hasSensorsConnected && !isRecording; final hasRecordings = _recordings.isNotEmpty; - final latestRecording = - hasRecordings ? _recordings.first as Directory : null; + final latestRecording = hasRecordings ? _recordings.first : null; return SafeArea( top: false, @@ -333,22 +327,41 @@ class _LocalRecorderViewState extends State { LocalRecorderRecordingFolderCard( folder: latestRecording, isCurrentRecording: isRecording, - isExpanded: _expandedFolders.contains( - latestRecording.path, - ), + isExpanded: _expandedFolders.contains(latestRecording.path), files: _expandedFolders.contains(latestRecording.path) ? _getFilesInFolder(latestRecording) - : const [], + : const [], updatedLabel: - 'Updated ${localRecorderFormatDateTime(latestRecording.statSync().changed)}', - onToggleExpanded: () { - setState(() { - if (_expandedFolders.contains(latestRecording.path)) { - _expandedFolders.remove(latestRecording.path); - } else { - _expandedFolders.add(latestRecording.path); + 'Updated ${localRecorderFormatDateTime(latestRecording.updatedAt)}', + onToggleExpanded: () async { + final path = latestRecording.path; + final isExpanding = !_expandedFolders.contains(path); + + if (isExpanding) { + // 1. Fetch the absolute latest data from SharedPreferences + final allFolders = await listRecordingFolders(); + + // 2. Find the version of this folder that actually has the files populated + final freshFolder = allFolders.firstWhere( + (f) => f.path == path, + orElse: () => latestRecording, + ); + + if (mounted) { + setState(() { + // 3. Update the files list in our current reference so the UI sees them + latestRecording.files.clear(); + latestRecording.files.addAll(freshFolder.files); + + // 4. Mark as expanded + _expandedFolders.add(path); + }); } - }); + } else { + setState(() { + _expandedFolders.remove(path); + }); + } }, onShareFolder: () => _shareFolder(latestRecording), onDeleteFolder: () async { diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart index 59b12d27..56f403de 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -2,7 +2,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:logger/logger.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_dialogs.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_files.dart'; diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 54cdc7fe..6db4854c 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,12 +1,13 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; @@ -43,7 +44,7 @@ class _SensorValuesPageState extends State @override void initState() { super.initState(); - if (Platform.isAndroid) { + if (!kIsWeb && Platform.isAndroid) { _checkStreamingStatus(); } } @@ -302,7 +303,7 @@ class _SensorValuesPageState extends State Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { // If initializing, show a loading card - if (_isInitializing && Platform.isAndroid) { + if (!kIsWeb && _isInitializing && Platform.isAndroid) { return Card( child: Container( height: 100, diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index 41eb4bdf..29f78e00 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -8,6 +8,7 @@ PODS: - ZIPFoundation (= 0.9.19) - mcumgr_flutter (0.9.0): - Flutter + - record_macos (1.2.0): - FlutterMacOS - iOSMcuManagerLibrary (= 1.12) - SwiftProtobuf @@ -19,6 +20,7 @@ DEPENDENCIES: - flutter_archive (from `Flutter/ephemeral/.symlinks/plugins/flutter_archive/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - mcumgr_flutter (from `Flutter/ephemeral/.symlinks/plugins/mcumgr_flutter/darwin`) + - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) SPEC REPOS: trunk: @@ -34,6 +36,8 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral mcumgr_flutter: :path: Flutter/ephemeral/.symlinks/plugins/mcumgr_flutter/darwin + record_macos: + :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos SPEC CHECKSUMS: flutter_archive: 07888d9aeb79da005e0ad8b9d347d17cdea07f68 diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 7286a371..5567f4cb 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -548,10 +548,10 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.18" material_color_utilities: dependency: transitive description: @@ -1089,10 +1089,10 @@ packages: dependency: transitive description: name: synchronized - sha256: "63896c27e81b28f8cb4e69ead0d3e8f03f1d1e5fc531a3e579cabed6a2c7c9e5" + sha256: c254ade258ec8282947a0acbbc90b9575b4f19673533ee46f2f6e9b3aeefd7c0 url: "https://pub.dev" source: hosted - version: "3.4.0+1" + version: "3.4.0" term_glyph: dependency: transitive description: From 164c9a4df2252819de5582e3dab3bbb136e35ebc Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Tue, 21 Apr 2026 12:18:40 +0200 Subject: [PATCH 17/34] fix recorder on macos --- .../lib/models/bluetooth_auto_connector.dart | 3 +- .../local_recorder_storage.dart | 107 ++++++++++ .../local_recorder_storage_io.dart | 62 +++--- .../flutter/generated_plugin_registrant.cc | 4 - .../linux/flutter/generated_plugins.cmake | 2 - .../Flutter/GeneratedPluginRegistrant.swift | 4 - open_wearable/pubspec.lock | 192 ++---------------- .../flutter/generated_plugin_registrant.cc | 3 - .../windows/flutter/generated_plugins.cmake | 2 - 9 files changed, 158 insertions(+), 221 deletions(-) diff --git a/open_wearable/lib/models/bluetooth_auto_connector.dart b/open_wearable/lib/models/bluetooth_auto_connector.dart index dbc5cf39..e312416f 100644 --- a/open_wearable/lib/models/bluetooth_auto_connector.dart +++ b/open_wearable/lib/models/bluetooth_auto_connector.dart @@ -314,7 +314,8 @@ class BluetoothAutoConnector { } if (!hasPerm) { logger.w( - 'Bluetooth permissions not granted. Showing permissions dialog.'); + 'Bluetooth permissions not granted. Showing permissions dialog.', + ); if (!_askedPermissionsThisSession) { _askedPermissionsThisSession = true; _showPermissionsDialog(); diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart index b567ddf4..6ee6aaba 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart @@ -1,4 +1,111 @@ export 'local_recorder_models.dart'; +<<<<<<< HEAD export 'local_recorder_storage_io.dart' if (dart.library.html) 'local_recorder_storage_web.dart'; +======= +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:path_provider/path_provider.dart'; + +String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last; + +String localRecorderFormatFileSize(File file) { + final bytes = file.lengthSync(); + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; +} + +String localRecorderFormatDateTime(DateTime value) { + final local = value.toLocal(); + String twoDigits(int n) => n.toString().padLeft(2, '0'); + return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' + '${twoDigits(local.hour)}:${twoDigits(local.minute)}'; +} + +Future getAppleRecordingsDirectory() async { + final appDocDir = await getApplicationDocumentsDirectory(); + final dirPath = '${appDocDir.path}/Recordings'; + final dir = Directory(dirPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + return dir; +} + +Future getRecordingsRootDirectory() async { + if (kIsWeb) return null; + if (Platform.isAndroid) { + return getExternalStorageDirectory(); + } + if (Platform.isIOS || Platform.isMacOS) { + return getAppleRecordingsDirectory(); + } + return null; +} + +Future> listRecordingDirectories() async { + final recordingsDir = await getRecordingsRootDirectory(); + if (recordingsDir == null || !await recordingsDir.exists()) { + return []; + } + + final entities = recordingsDir.listSync(); + final recordings = entities + .whereType() + .where((entity) => entity.path.contains('OpenWearable_Recording')) + .toList(); + + recordings.sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed)); + return recordings; +} + +List listFilesInRecordingFolder(Directory folder) { + try { + return folder.listSync(recursive: false).whereType().toList() + ..sort((a, b) => localRecorderBasename(a.path).compareTo(localRecorderBasename(b.path))); + } catch (_) { + return []; + } +} + +Future isDirectoryEmpty(String path) async { + final dir = Directory(path); + if (!await dir.exists()) return true; + return dir.list(followLinks: false).isEmpty; +} + +Future pickRecordingDirectory() async { + if (kIsWeb) return null; + + if (Platform.isAndroid) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + final appDir = await getExternalStorageDirectory(); + if (appDir == null) return null; + + final dirPath = '${appDir.path}/$recordingName'; + final dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + if (Platform.isIOS || Platform.isMacOS) { + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; + final dirPath = + '${(await getAppleRecordingsDirectory()).path}/$recordingName'; + final dir = Directory(dirPath); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + return dirPath; + } + + return null; +} +>>>>>>> 6871962 (fix recorder on MacOS) diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart index 71ec76ac..b755e76d 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart @@ -6,7 +6,11 @@ import 'package:path_provider/path_provider.dart'; import 'local_recorder_models.dart'; -Future _ensureIOSDirectory() async { +/// Helper to get the base name of a file or directory across platforms. +String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last; + +/// Standardizes access to the recordings root for Apple platforms (iOS & macOS). +Future _getAppleRecordingsDirectory() async { final appDocDir = await getApplicationDocumentsDirectory(); final dirPath = '${appDocDir.path}/Recordings'; final dir = Directory(dirPath); @@ -18,36 +22,39 @@ Future _ensureIOSDirectory() async { return dir; } -Future pickRecordingDirectory() async { +Future _getRecordingsRootDirectory() async { if (kIsWeb) return null; - final recordingName = - 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; - if (Platform.isAndroid) { - final appDir = await getExternalStorageDirectory(); - if (appDir == null) return null; - - final dirPath = '${appDir.path}/$recordingName'; - final dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; + return getExternalStorageDirectory(); } - if (Platform.isIOS) { - final dirPath = '${(await _ensureIOSDirectory()).path}/$recordingName'; - final dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; + if (Platform.isIOS || Platform.isMacOS) { + return _getAppleRecordingsDirectory(); } return null; } +Future pickRecordingDirectory() async { + if (kIsWeb) return null; + + final recordingName = + 'OpenWearable_Recording_${DateTime.now().toIso8601String().replaceAll(':', '-')}'; + + final rootDir = await _getRecordingsRootDirectory(); + if (rootDir == null) return null; + + final dirPath = '${rootDir.path}/$recordingName'; + final dir = Directory(dirPath); + + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + return dirPath; +} + Future> listRecordingFolders() async { final root = await _getRecordingsRootDirectory(); if (root == null || !await root.exists()) { @@ -86,17 +93,6 @@ Future readRecordingFileText(LocalRecorderRecordingFile file) { return File(file.path).readAsString(); } -Future _getRecordingsRootDirectory() async { - if (kIsWeb) return null; - if (Platform.isAndroid) { - return getExternalStorageDirectory(); - } - if (Platform.isIOS) { - return _ensureIOSDirectory(); - } - return null; -} - LocalRecorderRecordingFolder _directoryToFolder(Directory directory) { final files = directory .listSync(recursive: false) @@ -118,4 +114,4 @@ LocalRecorderRecordingFolder _directoryToFolder(Directory directory) { updatedAt: directory.statSync().changed, files: files, ); -} \ No newline at end of file +} diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index b180d86f..f547c379 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -9,7 +9,6 @@ #include #include #include -#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -22,9 +21,6 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); - g_autoptr(FlPluginRegistrar) record_linux_registrar = - fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); - record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index dcc28ba2..18a2dcb9 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -6,12 +6,10 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux open_file_linux - record_linux url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index a608bccb..adf49434 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,14 +6,12 @@ import FlutterMacOS import Foundation import audioplayers_darwin -import device_info_plus import file_picker import file_selector_macos import flutter_archive import mcumgr_flutter import open_file_mac import package_info_plus -import record_macos import share_plus import shared_preferences_foundation import universal_ble @@ -22,14 +20,12 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) - DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) McumgrFlutterPlugin.register(with: registry.registrar(forPlugin: "McumgrFlutterPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) - RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 5567f4cb..fb334b8f 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: bloc - sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 + sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 url: "https://pub.dev" source: hosted - version: "9.2.1" + version: "9.2.0" bluez: dependency: transitive description: @@ -193,22 +193,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" - device_info_plus: - dependency: "direct main" - description: - name: device_info_plus - sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd - url: "https://pub.dev" - source: hosted - version: "12.4.0" - device_info_plus_platform_interface: - dependency: transitive - description: - name: device_info_plus_platform_interface - sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f - url: "https://pub.dev" - source: hosted - version: "7.0.3" equatable: dependency: transitive description: @@ -261,10 +245,10 @@ packages: dependency: transitive description: name: file_selector_android - sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" + sha256: bf7ab65776d7e176280c853679e7742668586ba1663f7f1561e897fadad6c3ba url: "https://pub.dev" source: hosted - version: "0.5.2+6" + version: "0.5.2+5" file_selector_ios: dependency: transitive description: @@ -358,14 +342,6 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" - flutter_headset_detector: - dependency: "direct main" - description: - name: flutter_headset_detector - sha256: fe061eceb106a61b837ae58eda1575604db24299a4ebe13e34839dd3d30085df - url: "https://pub.dev" - source: hosted - version: "3.1.0" flutter_lints: dependency: "direct dev" description: @@ -378,10 +354,10 @@ packages: dependency: "direct main" description: name: flutter_platform_widgets - sha256: aa110ef638076831d060047911a62810d02b4695db58e7682b716c4c4eee65bc + sha256: "22a86564cb6cc0b93637c813ca91b0b1f61c2681a31e0f9d77590c1fa9f12020" url: "https://pub.dev" source: hosted - version: "10.0.1" + version: "9.0.0" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -402,10 +378,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" + sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" url: "https://pub.dev" source: hosted - version: "2.3.0" + version: "2.2.4" flutter_test: dependency: "direct dev" description: flutter @@ -444,10 +420,10 @@ packages: dependency: transitive description: name: hooks - sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.2" http: dependency: "direct main" description: @@ -472,22 +448,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" - jni: - dependency: transitive - description: - name: jni - sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f - url: "https://pub.dev" - source: hosted - version: "1.0.0" - jni_flutter: - dependency: transitive - description: - name: jni_flutter - sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" - url: "https://pub.dev" - source: hosted - version: "1.0.1" json_annotation: dependency: transitive description: @@ -680,14 +640,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.3" - package_config: - dependency: transitive - description: - name: package_config - sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" - source: hosted - version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -732,10 +684,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" + sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" url: "https://pub.dev" source: hosted - version: "2.3.1" + version: "2.2.23" path_provider_foundation: dependency: transitive description: @@ -769,7 +721,7 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: "direct main" + dependency: transitive description: name: permission_handler sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 @@ -832,14 +784,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" - playback_capture: - dependency: "direct main" - description: - name: playback_capture - sha256: b2766b8741c00b51e0140660e9503d493a38cf5c9b0b9c5127c1d61f07a8e5e3 - url: "https://pub.dev" - source: hosted - version: "0.0.4" plugin_platform_interface: dependency: transitive description: @@ -873,85 +817,13 @@ packages: source: hosted version: "6.1.5+1" pub_semver: - dependency: "direct main" + dependency: transitive description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted version: "2.2.0" - record: - dependency: "direct main" - description: - name: record - sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 - url: "https://pub.dev" - source: hosted - version: "6.2.0" - record_android: - dependency: transitive - description: - name: record_android - sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" - url: "https://pub.dev" - source: hosted - version: "1.5.1" - record_ios: - dependency: transitive - description: - name: record_ios - sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" - url: "https://pub.dev" - source: hosted - version: "1.2.0" - record_linux: - dependency: transitive - description: - name: record_linux - sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 - url: "https://pub.dev" - source: hosted - version: "1.3.0" - record_macos: - dependency: transitive - description: - name: record_macos - sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" - url: "https://pub.dev" - source: hosted - version: "1.2.1" - record_platform_interface: - dependency: transitive - description: - name: record_platform_interface - sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" - url: "https://pub.dev" - source: hosted - version: "1.5.0" - record_use: - dependency: transitive - description: - name: record_use - sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" - url: "https://pub.dev" - source: hosted - version: "0.6.0" - record_web: - dependency: transitive - description: - name: record_web - sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" - url: "https://pub.dev" - source: hosted - version: "1.3.0" - record_windows: - dependency: transitive - description: - name: record_windows - sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" - url: "https://pub.dev" - source: hosted - version: "1.0.7" rxdart: dependency: transitive description: @@ -960,22 +832,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" - sensors_plus: - dependency: "direct main" - description: - name: sensors_plus - sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa" - url: "https://pub.dev" - source: hosted - version: "7.0.0" - sensors_plus_platform_interface: - dependency: transitive - description: - name: sensors_plus_platform_interface - sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d" - url: "https://pub.dev" - source: hosted - version: "2.0.1" share_plus: dependency: "direct main" description: @@ -1185,10 +1041,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f url: "https://pub.dev" source: hosted - version: "2.4.3" + version: "2.4.2" url_launcher_windows: dependency: transitive description: @@ -1241,18 +1097,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" url: "https://pub.dev" source: hosted - version: "15.2.0" + version: "15.0.2" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 + sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" url: "https://pub.dev" source: hosted - version: "1.5.2" + version: "1.5.1" wakelock_plus_platform_interface: dependency: transitive description: @@ -1277,14 +1133,6 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" - win32_registry: - dependency: transitive - description: - name: win32_registry - sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" - url: "https://pub.dev" - source: hosted - version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index 0eaaf699..37245d29 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -9,7 +9,6 @@ #include #include #include -#include #include #include #include @@ -21,8 +20,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); - RecordWindowsPluginCApiRegisterWithRegistrar( - registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UniversalBlePluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index 3689918f..154f7830 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -6,14 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows permission_handler_windows - record_windows share_plus universal_ble url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST - jni ) set(PLUGIN_BUNDLED_LIBRARIES) From 53d57dbc3c3855d40511d778fb8aef6ecae07f7b Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 13 May 2026 21:38:44 +0300 Subject: [PATCH 18/34] fix recorder in macos --- .../local_recorder_storage.dart | 107 ---------- .../flutter/generated_plugin_registrant.cc | 4 + .../linux/flutter/generated_plugins.cmake | 2 + .../Flutter/GeneratedPluginRegistrant.swift | 4 + open_wearable/pubspec.lock | 192 ++++++++++++++++-- .../flutter/generated_plugin_registrant.cc | 3 + .../windows/flutter/generated_plugins.cmake | 2 + 7 files changed, 187 insertions(+), 127 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart index 6ee6aaba..b567ddf4 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart @@ -1,111 +1,4 @@ export 'local_recorder_models.dart'; -<<<<<<< HEAD export 'local_recorder_storage_io.dart' if (dart.library.html) 'local_recorder_storage_web.dart'; -======= -import 'package:flutter/foundation.dart' show kIsWeb; -import 'package:path_provider/path_provider.dart'; - -String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last; - -String localRecorderFormatFileSize(File file) { - final bytes = file.lengthSync(); - if (bytes < 1024) return '$bytes B'; - if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; - return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; -} - -String localRecorderFormatDateTime(DateTime value) { - final local = value.toLocal(); - String twoDigits(int n) => n.toString().padLeft(2, '0'); - return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' - '${twoDigits(local.hour)}:${twoDigits(local.minute)}'; -} - -Future getAppleRecordingsDirectory() async { - final appDocDir = await getApplicationDocumentsDirectory(); - final dirPath = '${appDocDir.path}/Recordings'; - final dir = Directory(dirPath); - - if (!await dir.exists()) { - await dir.create(recursive: true); - } - - return dir; -} - -Future getRecordingsRootDirectory() async { - if (kIsWeb) return null; - if (Platform.isAndroid) { - return getExternalStorageDirectory(); - } - if (Platform.isIOS || Platform.isMacOS) { - return getAppleRecordingsDirectory(); - } - return null; -} - -Future> listRecordingDirectories() async { - final recordingsDir = await getRecordingsRootDirectory(); - if (recordingsDir == null || !await recordingsDir.exists()) { - return []; - } - - final entities = recordingsDir.listSync(); - final recordings = entities - .whereType() - .where((entity) => entity.path.contains('OpenWearable_Recording')) - .toList(); - - recordings.sort((a, b) => b.statSync().changed.compareTo(a.statSync().changed)); - return recordings; -} - -List listFilesInRecordingFolder(Directory folder) { - try { - return folder.listSync(recursive: false).whereType().toList() - ..sort((a, b) => localRecorderBasename(a.path).compareTo(localRecorderBasename(b.path))); - } catch (_) { - return []; - } -} - -Future isDirectoryEmpty(String path) async { - final dir = Directory(path); - if (!await dir.exists()) return true; - return dir.list(followLinks: false).isEmpty; -} - -Future pickRecordingDirectory() async { - if (kIsWeb) return null; - - if (Platform.isAndroid) { - final recordingName = - 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; - final appDir = await getExternalStorageDirectory(); - if (appDir == null) return null; - - final dirPath = '${appDir.path}/$recordingName'; - final dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; - } - - if (Platform.isIOS || Platform.isMacOS) { - final recordingName = - 'OpenWearable_Recording_${DateTime.now().toIso8601String()}'; - final dirPath = - '${(await getAppleRecordingsDirectory()).path}/$recordingName'; - final dir = Directory(dirPath); - if (!await dir.exists()) { - await dir.create(recursive: true); - } - return dirPath; - } - - return null; -} ->>>>>>> 6871962 (fix recorder on MacOS) diff --git a/open_wearable/linux/flutter/generated_plugin_registrant.cc b/open_wearable/linux/flutter/generated_plugin_registrant.cc index f547c379..b180d86f 100644 --- a/open_wearable/linux/flutter/generated_plugin_registrant.cc +++ b/open_wearable/linux/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include void fl_register_plugins(FlPluginRegistry* registry) { @@ -21,6 +22,9 @@ void fl_register_plugins(FlPluginRegistry* registry) { g_autoptr(FlPluginRegistrar) open_file_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "OpenFileLinuxPlugin"); open_file_linux_plugin_register_with_registrar(open_file_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); diff --git a/open_wearable/linux/flutter/generated_plugins.cmake b/open_wearable/linux/flutter/generated_plugins.cmake index 18a2dcb9..dcc28ba2 100644 --- a/open_wearable/linux/flutter/generated_plugins.cmake +++ b/open_wearable/linux/flutter/generated_plugins.cmake @@ -6,10 +6,12 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_linux file_selector_linux open_file_linux + record_linux url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) diff --git a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift index adf49434..a608bccb 100644 --- a/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/open_wearable/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,12 +6,14 @@ import FlutterMacOS import Foundation import audioplayers_darwin +import device_info_plus import file_picker import file_selector_macos import flutter_archive import mcumgr_flutter import open_file_mac import package_info_plus +import record_macos import share_plus import shared_preferences_foundation import universal_ble @@ -20,12 +22,14 @@ import wakelock_plus func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin")) FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin")) FlutterArchivePlugin.register(with: registry.registrar(forPlugin: "FlutterArchivePlugin")) McumgrFlutterPlugin.register(with: registry.registrar(forPlugin: "McumgrFlutterPlugin")) OpenFilePlugin.register(with: registry.registrar(forPlugin: "OpenFilePlugin")) FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) + RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UniversalBlePlugin.register(with: registry.registrar(forPlugin: "UniversalBlePlugin")) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index fb334b8f..5567f4cb 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -85,10 +85,10 @@ packages: dependency: transitive description: name: bloc - sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 + sha256: e03b235924e4f509c27b5d6b2f949200e0a91149a9818b4f65eeb56662b75413 url: "https://pub.dev" source: hosted - version: "9.2.0" + version: "9.2.1" bluez: dependency: transitive description: @@ -193,6 +193,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.12" + device_info_plus: + dependency: "direct main" + description: + name: device_info_plus + sha256: b4fed1b2835da9d670d7bed7db79ae2a94b0f5ad6312268158a9b5479abbacdd + url: "https://pub.dev" + source: hosted + version: "12.4.0" + device_info_plus_platform_interface: + dependency: transitive + description: + name: device_info_plus_platform_interface + sha256: e1ea89119e34903dca74b883d0dd78eb762814f97fb6c76f35e9ff74d261a18f + url: "https://pub.dev" + source: hosted + version: "7.0.3" equatable: dependency: transitive description: @@ -245,10 +261,10 @@ packages: dependency: transitive description: name: file_selector_android - sha256: bf7ab65776d7e176280c853679e7742668586ba1663f7f1561e897fadad6c3ba + sha256: "89243030ea4b3463fb402b44d5eeacc4ccb1c46a88870cb2a5080d693200c1ed" url: "https://pub.dev" source: hosted - version: "0.5.2+5" + version: "0.5.2+6" file_selector_ios: dependency: transitive description: @@ -342,6 +358,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + flutter_headset_detector: + dependency: "direct main" + description: + name: flutter_headset_detector + sha256: fe061eceb106a61b837ae58eda1575604db24299a4ebe13e34839dd3d30085df + url: "https://pub.dev" + source: hosted + version: "3.1.0" flutter_lints: dependency: "direct dev" description: @@ -354,10 +378,10 @@ packages: dependency: "direct main" description: name: flutter_platform_widgets - sha256: "22a86564cb6cc0b93637c813ca91b0b1f61c2681a31e0f9d77590c1fa9f12020" + sha256: aa110ef638076831d060047911a62810d02b4695db58e7682b716c4c4eee65bc url: "https://pub.dev" source: hosted - version: "9.0.0" + version: "10.0.1" flutter_plugin_android_lifecycle: dependency: transitive description: @@ -378,10 +402,10 @@ packages: dependency: "direct main" description: name: flutter_svg - sha256: "1ded017b39c8e15c8948ea855070a5ff8ff8b3d5e83f3446e02d6bb12add7ad9" + sha256: "35882981abcbfb8c15b286f0cd690ff25bac12d95eff3e25ee207f37d4c42e7f" url: "https://pub.dev" source: hosted - version: "2.2.4" + version: "2.3.0" flutter_test: dependency: "direct dev" description: flutter @@ -420,10 +444,10 @@ packages: dependency: transitive description: name: hooks - sha256: e79ed1e8e1929bc6ecb6ec85f0cb519c887aa5b423705ded0d0f2d9226def388 + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" url: "https://pub.dev" source: hosted - version: "1.0.2" + version: "1.0.3" http: dependency: "direct main" description: @@ -448,6 +472,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.20.2" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: @@ -640,6 +680,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.0.3" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: @@ -684,10 +732,10 @@ packages: dependency: transitive description: name: path_provider_android - sha256: "149441ca6e4f38193b2e004c0ca6376a3d11f51fa5a77552d8bd4d2b0c0912ba" + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.23" + version: "2.3.1" path_provider_foundation: dependency: transitive description: @@ -721,7 +769,7 @@ packages: source: hosted version: "2.3.0" permission_handler: - dependency: transitive + dependency: "direct main" description: name: permission_handler sha256: bc917da36261b00137bbc8896bf1482169cd76f866282368948f032c8c1caae1 @@ -784,6 +832,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.6" + playback_capture: + dependency: "direct main" + description: + name: playback_capture + sha256: b2766b8741c00b51e0140660e9503d493a38cf5c9b0b9c5127c1d61f07a8e5e3 + url: "https://pub.dev" + source: hosted + version: "0.0.4" plugin_platform_interface: dependency: transitive description: @@ -817,13 +873,85 @@ packages: source: hosted version: "6.1.5+1" pub_semver: - dependency: transitive + dependency: "direct main" description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" url: "https://pub.dev" source: hosted version: "2.2.0" + record: + dependency: "direct main" + description: + name: record + sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + url: "https://pub.dev" + source: hosted + version: "6.2.0" + record_android: + dependency: transitive + description: + name: record_android + sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + url: "https://pub.dev" + source: hosted + version: "1.5.1" + record_ios: + dependency: transitive + description: + name: record_ios + sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + url: "https://pub.dev" + source: hosted + version: "1.2.0" + record_linux: + dependency: transitive + description: + name: record_linux + sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_macos: + dependency: transitive + description: + name: record_macos + sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + url: "https://pub.dev" + source: hosted + version: "1.2.1" + record_platform_interface: + dependency: transitive + description: + name: record_platform_interface + sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + url: "https://pub.dev" + source: hosted + version: "1.5.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" + record_web: + dependency: transitive + description: + name: record_web + sha256: "7e9846981c1f2d111d86f0ae3309071f5bba8b624d1c977316706f08fc31d16d" + url: "https://pub.dev" + source: hosted + version: "1.3.0" + record_windows: + dependency: transitive + description: + name: record_windows + sha256: "223258060a1d25c62bae18282c16783f28581ec19401d17e56b5205b9f039d78" + url: "https://pub.dev" + source: hosted + version: "1.0.7" rxdart: dependency: transitive description: @@ -832,6 +960,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.28.0" + sensors_plus: + dependency: "direct main" + description: + name: sensors_plus + sha256: "56e8cd4260d9ed8e00ecd8da5d9fdc8a1b2ec12345a750dfa51ff83fcf12e3fa" + url: "https://pub.dev" + source: hosted + version: "7.0.0" + sensors_plus_platform_interface: + dependency: transitive + description: + name: sensors_plus_platform_interface + sha256: "58815d2f5e46c0c41c40fb39375d3f127306f7742efe3b891c0b1c87e2b5cd5d" + url: "https://pub.dev" + source: hosted + version: "2.0.1" share_plus: dependency: "direct main" description: @@ -1041,10 +1185,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.4.3" url_launcher_windows: dependency: transitive description: @@ -1097,18 +1241,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" wakelock_plus: dependency: "direct main" description: name: wakelock_plus - sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39" + sha256: ddf3db70eaa10c37558ff817519b85d527dbd21034fd5d8e1c2e85f31588f1c1 url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" wakelock_plus_platform_interface: dependency: transitive description: @@ -1133,6 +1277,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.15.0" + win32_registry: + dependency: transitive + description: + name: win32_registry + sha256: "6f1b564492d0147b330dd794fee8f512cec4977957f310f9951b5f9d83618dae" + url: "https://pub.dev" + source: hosted + version: "2.1.0" xdg_directories: dependency: transitive description: diff --git a/open_wearable/windows/flutter/generated_plugin_registrant.cc b/open_wearable/windows/flutter/generated_plugin_registrant.cc index 37245d29..0eaaf699 100644 --- a/open_wearable/windows/flutter/generated_plugin_registrant.cc +++ b/open_wearable/windows/flutter/generated_plugin_registrant.cc @@ -9,6 +9,7 @@ #include #include #include +#include #include #include #include @@ -20,6 +21,8 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { registry->GetRegistrarForPlugin("FileSelectorWindows")); PermissionHandlerWindowsPluginRegisterWithRegistrar( registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin")); + RecordWindowsPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("RecordWindowsPluginCApi")); SharePlusWindowsPluginCApiRegisterWithRegistrar( registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi")); UniversalBlePluginCApiRegisterWithRegistrar( diff --git a/open_wearable/windows/flutter/generated_plugins.cmake b/open_wearable/windows/flutter/generated_plugins.cmake index 154f7830..3689918f 100644 --- a/open_wearable/windows/flutter/generated_plugins.cmake +++ b/open_wearable/windows/flutter/generated_plugins.cmake @@ -6,12 +6,14 @@ list(APPEND FLUTTER_PLUGIN_LIST audioplayers_windows file_selector_windows permission_handler_windows + record_windows share_plus universal_ble url_launcher_windows ) list(APPEND FLUTTER_FFI_PLUGIN_LIST + jni ) set(PLUGIN_BUNDLED_LIBRARIES) From e281f412848c6acdae25a7bbb15126e4db5eb6ad Mon Sep 17 00:00:00 2001 From: Oliver Bagge Date: Wed, 13 May 2026 21:39:15 +0300 Subject: [PATCH 19/34] remove export --- .../widgets/sensors/local_recorder/local_recorder_storage.dart | 2 -- 1 file changed, 2 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart index b567ddf4..edda661e 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage.dart @@ -1,4 +1,2 @@ -export 'local_recorder_models.dart'; - export 'local_recorder_storage_io.dart' if (dart.library.html) 'local_recorder_storage_web.dart'; From 6f305ed111a0eb1f8b9f79cf2efc74314e6c06dc Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 20 May 2026 22:40:35 +0200 Subject: [PATCH 20/34] fix(recorder): cancel web sensor subscriptions --- .../sensor_recorder_provider_web.dart | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart index 7c932d3a..40bc27ad 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart @@ -81,6 +81,7 @@ class SensorRecorderProvider with ChangeNotifier { final folderPath = _currentDirectory; final sessions = _sessions.values.toList(growable: false); _sessions.clear(); + await _cancelSensorSubscriptions(_sensorSubscriptions.keys); for (final session in sessions) { await session.dispose(); @@ -154,8 +155,8 @@ class SensorRecorderProvider with ChangeNotifier { .toList(growable: false); for (final key in sessionKeys) { unawaited(_sessions.remove(key)?.dispose()); - _sensorSubscriptions.remove(key)?.cancel(); } + unawaited(_cancelSensorSubscriptions(sessionKeys)); _updateConnected(); } @@ -186,16 +187,27 @@ class SensorRecorderProvider with ChangeNotifier { ); _sessions[key] = session; - final subscription = SensorStreams.shared( + _sensorSubscriptions[key] = SensorStreams.shared( wearable: wearable, sensor: sensor, - ).listen((sensorValue) { - session.append(sensorValue); - }); - _sensorSubscriptions[key] = subscription; + ).listen(session.append); } } + Future _cancelSensorSubscriptions(Iterable keys) async { + final subscriptions = >[]; + for (final key in keys.toList(growable: false)) { + final subscription = _sensorSubscriptions.remove(key); + if (subscription != null) { + subscriptions.add(subscription); + } + } + + await Future.wait( + subscriptions.map((subscription) => subscription.cancel()), + ); + } + String _sensorRecordingKey({ required Wearable wearable, required Sensor sensor, @@ -232,10 +244,7 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { - for (final subscription in _sensorSubscriptions.values) { - subscription.cancel(); - } - _sensorSubscriptions.clear(); + unawaited(_cancelSensorSubscriptions(_sensorSubscriptions.keys)); for (final session in _sessions.values) { unawaited(session.dispose()); } From 1991efc39c50d1d5bdcf21b12445ffd5448cf879 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 20 May 2026 22:40:41 +0200 Subject: [PATCH 21/34] fix(web): replace deprecated html APIs --- .../local_recorder_file_actions_web.dart | 22 +++++++++++++------ open_wearable/pubspec.lock | 6 ++--- open_wearable/pubspec.yaml | 1 + 3 files changed, 19 insertions(+), 10 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart index 1ba8ba32..7dd4803c 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_web.dart @@ -1,6 +1,7 @@ -import 'dart:html' as html; +import 'dart:js_interop'; import 'package:share_plus/share_plus.dart'; +import 'package:web/web.dart' as web; import 'local_recorder_models.dart'; import 'local_recorder_storage.dart'; @@ -21,7 +22,9 @@ Future localRecorderShareFile(LocalRecorderRecordingFile file) async { ); } -Future localRecorderShareFolder(LocalRecorderRecordingFolder folder) async { +Future localRecorderShareFolder( + LocalRecorderRecordingFolder folder, +) async { final files = []; for (final file in folder.files) { final bytes = await readRecordingFileBytes(file); @@ -42,9 +45,14 @@ Future localRecorderShareFolder(LocalRecorderRecordingFolder folder) async ); } -Future localRecorderOpenRecordingFile(LocalRecorderRecordingFile file) async { +Future localRecorderOpenRecordingFile( + LocalRecorderRecordingFile file, +) async { final bytes = await readRecordingFileBytes(file); - final blob = html.Blob([bytes], file.mimeType); - final url = html.Url.createObjectUrlFromBlob(blob); - html.window.open(url, '_blank'); -} \ No newline at end of file + final blob = web.Blob( + [bytes.toJS].toJS, + web.BlobPropertyBag(type: file.mimeType), + ); + final url = web.URL.createObjectURL(blob); + web.window.open(url, '_blank'); +} diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 5567f4cb..3929fb39 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -548,10 +548,10 @@ packages: dependency: transitive description: name: matcher - sha256: "12956d0ad8390bbcc63ca2e1469c0619946ccb52809807067a7020d57e647aa6" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.18" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -1262,7 +1262,7 @@ packages: source: hosted version: "1.5.1" web: - dependency: transitive + dependency: "direct main" description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 3bccff6a..381ca02b 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -66,6 +66,7 @@ dependencies: flutter_headset_detector: ^3.1.0 record: ^6.1.2 permission_handler: ^12.0.1 + web: ^1.1.1 dev_dependencies: flutter_test: From e7cf4b325565d852e326cea038d50d7ff6be6e6c Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 20 May 2026 22:40:47 +0200 Subject: [PATCH 22/34] style(recorder): format local recorder helpers --- .../local_recorder/local_recorder_file_actions_io.dart | 10 +++++++--- .../sensors/local_recorder/local_recorder_models.dart | 5 ++--- .../local_recorder/local_recorder_storage_web.dart | 3 ++- 3 files changed, 11 insertions(+), 7 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart index 0a935236..99f8184a 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart @@ -12,7 +12,9 @@ Future localRecorderShareFile(LocalRecorderRecordingFile file) async { ); } -Future localRecorderShareFolder(LocalRecorderRecordingFolder folder) async { +Future localRecorderShareFolder( + LocalRecorderRecordingFolder folder, +) async { await SharePlus.instance.share( ShareParams( files: folder.files.map((entry) => XFile(entry.path)).toList(), @@ -21,7 +23,9 @@ Future localRecorderShareFolder(LocalRecorderRecordingFolder folder) async ); } -Future localRecorderOpenRecordingFile(LocalRecorderRecordingFile file) async { +Future localRecorderOpenRecordingFile( + LocalRecorderRecordingFile file, +) async { final result = await OpenFile.open( file.path, type: 'text/comma-separated-values', @@ -30,4 +34,4 @@ Future localRecorderOpenRecordingFile(LocalRecorderRecordingFile file) asy if (result.type != ResultType.done) { throw StateError(result.message); } -} \ No newline at end of file +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart index f8ee3aee..adb923ac 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart @@ -40,8 +40,7 @@ class LocalRecorderDraftFile { }); } -String localRecorderBasename(String path) => - path.split(RegExp(r'[\\/]+')).last; +String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last; String localRecorderFormatFileSize(int bytes) { if (bytes < 1024) return '$bytes B'; @@ -54,4 +53,4 @@ String localRecorderFormatDateTime(DateTime value) { String twoDigits(int n) => n.toString().padLeft(2, '0'); return '${local.year}-${twoDigits(local.month)}-${twoDigits(local.day)} ' '${twoDigits(local.hour)}:${twoDigits(local.minute)}'; -} \ No newline at end of file +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart index 66806f22..30cd9e66 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart @@ -61,7 +61,8 @@ Future persistRecordingFolderFiles( } Future readRecordingFileBytes( - LocalRecorderRecordingFile file) async { + LocalRecorderRecordingFile file, +) async { final prefs = await SharedPreferences.getInstance(); final folders = _readFolders(prefs); for (final folder in folders) { From 68003fd4f02e4248ac7fdd62ed14d73b9b0690d9 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 20 May 2026 22:40:52 +0200 Subject: [PATCH 23/34] build(macos): refresh pod lockfile --- open_wearable/macos/Podfile.lock | 4 ---- 1 file changed, 4 deletions(-) diff --git a/open_wearable/macos/Podfile.lock b/open_wearable/macos/Podfile.lock index 29f78e00..41eb4bdf 100644 --- a/open_wearable/macos/Podfile.lock +++ b/open_wearable/macos/Podfile.lock @@ -8,7 +8,6 @@ PODS: - ZIPFoundation (= 0.9.19) - mcumgr_flutter (0.9.0): - Flutter - - record_macos (1.2.0): - FlutterMacOS - iOSMcuManagerLibrary (= 1.12) - SwiftProtobuf @@ -20,7 +19,6 @@ DEPENDENCIES: - flutter_archive (from `Flutter/ephemeral/.symlinks/plugins/flutter_archive/macos`) - FlutterMacOS (from `Flutter/ephemeral`) - mcumgr_flutter (from `Flutter/ephemeral/.symlinks/plugins/mcumgr_flutter/darwin`) - - record_macos (from `Flutter/ephemeral/.symlinks/plugins/record_macos/macos`) SPEC REPOS: trunk: @@ -36,8 +34,6 @@ EXTERNAL SOURCES: :path: Flutter/ephemeral mcumgr_flutter: :path: Flutter/ephemeral/.symlinks/plugins/mcumgr_flutter/darwin - record_macos: - :path: Flutter/ephemeral/.symlinks/plugins/record_macos/macos SPEC CHECKSUMS: flutter_archive: 07888d9aeb79da005e0ad8b9d347d17cdea07f68 From f9888815c6924af74596bf83dc43faab3f3bfb58 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Thu, 21 May 2026 11:02:36 +0200 Subject: [PATCH 24/34] fix(sensors): remove duplicate stereo badge --- .../configuration/sensor_configuration_device_row.dart | 5 ----- 1 file changed, 5 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart index 904933f9..4845f87a 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_device_row.dart @@ -6,7 +6,6 @@ import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/view_models/sensor_configuration_storage.dart'; import 'package:open_wearable/view_models/sensor_profile_service.dart'; import 'package:open_wearable/widgets/app_toast.dart'; -import 'package:open_wearable/widgets/devices/device_detail/stereo_pos_label.dart'; import 'package:open_wearable/widgets/devices/stereo_position_badge.dart'; import 'package:open_wearable/widgets/sensors/configuration/edge_recorder_prefix_row.dart'; import 'package:open_wearable/widgets/sensors/configuration/save_config_row.dart'; @@ -140,10 +139,6 @@ class _SensorConfigurationDeviceRowState child: tabBar, ), ], - if (device.hasCapability()) - StereoPosLabel( - device: device.requireCapability(), - ), ], ), ), From 519052c08b47d17c8aac00b9a257fa029ec706a5 Mon Sep 17 00:00:00 2001 From: TobiasRoeddiger Date: Thu, 21 May 2026 14:05:40 +0200 Subject: [PATCH 25/34] fix(sensors): update system microphone level chart --- .../sensor_recorder_provider_io.dart | 28 +- .../sensor_recorder_provider_web.dart | 11 + .../microphone_selection_widget.dart | 9 + .../sensor_configuration_view.dart | 9 + .../sensors/values/sensor_values_page.dart | 900 ++++++++++++++---- 5 files changed, 771 insertions(+), 186 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart index bf573ea3..411ae815 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -25,6 +25,9 @@ import '../models/sensor_streams.dart'; /// - Recording status (`isRecording`, `recordingStart`, etc.). /// - Recorder access used by recorder UI pages. class SensorRecorderProvider with ChangeNotifier { + static const Duration _microphoneConfigurationSettleDelay = + Duration(milliseconds: 300); + final Map> _recorders = {}; final Map _recordingFilepathsBySensorIdentity = {}; Future _pendingSynchronization = Future.value(); @@ -51,9 +54,30 @@ class SensorRecorderProvider with ChangeNotifier { InputDevice? _selectedBLEDevice; + /// Label for the currently selected BLE microphone input, when available. + String? get selectedBLEDeviceLabel => _selectedBLEDevice?.label; + + int _microphoneConfigurationRevision = 0; + int get microphoneConfigurationRevision => _microphoneConfigurationRevision; + bool _isBLEMicrophoneStreamingEnabled = false; bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; + void notifyMicrophoneConfigurationChanged() { + _bumpMicrophoneConfigurationRevision(); + Future.delayed(_microphoneConfigurationSettleDelay, () { + if (_disposed) { + return; + } + _bumpMicrophoneConfigurationRevision(); + }); + } + + void _bumpMicrophoneConfigurationRevision() { + _microphoneConfigurationRevision++; + notifyListeners(); + } + // Path for temporary streaming file String? _streamingPath; bool _isStreamingActive = false; @@ -133,7 +157,7 @@ class SensorRecorderProvider with ChangeNotifier { .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen((amp) { final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); + _waveformData.add(normalized.clamp(0.0, 1.0)); if (_waveformData.length > 100) { _waveformData.removeAt(0); @@ -295,7 +319,7 @@ class SensorRecorderProvider with ChangeNotifier { .onAmplitudeChanged(const Duration(milliseconds: 100)) .listen((amp) { final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 2.0)); + _waveformData.add(normalized.clamp(0.0, 1.0)); if (_waveformData.length > 100) { _waveformData.removeAt(0); diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart index 40bc27ad..81278aaf 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart @@ -28,9 +28,20 @@ class SensorRecorderProvider with ChangeNotifier { int get waveformRevision => _waveformRevision; List get waveformData => List.unmodifiable(_waveformData); + /// Web does not expose a selected BLE microphone input. + String? get selectedBLEDeviceLabel => null; + + int _microphoneConfigurationRevision = 0; + int get microphoneConfigurationRevision => _microphoneConfigurationRevision; + bool _isBLEMicrophoneStreamingEnabled = false; bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; + void notifyMicrophoneConfigurationChanged() { + _microphoneConfigurationRevision++; + notifyListeners(); + } + Future startBLEMicrophoneStream() async { logger.w('BLE microphone streaming is not supported on web.'); return false; diff --git a/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart b/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart index 09eca53d..31f37e4c 100644 --- a/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart +++ b/open_wearable/lib/widgets/devices/device_detail/microphone_selection_widget.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; +import 'package:provider/provider.dart'; import 'stereo_pair_option_selector.dart'; @@ -30,6 +32,13 @@ class MicrophoneSelectionWidget extends StatelessWidget { readSelection: (manager) => manager.getMicrophone(), applySelection: (manager, microphone) async { manager.setMicrophone(microphone); + try { + context + .read() + .notifyMicrophoneConfigurationChanged(); + } catch (_) { + // The selector can be embedded outside the recorder provider tree. + } }, optionsFor: (manager) => manager.availableMicrophones, supportsOption: (manager, microphone) => manager.availableMicrophones.any( diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index 0c60af2a..bc9fb59c 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -278,6 +278,7 @@ class SensorConfigurationView extends StatelessWidget { final recorderProvider = Provider.of(context, listen: false); bool shouldEnableMicrophoneStreaming = false; + bool didApplyMicrophoneConfiguration = false; for (final target in targets) { final primaryEntriesToApply = _entriesToApplyForProvider(target.provider); @@ -292,6 +293,7 @@ class SensorConfigurationView extends StatelessWidget { final SensorConfiguration config = entry.$1; final SensorConfigurationValue value = entry.$2; if (config.name.toLowerCase().contains('microphone')) { + didApplyMicrophoneConfiguration = true; final options = target.provider.getSelectedConfigurationOptions(config); if (options.any((opt) => opt is StreamSensorConfigOption)) { @@ -306,6 +308,9 @@ class SensorConfigurationView extends StatelessWidget { for (final entry in mirroredEntriesToApply) { final SensorConfiguration config = entry.$1; final SensorConfigurationValue value = entry.$2; + if (config.name.toLowerCase().contains('microphone')) { + didApplyMicrophoneConfiguration = true; + } config.setConfiguration(value); } @@ -322,6 +327,10 @@ class SensorConfigurationView extends StatelessWidget { ); } + if (didApplyMicrophoneConfiguration) { + recorderProvider.notifyMicrophoneConfigurationChanged(); + } + if (!context.mounted) { return; } diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 6db4854c..8987062e 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,10 +1,12 @@ import 'dart:io'; +import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; @@ -27,49 +29,22 @@ class SensorValuesPage extends StatefulWidget { class _SensorValuesPageState extends State with AutomaticKeepAliveClientMixin { + static const Duration _audioMicrophoneSourcesRefreshInterval = + Duration(seconds: 5); + final Map<(Wearable, Sensor), SensorDataProvider> _ownedProviders = {}; + Future>? _audioMicrophoneSourcesFuture; + String? _audioMicrophoneSourcesCacheKey; + DateTime? _audioMicrophoneSourcesLastRefresh; Map<(Wearable, Sensor), SensorDataProvider> get _sensorDataProvider => widget.sharedProviders ?? _ownedProviders; bool get _ownsProviders => widget.sharedProviders == null; - String? _errorMessage; - - bool _isInitializing = true; - @override bool get wantKeepAlive => true; - @override - void initState() { - super.initState(); - if (!kIsWeb && Platform.isAndroid) { - _checkStreamingStatus(); - } - } - - void _checkStreamingStatus() { - final recorderProvider = - Provider.of(context, listen: false); - if (!recorderProvider.isBLEMicrophoneStreamingEnabled) { - if (mounted) { - setState(() { - _isInitializing = false; - _errorMessage = - 'BLE microphone streaming not enabled. Enable it in sensor configuration.'; - }); - } - } else { - if (mounted) { - setState(() { - _isInitializing = false; - _errorMessage = null; - }); - } - } - } - @override void dispose() { if (_ownsProviders) { @@ -117,6 +92,16 @@ class _SensorValuesPageState extends State ); final orderedWearables = _orderedWearablesFromGroups(groups); + final audioHeaderInfo = _resolveAudioHeaderInfo( + groups: groups, + recorderProvider: recorderProvider, + ); + final audioMicrophoneSourcesFuture = + _audioMicrophoneSourcesFutureFor( + groups, + microphoneConfigurationRevision: + recorderProvider.microphoneConfigurationRevision, + ); _ensureProviders(orderedWearables); _cleanupProviders(orderedWearables); @@ -135,6 +120,9 @@ class _SensorValuesPageState extends State context, charts, recorderProvider, + audioHeaderInfo: audioHeaderInfo, + audioMicrophoneSourcesFuture: + audioMicrophoneSourcesFuture, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -144,6 +132,9 @@ class _SensorValuesPageState extends State context, charts, recorderProvider, + audioHeaderInfo: audioHeaderInfo, + audioMicrophoneSourcesFuture: + audioMicrophoneSourcesFuture, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -301,87 +292,354 @@ class _SensorValuesPageState extends State return ordered; } - Widget _buildAudioUI(SensorRecorderProvider recorderProvider) { - // If initializing, show a loading card - if (!kIsWeb && _isInitializing && Platform.isAndroid) { - return Card( - child: Container( - height: 100, - alignment: Alignment.center, - child: const CircularProgressIndicator(), + _AudioHeaderInfo _resolveAudioHeaderInfo({ + required List groups, + required SensorRecorderProvider recorderProvider, + }) { + final stereoBadgeLabel = _resolveAudioStereoBadgeLabel(groups); + final selectedInputLabel = recorderProvider.selectedBLEDeviceLabel; + final selectedInputName = + _formatAudioDeviceName(selectedInputLabel, allowGeneric: false); + if (selectedInputName != null) { + return _AudioHeaderInfo( + deviceName: selectedInputName, + stereoBadgeLabel: stereoBadgeLabel, + ); + } + + if (groups.isEmpty) { + return _AudioHeaderInfo( + deviceName: null, + stereoBadgeLabel: stereoBadgeLabel, + ); + } + + WearableDisplayGroup? combinedGroup; + for (final group in groups) { + if (group.isCombined) { + combinedGroup = group; + break; + } + } + final displayName = combinedGroup?.displayName ?? groups.first.displayName; + return _AudioHeaderInfo( + deviceName: _formatAudioDeviceName(displayName, allowGeneric: true), + stereoBadgeLabel: stereoBadgeLabel, + ); + } + + String? _resolveAudioStereoBadgeLabel(List groups) { + if (groups.any((group) => group.isCombined)) { + return 'L+R'; + } + + final sidesByPairKey = >{}; + DevicePosition? singleKnownSide; + for (final group in groups) { + if (group.primaryPosition != null) { + singleKnownSide ??= group.primaryPosition; + } + final pairKey = group.stereoPairKey; + final position = group.primaryPosition; + if (pairKey == null || position == null) { + continue; + } + sidesByPairKey.putIfAbsent(pairKey, () => {}).add( + position, + ); + } + + final hasConnectedPair = sidesByPairKey.values.any( + (positions) => + positions.contains(DevicePosition.left) && + positions.contains(DevicePosition.right), + ); + if (hasConnectedPair) { + return 'L+R'; + } + + return switch (singleKnownSide) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + } + + String? _formatAudioDeviceName( + String? rawName, { + required bool allowGeneric, + }) { + final trimmed = rawName?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + final lower = trimmed.toLowerCase(); + if (!allowGeneric && + (lower == 'bluetooth' || + lower.contains('bluetooth sco') || + lower.contains('default'))) { + return null; + } + + final formatted = formatWearableDisplayName(trimmed); + final withoutSideSuffix = formatted + .replaceFirst( + RegExp(r'\s*\((left|right|l|r)\)$', caseSensitive: false), + '', + ) + .replaceFirst( + RegExp(r'[\s_-]+(left|right|l|r)$', caseSensitive: false), + '', + ) + .trim(); + + return withoutSideSuffix.isEmpty ? formatted : withoutSideSuffix; + } + + Future> _resolveAudioMicrophoneSources( + List groups, + ) async { + final futures = >[]; + final seenDeviceIds = {}; + + void addCandidate(Wearable? wearable, DevicePosition? position) { + if (wearable == null || !seenDeviceIds.add(wearable.deviceId)) { + return; + } + futures.add( + _resolveAudioMicrophoneSource( + wearable: wearable, + position: position, ), ); } - return Column( - children: [ - if (recorderProvider.isBLEMicrophoneStreamingEnabled) - Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.fiber_manual_record, - color: Colors.red, - size: 16, - ), - const SizedBox(width: 8), - Text( - 'AUDIO WAVEFORM ${recorderProvider.isRecording ? "(RECORDING)" : ""}', - style: Theme.of(context).textTheme.labelLarge, - ), - ], + for (final group in groups) { + final leftDevice = group.leftDevice; + final rightDevice = group.rightDevice; + addCandidate(leftDevice, DevicePosition.left); + addCandidate(rightDevice, DevicePosition.right); + if (leftDevice == null && rightDevice == null) { + addCandidate(group.primary, group.primaryPosition); + } + } + + final resolvedSources = await Future.wait(futures); + final sources = resolvedSources + .whereType<_AudioMicrophoneSourceInfo>() + .toList(growable: false); + return sources..sort(_compareAudioMicrophoneSources); + } + + Future> _audioMicrophoneSourcesFutureFor( + List groups, { + required int microphoneConfigurationRevision, + }) { + final cacheKey = + '${_audioMicrophoneSourcesKey(groups)}#$microphoneConfigurationRevision'; + final now = DateTime.now(); + final lastRefresh = _audioMicrophoneSourcesLastRefresh; + final cacheExpired = lastRefresh == null || + now.difference(lastRefresh) > _audioMicrophoneSourcesRefreshInterval; + + if (_audioMicrophoneSourcesFuture == null || + _audioMicrophoneSourcesCacheKey != cacheKey || + cacheExpired) { + _audioMicrophoneSourcesCacheKey = cacheKey; + _audioMicrophoneSourcesLastRefresh = now; + _audioMicrophoneSourcesFuture = _resolveAudioMicrophoneSources(groups); + } + + return _audioMicrophoneSourcesFuture!; + } + + String _audioMicrophoneSourcesKey(List groups) { + final parts = []; + for (final group in groups) { + final leftDevice = group.leftDevice; + final rightDevice = group.rightDevice; + if (leftDevice != null) { + parts.add('${leftDevice.deviceId}:left'); + } + if (rightDevice != null) { + parts.add('${rightDevice.deviceId}:right'); + } + if (leftDevice == null && rightDevice == null) { + parts.add( + '${group.primary.deviceId}:${group.primaryPosition?.name ?? 'unknown'}', + ); + } + } + return parts.join('|'); + } + + Future<_AudioMicrophoneSourceInfo?> _resolveAudioMicrophoneSource({ + required Wearable wearable, + required DevicePosition? position, + }) async { + if (!wearable.hasCapability()) { + return null; + } + + final resolvedPosition = + position ?? await _readAudioMicrophoneSourcePosition(wearable); + final sideLabel = _audioMicrophoneSourceSideLabel(resolvedPosition); + if (sideLabel == null) { + return null; + } + + try { + final microphone = + await wearable.requireCapability().getMicrophone(); + final microphoneLabel = _audioMicrophoneSourceLabel(microphone); + if (microphoneLabel == null) { + return null; + } + return _AudioMicrophoneSourceInfo( + sideLabel: sideLabel, + microphoneLabel: microphoneLabel, + ); + } catch (_) { + return null; + } + } + + Future _readAudioMicrophoneSourcePosition( + Wearable wearable, + ) async { + if (!wearable.hasCapability()) { + return null; + } + try { + return await wearable.requireCapability().position; + } catch (_) { + return null; + } + } + + int _compareAudioMicrophoneSources( + _AudioMicrophoneSourceInfo a, + _AudioMicrophoneSourceInfo b, + ) { + return _audioMicrophoneSourceSortRank(a.sideLabel) + .compareTo(_audioMicrophoneSourceSortRank(b.sideLabel)); + } + + int _audioMicrophoneSourceSortRank(String sideLabel) { + return switch (sideLabel) { + 'L' => 0, + 'R' => 1, + _ => 2, + }; + } + + String? _audioMicrophoneSourceSideLabel(DevicePosition? position) { + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + } + + String? _audioMicrophoneSourceLabel(Microphone microphone) { + final normalized = microphone.key.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); + if (normalized.contains('inner') || normalized.contains('internal')) { + return 'Inner'; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return 'Outer'; + } + return null; + } + + Widget _buildAudioUI( + SensorRecorderProvider recorderProvider, { + required _AudioHeaderInfo audioHeaderInfo, + required Future> + audioMicrophoneSourcesFuture, + }) { + final hasHeaderMeta = audioHeaderInfo.deviceName != null || + audioHeaderInfo.stereoBadgeLabel != null; + + return Card( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: PlatformText( + 'SYSTEM MICROPHONE', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), ), - const SizedBox(height: 8), - CustomPaint( - size: const Size(double.infinity, 100), - painter: WaveformPainter( - recorderProvider.waveformData, - sampleRevision: recorderProvider.waveformRevision, + ), + if (hasHeaderMeta) ...[ + const SizedBox(width: 8), + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (audioHeaderInfo.deviceName != null) + Flexible( + child: PlatformText( + audioHeaderInfo.deviceName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + if (audioHeaderInfo.stereoBadgeLabel != null) ...[ + const SizedBox(width: 8), + _AudioStereoBadge( + label: audioHeaderInfo.stereoBadgeLabel!, + ), + ], + ], ), ), ], - ), + ], ), - ) - else if (_errorMessage != null) - Card( - child: Padding( - padding: const EdgeInsets.all(16.0), - child: Row( - children: [ - const Icon(Icons.error_outline, color: Colors.red), - const SizedBox(width: 12), - Expanded( - child: PlatformText( - _errorMessage!, - style: const TextStyle(color: Colors.red), - ), - ), - ], + const SizedBox(height: 10), + SizedBox( + height: 200, + child: _AudioLevelChart( + waveformData: recorderProvider.waveformData, + microphoneSourcesFuture: audioMicrophoneSourcesFuture, ), ), - ), - const SizedBox(height: 10), - ], + ], + ), + ), ); } + bool _shouldShowSystemMicrophoneChart() => !kIsWeb && Platform.isAndroid; + Widget _buildSmallScreenLayout( BuildContext context, List charts, SensorRecorderProvider recorderProvider, { + required _AudioHeaderInfo audioHeaderInfo, + required Future> + audioMicrophoneSourcesFuture, required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { return ListView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), children: [ - _buildAudioUI(recorderProvider), ...charts, if (charts.isEmpty) Center( @@ -393,6 +651,12 @@ class _SensorValuesPageState extends State ), ), ), + if (_shouldShowSystemMicrophoneChart()) + _buildAudioUI( + recorderProvider, + audioHeaderInfo: audioHeaderInfo, + audioMicrophoneSourcesFuture: audioMicrophoneSourcesFuture, + ), ], ); } @@ -401,38 +665,46 @@ class _SensorValuesPageState extends State BuildContext context, List charts, SensorRecorderProvider recorderProvider, { + required _AudioHeaderInfo audioHeaderInfo, + required Future> + audioMicrophoneSourcesFuture, required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { + final gridItems = [ + if (charts.isEmpty) + _buildEmptyStateCard( + context, + _resolveEmptyState( + hasAnySensors: hasAnySensors, + hideCardsWithoutLiveData: hideCardsWithoutLiveData, + ), + ) + else + ...charts, + if (_shouldShowSystemMicrophoneChart()) + _buildAudioUI( + recorderProvider, + audioHeaderInfo: audioHeaderInfo, + audioMicrophoneSourcesFuture: audioMicrophoneSourcesFuture, + ), + ]; + return SingleChildScrollView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), - child: Column( - children: [ - _buildAudioUI(recorderProvider), - GridView.builder( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( - maxCrossAxisExtent: 500, - childAspectRatio: 1.5, - crossAxisSpacing: SensorPageSpacing.gridGap, - mainAxisSpacing: SensorPageSpacing.gridGap, - ), - itemCount: charts.isEmpty ? 1 : charts.length, - itemBuilder: (context, index) { - if (charts.isEmpty) { - return _buildEmptyStateCard( - context, - _resolveEmptyState( - hasAnySensors: hasAnySensors, - hideCardsWithoutLiveData: hideCardsWithoutLiveData, - ), - ); - } - return charts[index]; - }, - ), - ], + child: GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent( + maxCrossAxisExtent: 500, + childAspectRatio: 1.5, + crossAxisSpacing: SensorPageSpacing.gridGap, + mainAxisSpacing: SensorPageSpacing.gridGap, + ), + itemCount: gridItems.length, + itemBuilder: (context, index) { + return gridItems[index]; + }, ), ); } @@ -554,85 +826,345 @@ class _SensorValuesEmptyState { }); } -/// Paints the live audio amplitude window as a horizontally scrolling waveform. -class WaveformPainter extends CustomPainter { +class _AudioHeaderInfo { + final String? deviceName; + final String? stereoBadgeLabel; + + const _AudioHeaderInfo({ + required this.deviceName, + required this.stereoBadgeLabel, + }); +} + +class _AudioMicrophoneSourceInfo { + final String sideLabel; + final String microphoneLabel; + + const _AudioMicrophoneSourceInfo({ + required this.sideLabel, + required this.microphoneLabel, + }); + + String get label => '$microphoneLabel ($sideLabel)'; +} + +class _AudioStereoBadge extends StatelessWidget { + final String label; + + const _AudioStereoBadge({ + required this.label, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.24), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _AudioLevelChart extends StatelessWidget { + static const double _sampleIntervalSeconds = 0.1; + static const double _windowSeconds = 5; + static const int _maxSamples = 51; + static const double _maxAbsLevel = 100; + final List waveformData; - final int sampleRevision; - final Color waveColor; - final double spacing; - final double waveThickness; - final bool showMiddleLine; - - WaveformPainter( - this.waveformData, { - required this.sampleRevision, - this.waveColor = Colors.blue, - this.spacing = 4.0, - this.waveThickness = 3.0, - this.showMiddleLine = true, + final Future> microphoneSourcesFuture; + + const _AudioLevelChart({ + required this.waveformData, + required this.microphoneSourcesFuture, }); @override - void paint(Canvas canvas, Size size) { - if (waveformData.isEmpty) return; - - final double height = size.height; - final double centerY = height / 2; - - // Draw middle line first (behind the bars) - if (showMiddleLine) { - final centerLinePaint = Paint() - ..color = Colors.grey.withAlpha(75) - ..strokeWidth = 1.0; - canvas.drawLine( - Offset(0, centerY), - Offset(size.width, centerY), - centerLinePaint, - ); - } + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final bars = _buildBars(colorScheme.primary); - // Paint for the vertical bars - final paint = Paint() - ..color = waveColor - ..strokeWidth = waveThickness - ..strokeCap = StrokeCap.round - ..style = PaintingStyle.stroke; + final chartData = LineChartData( + minX: -_windowSeconds, + maxX: 0, + minY: -_maxAbsLevel, + maxY: _maxAbsLevel, + lineTouchData: const LineTouchData( + enabled: false, + handleBuiltInTouches: false, + ), + gridData: FlGridData( + show: true, + drawVerticalLine: true, + getDrawingHorizontalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + getDrawingVerticalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + axisNameWidget: PlatformText( + '%', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 34, + minIncluded: false, + maxIncluded: false, + getTitlesWidget: (value, meta) { + final isBoundaryTick = (value + _maxAbsLevel).abs() < 1e-6 || + (value - _maxAbsLevel).abs() < 1e-6; + if (isBoundaryTick) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + space: 6, + child: SizedBox( + width: 30, + child: Text( + _formatYAxisTick(value), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + axisNameSize: 0, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20, + interval: 1, + minIncluded: true, + maxIncluded: true, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + _formatXAxisTick(value), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + left: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + bottom: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + ), + ), + lineBarsData: bars, + ); - // Calculate how many bars can fit in the available width - final maxBars = (size.width / spacing).floor(); - final startIndex = - waveformData.length > maxBars ? waveformData.length - maxBars : 0; + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 2, 2, 0), + child: LineChart( + chartData, + duration: const Duration(milliseconds: 0), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + Expanded( + child: FutureBuilder>( + future: microphoneSourcesFuture, + builder: (context, snapshot) { + final sources = snapshot.data ?? const []; + if (sources.isEmpty) { + return const SizedBox.shrink(); + } + final minChipWidth = constraints.maxWidth > 70 + ? constraints.maxWidth - 70 + : 0.0; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: minChipWidth, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: sources + .map( + (source) => Padding( + padding: const EdgeInsets.only(right: 6), + child: _AudioMicrophoneSourceChip( + label: source.label, + ), + ), + ) + .toList(growable: false), + ), + ), + ), + ); + }, + ), + ), + const SizedBox(width: 8), + Text( + 'Time (s)', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ], + ); + } + + List _buildBars(Color color) { + if (waveformData.isEmpty) { + return const []; + } - // Calculate starting position (always start at 0 or align right) + final startIndex = waveformData.length > _maxSamples + ? waveformData.length - _maxSamples + : 0; final visibleData = waveformData.sublist(startIndex); - final totalWaveformWidth = visibleData.length * spacing; - final startX = size.width - totalWaveformWidth; - - // Draw each amplitude value as a vertical bar - for (int i = 0; i < visibleData.length; i++) { - final x = startX + (i * spacing); - final amplitude = visibleData[i]; - - // Scale amplitude to fit within the canvas height - final barHeight = amplitude * centerY * 0.8; - - // Draw top half of the bar (above center line) - final topY = centerY - barHeight; - final bottomY = centerY + barHeight; - - // Draw the vertical line from top to bottom - canvas.drawLine( - Offset(x, topY), - Offset(x, bottomY), - paint, + + return visibleData.asMap().entries.map((entry) { + final samplesFromNewest = visibleData.length - 1 - entry.key; + final x = -samplesFromNewest * _sampleIntervalSeconds; + final level = (entry.value * 100).clamp(0.0, _maxAbsLevel).toDouble(); + return LineChartBarData( + spots: [ + FlSpot(x, -level), + FlSpot(x, level), + ], + isCurved: false, + barWidth: 3, + color: color, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), ); + }).toList(growable: false); + } + + String _formatXAxisTick(double value) { + final rounded = value.roundToDouble(); + if ((value - rounded).abs() < 0.05) { + return rounded.toInt().toString(); } + return value.toStringAsFixed(1); } + String _formatYAxisTick(double value) { + final abs = value.abs(); + if (abs >= 100) { + return value.toStringAsFixed(0); + } + if (abs >= 1) { + return value.toStringAsFixed(1).replaceFirst(RegExp(r'\.0$'), ''); + } + return '0'; + } +} + +class _AudioMicrophoneSourceChip extends StatelessWidget { + final String label; + + const _AudioMicrophoneSourceChip({ + required this.label, + }); + @override - bool shouldRepaint(covariant WaveformPainter oldDelegate) { - return oldDelegate.sampleRevision != sampleRevision || - oldDelegate.waveformData.length != waveformData.length || - oldDelegate.waveColor != waveColor; + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final axisColor = colorScheme.primary; + + return IgnorePointer( + child: FilterChip( + label: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: axisColor.withValues(alpha: 0.95), + fontWeight: FontWeight.w700, + fontSize: 10.5, + ), + ), + avatar: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: axisColor, + shape: BoxShape.circle, + ), + ), + selected: true, + onSelected: (_) {}, + showCheckmark: false, + visualDensity: const VisualDensity( + horizontal: -3, + vertical: -3, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 4), + selectedColor: axisColor.withValues(alpha: 0.18), + backgroundColor: axisColor.withValues(alpha: 0.18), + side: BorderSide( + color: axisColor.withValues(alpha: 0.28), + ), + ), + ); } } From 43d34e1fe7471e35a436d3b3d510cf48665bc088 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 26 May 2026 11:29:23 +0200 Subject: [PATCH 26/34] refactor(sensor_values_page): extracted system microphone chart into it's own file --- .../sensors/values/sensor_values_page.dart | 803 ++---------------- .../values/system_microphone_audio_chart.dart | 760 +++++++++++++++++ 2 files changed, 825 insertions(+), 738 deletions(-) create mode 100644 open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart diff --git a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart index 8987062e..4f1b9a07 100644 --- a/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart +++ b/open_wearable/lib/widgets/sensors/values/sensor_values_page.dart @@ -1,18 +1,13 @@ -import 'dart:io'; - -import 'package:fl_chart/fl_chart.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; import 'package:open_wearable/models/app_shutdown_settings.dart'; -import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_data_provider.dart'; -import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; import 'package:open_wearable/widgets/sensors/values/sensor_value_card.dart'; +import 'package:open_wearable/widgets/sensors/values/system_microphone_audio_chart.dart'; import 'package:provider/provider.dart'; class SensorValuesPage extends StatefulWidget { @@ -29,13 +24,9 @@ class SensorValuesPage extends StatefulWidget { class _SensorValuesPageState extends State with AutomaticKeepAliveClientMixin { - static const Duration _audioMicrophoneSourcesRefreshInterval = - Duration(seconds: 5); - final Map<(Wearable, Sensor), SensorDataProvider> _ownedProviders = {}; - Future>? _audioMicrophoneSourcesFuture; - String? _audioMicrophoneSourcesCacheKey; - DateTime? _audioMicrophoneSourcesLastRefresh; + Future>? _wearableGroupsFuture; + String? _wearableGroupsCacheKey; Map<(Wearable, Sensor), SensorDataProvider> get _sensorDataProvider => widget.sharedProviders ?? _ownedProviders; @@ -68,17 +59,10 @@ class _SensorValuesPageState extends State builder: (context, hideCardsWithoutLiveData, __) { final shouldHideCardsWithoutLiveData = hideCardsWithoutLiveData && !disableLiveDataGraphs; - return Consumer2( - builder: (context, wearablesProvider, recorderProvider, child) { + return Consumer( + builder: (context, wearablesProvider, child) { return FutureBuilder>( - future: buildWearableDisplayGroups( - wearablesProvider.wearables, - shouldCombinePair: (left, right) => - wearablesProvider.isStereoPairCombined( - first: left, - second: right, - ), - ), + future: _wearableGroupsFutureFor(wearablesProvider), builder: (context, snapshot) { final groups = orderWearableGroupsByNameAndSide( snapshot.data ?? @@ -92,16 +76,6 @@ class _SensorValuesPageState extends State ); final orderedWearables = _orderedWearablesFromGroups(groups); - final audioHeaderInfo = _resolveAudioHeaderInfo( - groups: groups, - recorderProvider: recorderProvider, - ); - final audioMicrophoneSourcesFuture = - _audioMicrophoneSourcesFutureFor( - groups, - microphoneConfigurationRevision: - recorderProvider.microphoneConfigurationRevision, - ); _ensureProviders(orderedWearables); _cleanupProviders(orderedWearables); @@ -119,10 +93,7 @@ class _SensorValuesPageState extends State return _buildSmallScreenLayout( context, charts, - recorderProvider, - audioHeaderInfo: audioHeaderInfo, - audioMicrophoneSourcesFuture: - audioMicrophoneSourcesFuture, + groups, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -131,10 +102,7 @@ class _SensorValuesPageState extends State return _buildLargeScreenLayout( context, charts, - recorderProvider, - audioHeaderInfo: audioHeaderInfo, - audioMicrophoneSourcesFuture: - audioMicrophoneSourcesFuture, + groups, hasAnySensors: hasAnySensors, hideCardsWithoutLiveData: shouldHideCardsWithoutLiveData, @@ -165,6 +133,57 @@ class _SensorValuesPageState extends State ); } + /// Returns a stable stereo-grouping future until connected devices or + /// combine preferences change. + Future> _wearableGroupsFutureFor( + WearablesProvider wearablesProvider, + ) { + final wearables = wearablesProvider.wearables; + final cacheKey = _wearableGroupsKey(wearablesProvider); + + if (_wearableGroupsFuture == null || _wearableGroupsCacheKey != cacheKey) { + _wearableGroupsCacheKey = cacheKey; + _wearableGroupsFuture = buildWearableDisplayGroups( + wearables, + shouldCombinePair: (left, right) => + wearablesProvider.isStereoPairCombined( + first: left, + second: right, + ), + ); + } + + return _wearableGroupsFuture!; + } + + /// Builds the cache key for wearable grouping inputs that affect UI shape. + String _wearableGroupsKey(WearablesProvider wearablesProvider) { + final wearables = wearablesProvider.wearables; + final deviceParts = wearables + .map((wearable) => '${wearable.deviceId}:${wearable.name}') + .join('|'); + final combineParts = []; + + for (var i = 0; i < wearables.length; i++) { + for (var j = i + 1; j < wearables.length; j++) { + final first = wearables[i]; + final second = wearables[j]; + final pairKey = WearableDisplayGroup.stereoPairKeyForDevices( + first, + second, + ); + final combined = wearablesProvider.isStereoPairCombined( + first: first, + second: second, + ); + combineParts.add('$pairKey:$combined'); + } + } + combineParts.sort(); + + return '$deviceParts#${combineParts.join('|')}'; + } + void _ensureProviders(List orderedWearables) { for (final wearable in orderedWearables) { if (!wearable.hasCapability()) { @@ -292,348 +311,10 @@ class _SensorValuesPageState extends State return ordered; } - _AudioHeaderInfo _resolveAudioHeaderInfo({ - required List groups, - required SensorRecorderProvider recorderProvider, - }) { - final stereoBadgeLabel = _resolveAudioStereoBadgeLabel(groups); - final selectedInputLabel = recorderProvider.selectedBLEDeviceLabel; - final selectedInputName = - _formatAudioDeviceName(selectedInputLabel, allowGeneric: false); - if (selectedInputName != null) { - return _AudioHeaderInfo( - deviceName: selectedInputName, - stereoBadgeLabel: stereoBadgeLabel, - ); - } - - if (groups.isEmpty) { - return _AudioHeaderInfo( - deviceName: null, - stereoBadgeLabel: stereoBadgeLabel, - ); - } - - WearableDisplayGroup? combinedGroup; - for (final group in groups) { - if (group.isCombined) { - combinedGroup = group; - break; - } - } - final displayName = combinedGroup?.displayName ?? groups.first.displayName; - return _AudioHeaderInfo( - deviceName: _formatAudioDeviceName(displayName, allowGeneric: true), - stereoBadgeLabel: stereoBadgeLabel, - ); - } - - String? _resolveAudioStereoBadgeLabel(List groups) { - if (groups.any((group) => group.isCombined)) { - return 'L+R'; - } - - final sidesByPairKey = >{}; - DevicePosition? singleKnownSide; - for (final group in groups) { - if (group.primaryPosition != null) { - singleKnownSide ??= group.primaryPosition; - } - final pairKey = group.stereoPairKey; - final position = group.primaryPosition; - if (pairKey == null || position == null) { - continue; - } - sidesByPairKey.putIfAbsent(pairKey, () => {}).add( - position, - ); - } - - final hasConnectedPair = sidesByPairKey.values.any( - (positions) => - positions.contains(DevicePosition.left) && - positions.contains(DevicePosition.right), - ); - if (hasConnectedPair) { - return 'L+R'; - } - - return switch (singleKnownSide) { - DevicePosition.left => 'L', - DevicePosition.right => 'R', - _ => null, - }; - } - - String? _formatAudioDeviceName( - String? rawName, { - required bool allowGeneric, - }) { - final trimmed = rawName?.trim(); - if (trimmed == null || trimmed.isEmpty) { - return null; - } - - final lower = trimmed.toLowerCase(); - if (!allowGeneric && - (lower == 'bluetooth' || - lower.contains('bluetooth sco') || - lower.contains('default'))) { - return null; - } - - final formatted = formatWearableDisplayName(trimmed); - final withoutSideSuffix = formatted - .replaceFirst( - RegExp(r'\s*\((left|right|l|r)\)$', caseSensitive: false), - '', - ) - .replaceFirst( - RegExp(r'[\s_-]+(left|right|l|r)$', caseSensitive: false), - '', - ) - .trim(); - - return withoutSideSuffix.isEmpty ? formatted : withoutSideSuffix; - } - - Future> _resolveAudioMicrophoneSources( - List groups, - ) async { - final futures = >[]; - final seenDeviceIds = {}; - - void addCandidate(Wearable? wearable, DevicePosition? position) { - if (wearable == null || !seenDeviceIds.add(wearable.deviceId)) { - return; - } - futures.add( - _resolveAudioMicrophoneSource( - wearable: wearable, - position: position, - ), - ); - } - - for (final group in groups) { - final leftDevice = group.leftDevice; - final rightDevice = group.rightDevice; - addCandidate(leftDevice, DevicePosition.left); - addCandidate(rightDevice, DevicePosition.right); - if (leftDevice == null && rightDevice == null) { - addCandidate(group.primary, group.primaryPosition); - } - } - - final resolvedSources = await Future.wait(futures); - final sources = resolvedSources - .whereType<_AudioMicrophoneSourceInfo>() - .toList(growable: false); - return sources..sort(_compareAudioMicrophoneSources); - } - - Future> _audioMicrophoneSourcesFutureFor( - List groups, { - required int microphoneConfigurationRevision, - }) { - final cacheKey = - '${_audioMicrophoneSourcesKey(groups)}#$microphoneConfigurationRevision'; - final now = DateTime.now(); - final lastRefresh = _audioMicrophoneSourcesLastRefresh; - final cacheExpired = lastRefresh == null || - now.difference(lastRefresh) > _audioMicrophoneSourcesRefreshInterval; - - if (_audioMicrophoneSourcesFuture == null || - _audioMicrophoneSourcesCacheKey != cacheKey || - cacheExpired) { - _audioMicrophoneSourcesCacheKey = cacheKey; - _audioMicrophoneSourcesLastRefresh = now; - _audioMicrophoneSourcesFuture = _resolveAudioMicrophoneSources(groups); - } - - return _audioMicrophoneSourcesFuture!; - } - - String _audioMicrophoneSourcesKey(List groups) { - final parts = []; - for (final group in groups) { - final leftDevice = group.leftDevice; - final rightDevice = group.rightDevice; - if (leftDevice != null) { - parts.add('${leftDevice.deviceId}:left'); - } - if (rightDevice != null) { - parts.add('${rightDevice.deviceId}:right'); - } - if (leftDevice == null && rightDevice == null) { - parts.add( - '${group.primary.deviceId}:${group.primaryPosition?.name ?? 'unknown'}', - ); - } - } - return parts.join('|'); - } - - Future<_AudioMicrophoneSourceInfo?> _resolveAudioMicrophoneSource({ - required Wearable wearable, - required DevicePosition? position, - }) async { - if (!wearable.hasCapability()) { - return null; - } - - final resolvedPosition = - position ?? await _readAudioMicrophoneSourcePosition(wearable); - final sideLabel = _audioMicrophoneSourceSideLabel(resolvedPosition); - if (sideLabel == null) { - return null; - } - - try { - final microphone = - await wearable.requireCapability().getMicrophone(); - final microphoneLabel = _audioMicrophoneSourceLabel(microphone); - if (microphoneLabel == null) { - return null; - } - return _AudioMicrophoneSourceInfo( - sideLabel: sideLabel, - microphoneLabel: microphoneLabel, - ); - } catch (_) { - return null; - } - } - - Future _readAudioMicrophoneSourcePosition( - Wearable wearable, - ) async { - if (!wearable.hasCapability()) { - return null; - } - try { - return await wearable.requireCapability().position; - } catch (_) { - return null; - } - } - - int _compareAudioMicrophoneSources( - _AudioMicrophoneSourceInfo a, - _AudioMicrophoneSourceInfo b, - ) { - return _audioMicrophoneSourceSortRank(a.sideLabel) - .compareTo(_audioMicrophoneSourceSortRank(b.sideLabel)); - } - - int _audioMicrophoneSourceSortRank(String sideLabel) { - return switch (sideLabel) { - 'L' => 0, - 'R' => 1, - _ => 2, - }; - } - - String? _audioMicrophoneSourceSideLabel(DevicePosition? position) { - return switch (position) { - DevicePosition.left => 'L', - DevicePosition.right => 'R', - _ => null, - }; - } - - String? _audioMicrophoneSourceLabel(Microphone microphone) { - final normalized = microphone.key.toLowerCase().replaceAll( - RegExp(r'[^a-z0-9]'), - '', - ); - if (normalized.contains('inner') || normalized.contains('internal')) { - return 'Inner'; - } - if (normalized.contains('outer') || normalized.contains('external')) { - return 'Outer'; - } - return null; - } - - Widget _buildAudioUI( - SensorRecorderProvider recorderProvider, { - required _AudioHeaderInfo audioHeaderInfo, - required Future> - audioMicrophoneSourcesFuture, - }) { - final hasHeaderMeta = audioHeaderInfo.deviceName != null || - audioHeaderInfo.stereoBadgeLabel != null; - - return Card( - child: Padding( - padding: const EdgeInsets.all(10.0), - child: Column( - children: [ - Row( - children: [ - Expanded( - child: PlatformText( - 'SYSTEM MICROPHONE', - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodyLarge?.copyWith( - fontWeight: FontWeight.w700, - ), - ), - ), - if (hasHeaderMeta) ...[ - const SizedBox(width: 8), - Flexible( - child: Row( - mainAxisSize: MainAxisSize.min, - mainAxisAlignment: MainAxisAlignment.end, - children: [ - if (audioHeaderInfo.deviceName != null) - Flexible( - child: PlatformText( - audioHeaderInfo.deviceName!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.right, - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - if (audioHeaderInfo.stereoBadgeLabel != null) ...[ - const SizedBox(width: 8), - _AudioStereoBadge( - label: audioHeaderInfo.stereoBadgeLabel!, - ), - ], - ], - ), - ), - ], - ], - ), - const SizedBox(height: 10), - SizedBox( - height: 200, - child: _AudioLevelChart( - waveformData: recorderProvider.waveformData, - microphoneSourcesFuture: audioMicrophoneSourcesFuture, - ), - ), - ], - ), - ), - ); - } - - bool _shouldShowSystemMicrophoneChart() => !kIsWeb && Platform.isAndroid; - Widget _buildSmallScreenLayout( BuildContext context, List charts, - SensorRecorderProvider recorderProvider, { - required _AudioHeaderInfo audioHeaderInfo, - required Future> - audioMicrophoneSourcesFuture, + List groups, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { @@ -651,12 +332,8 @@ class _SensorValuesPageState extends State ), ), ), - if (_shouldShowSystemMicrophoneChart()) - _buildAudioUI( - recorderProvider, - audioHeaderInfo: audioHeaderInfo, - audioMicrophoneSourcesFuture: audioMicrophoneSourcesFuture, - ), + if (SystemMicrophoneAudioChart.isSupported) + SystemMicrophoneAudioChart(groups: groups), ], ); } @@ -664,10 +341,7 @@ class _SensorValuesPageState extends State Widget _buildLargeScreenLayout( BuildContext context, List charts, - SensorRecorderProvider recorderProvider, { - required _AudioHeaderInfo audioHeaderInfo, - required Future> - audioMicrophoneSourcesFuture, + List groups, { required bool hasAnySensors, required bool hideCardsWithoutLiveData, }) { @@ -682,12 +356,8 @@ class _SensorValuesPageState extends State ) else ...charts, - if (_shouldShowSystemMicrophoneChart()) - _buildAudioUI( - recorderProvider, - audioHeaderInfo: audioHeaderInfo, - audioMicrophoneSourcesFuture: audioMicrophoneSourcesFuture, - ), + if (SystemMicrophoneAudioChart.isSupported) + SystemMicrophoneAudioChart(groups: groups), ]; return SingleChildScrollView( @@ -825,346 +495,3 @@ class _SensorValuesEmptyState { this.removeCardBackground = false, }); } - -class _AudioHeaderInfo { - final String? deviceName; - final String? stereoBadgeLabel; - - const _AudioHeaderInfo({ - required this.deviceName, - required this.stereoBadgeLabel, - }); -} - -class _AudioMicrophoneSourceInfo { - final String sideLabel; - final String microphoneLabel; - - const _AudioMicrophoneSourceInfo({ - required this.sideLabel, - required this.microphoneLabel, - }); - - String get label => '$microphoneLabel ($sideLabel)'; -} - -class _AudioStereoBadge extends StatelessWidget { - final String label; - - const _AudioStereoBadge({ - required this.label, - }); - - @override - Widget build(BuildContext context) { - final colorScheme = Theme.of(context).colorScheme; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: colorScheme.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: colorScheme.primary.withValues(alpha: 0.24), - ), - ), - child: Text( - label, - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: colorScheme.primary, - fontWeight: FontWeight.w700, - ), - ), - ); - } -} - -class _AudioLevelChart extends StatelessWidget { - static const double _sampleIntervalSeconds = 0.1; - static const double _windowSeconds = 5; - static const int _maxSamples = 51; - static const double _maxAbsLevel = 100; - - final List waveformData; - final Future> microphoneSourcesFuture; - - const _AudioLevelChart({ - required this.waveformData, - required this.microphoneSourcesFuture, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final bars = _buildBars(colorScheme.primary); - - final chartData = LineChartData( - minX: -_windowSeconds, - maxX: 0, - minY: -_maxAbsLevel, - maxY: _maxAbsLevel, - lineTouchData: const LineTouchData( - enabled: false, - handleBuiltInTouches: false, - ), - gridData: FlGridData( - show: true, - drawVerticalLine: true, - getDrawingHorizontalLine: (_) => FlLine( - color: colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 1, - ), - getDrawingVerticalLine: (_) => FlLine( - color: colorScheme.outline.withValues(alpha: 0.2), - strokeWidth: 1, - ), - ), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - axisNameWidget: PlatformText( - '%', - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - axisNameSize: 16, - sideTitles: SideTitles( - showTitles: true, - reservedSize: 34, - minIncluded: false, - maxIncluded: false, - getTitlesWidget: (value, meta) { - final isBoundaryTick = (value + _maxAbsLevel).abs() < 1e-6 || - (value - _maxAbsLevel).abs() < 1e-6; - if (isBoundaryTick) { - return const SizedBox.shrink(); - } - return SideTitleWidget( - meta: meta, - space: 6, - child: SizedBox( - width: 30, - child: Text( - _formatYAxisTick(value), - maxLines: 1, - softWrap: false, - overflow: TextOverflow.fade, - textAlign: TextAlign.right, - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ); - }, - ), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - bottomTitles: AxisTitles( - axisNameSize: 0, - sideTitles: SideTitles( - showTitles: true, - reservedSize: 20, - interval: 1, - minIncluded: true, - maxIncluded: true, - getTitlesWidget: (value, meta) => SideTitleWidget( - meta: meta, - child: Text( - _formatXAxisTick(value), - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - ), - ), - ), - ), - ), - ), - borderData: FlBorderData( - show: true, - border: Border( - left: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.28), - ), - bottom: BorderSide( - color: colorScheme.outline.withValues(alpha: 0.28), - ), - ), - ), - lineBarsData: bars, - ); - - return Column( - children: [ - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(2, 2, 2, 0), - child: LineChart( - chartData, - duration: const Duration(milliseconds: 0), - ), - ), - ), - Padding( - padding: const EdgeInsets.only(top: 6), - child: LayoutBuilder( - builder: (context, constraints) => Row( - children: [ - Expanded( - child: FutureBuilder>( - future: microphoneSourcesFuture, - builder: (context, snapshot) { - final sources = snapshot.data ?? const []; - if (sources.isEmpty) { - return const SizedBox.shrink(); - } - final minChipWidth = constraints.maxWidth > 70 - ? constraints.maxWidth - 70 - : 0.0; - return SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: minChipWidth, - ), - child: Align( - alignment: Alignment.centerLeft, - child: Row( - mainAxisSize: MainAxisSize.min, - children: sources - .map( - (source) => Padding( - padding: const EdgeInsets.only(right: 6), - child: _AudioMicrophoneSourceChip( - label: source.label, - ), - ), - ) - .toList(growable: false), - ), - ), - ), - ); - }, - ), - ), - const SizedBox(width: 8), - Text( - 'Time (s)', - style: theme.textTheme.labelSmall?.copyWith( - color: colorScheme.onSurfaceVariant, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - ], - ); - } - - List _buildBars(Color color) { - if (waveformData.isEmpty) { - return const []; - } - - final startIndex = waveformData.length > _maxSamples - ? waveformData.length - _maxSamples - : 0; - final visibleData = waveformData.sublist(startIndex); - - return visibleData.asMap().entries.map((entry) { - final samplesFromNewest = visibleData.length - 1 - entry.key; - final x = -samplesFromNewest * _sampleIntervalSeconds; - final level = (entry.value * 100).clamp(0.0, _maxAbsLevel).toDouble(); - return LineChartBarData( - spots: [ - FlSpot(x, -level), - FlSpot(x, level), - ], - isCurved: false, - barWidth: 3, - color: color, - isStrokeCapRound: true, - dotData: const FlDotData(show: false), - belowBarData: BarAreaData(show: false), - ); - }).toList(growable: false); - } - - String _formatXAxisTick(double value) { - final rounded = value.roundToDouble(); - if ((value - rounded).abs() < 0.05) { - return rounded.toInt().toString(); - } - return value.toStringAsFixed(1); - } - - String _formatYAxisTick(double value) { - final abs = value.abs(); - if (abs >= 100) { - return value.toStringAsFixed(0); - } - if (abs >= 1) { - return value.toStringAsFixed(1).replaceFirst(RegExp(r'\.0$'), ''); - } - return '0'; - } -} - -class _AudioMicrophoneSourceChip extends StatelessWidget { - final String label; - - const _AudioMicrophoneSourceChip({ - required this.label, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - final colorScheme = theme.colorScheme; - final axisColor = colorScheme.primary; - - return IgnorePointer( - child: FilterChip( - label: Text( - label, - style: theme.textTheme.labelMedium?.copyWith( - color: axisColor.withValues(alpha: 0.95), - fontWeight: FontWeight.w700, - fontSize: 10.5, - ), - ), - avatar: Container( - width: 7, - height: 7, - decoration: BoxDecoration( - color: axisColor, - shape: BoxShape.circle, - ), - ), - selected: true, - onSelected: (_) {}, - showCheckmark: false, - visualDensity: const VisualDensity( - horizontal: -3, - vertical: -3, - ), - materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, - labelPadding: const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.symmetric(horizontal: 4), - selectedColor: axisColor.withValues(alpha: 0.18), - backgroundColor: axisColor.withValues(alpha: 0.18), - side: BorderSide( - color: axisColor.withValues(alpha: 0.28), - ), - ), - ); - } -} diff --git a/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart new file mode 100644 index 00000000..2e50355d --- /dev/null +++ b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart @@ -0,0 +1,760 @@ +import 'dart:io'; + +import 'package:fl_chart/fl_chart.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/device_name_formatter.dart'; +import 'package:open_wearable/models/wearable_display_group.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; +import 'package:provider/provider.dart'; + +/// Displays the Android system microphone level and source metadata. +/// +/// The widget keeps recorder-driven rebuilds local to the audio card so +/// frequent amplitude samples do not rebuild the surrounding sensor page. +class SystemMicrophoneAudioChart extends StatefulWidget { + final List groups; + + const SystemMicrophoneAudioChart({ + super.key, + required this.groups, + }); + + /// Whether the current platform can render the system microphone chart. + static bool get isSupported => !kIsWeb && Platform.isAndroid; + + @override + State createState() => + _SystemMicrophoneAudioChartState(); +} + +class _SystemMicrophoneAudioChartState + extends State { + static const Duration _microphoneSourcesRefreshInterval = + Duration(seconds: 5); + + Future>? _microphoneSourcesFuture; + String? _microphoneSourcesCacheKey; + DateTime? _microphoneSourcesLastRefresh; + + @override + Widget build(BuildContext context) { + return Selector( + selector: (context, recorderProvider) => _AudioRecorderMetadata( + selectedInputLabel: recorderProvider.selectedBLEDeviceLabel, + microphoneConfigurationRevision: + recorderProvider.microphoneConfigurationRevision, + ), + builder: (context, metadata, child) { + final audioHeaderInfo = _resolveHeaderInfo( + groups: widget.groups, + selectedInputLabel: metadata.selectedInputLabel, + ); + final microphoneSourcesFuture = _microphoneSourcesFutureFor( + widget.groups, + microphoneConfigurationRevision: + metadata.microphoneConfigurationRevision, + ); + final hasHeaderMeta = audioHeaderInfo.deviceName != null || + audioHeaderInfo.stereoBadgeLabel != null; + + return Card( + child: Padding( + padding: const EdgeInsets.all(10.0), + child: Column( + children: [ + Row( + children: [ + Expanded( + child: PlatformText( + 'SYSTEM MICROPHONE', + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + ), + if (hasHeaderMeta) ...[ + const SizedBox(width: 8), + Flexible( + child: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + if (audioHeaderInfo.deviceName != null) + Flexible( + child: PlatformText( + audioHeaderInfo.deviceName!, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.right, + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + if (audioHeaderInfo.stereoBadgeLabel != null) ...[ + const SizedBox(width: 8), + _AudioStereoBadge( + label: audioHeaderInfo.stereoBadgeLabel!, + ), + ], + ], + ), + ), + ], + ], + ), + const SizedBox(height: 10), + SizedBox( + height: 200, + child: Selector>( + selector: (context, recorderProvider) => + recorderProvider.waveformData, + builder: (context, waveformData, child) => _AudioLevelChart( + waveformData: waveformData, + microphoneSourcesFuture: microphoneSourcesFuture, + ), + ), + ), + ], + ), + ), + ); + }, + ); + } + + _AudioHeaderInfo _resolveHeaderInfo({ + required List groups, + required String? selectedInputLabel, + }) { + final stereoBadgeLabel = _resolveStereoBadgeLabel(groups); + final selectedInputName = + _formatAudioDeviceName(selectedInputLabel, allowGeneric: false); + if (selectedInputName != null) { + return _AudioHeaderInfo( + deviceName: selectedInputName, + stereoBadgeLabel: stereoBadgeLabel, + ); + } + + if (groups.isEmpty) { + return _AudioHeaderInfo( + deviceName: null, + stereoBadgeLabel: stereoBadgeLabel, + ); + } + + WearableDisplayGroup? combinedGroup; + for (final group in groups) { + if (group.isCombined) { + combinedGroup = group; + break; + } + } + final displayName = combinedGroup?.displayName ?? groups.first.displayName; + return _AudioHeaderInfo( + deviceName: _formatAudioDeviceName(displayName, allowGeneric: true), + stereoBadgeLabel: stereoBadgeLabel, + ); + } + + String? _resolveStereoBadgeLabel(List groups) { + if (groups.any((group) => group.isCombined)) { + return 'L+R'; + } + + final sidesByPairKey = >{}; + DevicePosition? singleKnownSide; + for (final group in groups) { + if (group.primaryPosition != null) { + singleKnownSide ??= group.primaryPosition; + } + final pairKey = group.stereoPairKey; + final position = group.primaryPosition; + if (pairKey == null || position == null) { + continue; + } + sidesByPairKey.putIfAbsent(pairKey, () => {}).add( + position, + ); + } + + final hasConnectedPair = sidesByPairKey.values.any( + (positions) => + positions.contains(DevicePosition.left) && + positions.contains(DevicePosition.right), + ); + if (hasConnectedPair) { + return 'L+R'; + } + + return switch (singleKnownSide) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + } + + String? _formatAudioDeviceName( + String? rawName, { + required bool allowGeneric, + }) { + final trimmed = rawName?.trim(); + if (trimmed == null || trimmed.isEmpty) { + return null; + } + + final lower = trimmed.toLowerCase(); + if (!allowGeneric && + (lower == 'bluetooth' || + lower.contains('bluetooth sco') || + lower.contains('default'))) { + return null; + } + + final formatted = formatWearableDisplayName(trimmed); + final withoutSideSuffix = formatted + .replaceFirst( + RegExp(r'\s*\((left|right|l|r)\)$', caseSensitive: false), + '', + ) + .replaceFirst( + RegExp(r'[\s_-]+(left|right|l|r)$', caseSensitive: false), + '', + ) + .trim(); + + return withoutSideSuffix.isEmpty ? formatted : withoutSideSuffix; + } + + Future> _resolveMicrophoneSources( + List groups, + ) async { + final futures = >[]; + final seenDeviceIds = {}; + + void addCandidate(Wearable? wearable, DevicePosition? position) { + if (wearable == null || !seenDeviceIds.add(wearable.deviceId)) { + return; + } + futures.add( + _resolveMicrophoneSource( + wearable: wearable, + position: position, + ), + ); + } + + for (final group in groups) { + final leftDevice = group.leftDevice; + final rightDevice = group.rightDevice; + addCandidate(leftDevice, DevicePosition.left); + addCandidate(rightDevice, DevicePosition.right); + if (leftDevice == null && rightDevice == null) { + addCandidate(group.primary, group.primaryPosition); + } + } + + final resolvedSources = await Future.wait(futures); + final sources = resolvedSources + .whereType<_AudioMicrophoneSourceInfo>() + .toList(growable: false); + return sources..sort(_compareMicrophoneSources); + } + + /// Returns a cached source lookup future for stable group and mic settings. + Future> _microphoneSourcesFutureFor( + List groups, { + required int microphoneConfigurationRevision, + }) { + final cacheKey = + '${_microphoneSourcesKey(groups)}#$microphoneConfigurationRevision'; + final now = DateTime.now(); + final lastRefresh = _microphoneSourcesLastRefresh; + final cacheExpired = lastRefresh == null || + now.difference(lastRefresh) > _microphoneSourcesRefreshInterval; + + if (_microphoneSourcesFuture == null || + _microphoneSourcesCacheKey != cacheKey || + cacheExpired) { + _microphoneSourcesCacheKey = cacheKey; + _microphoneSourcesLastRefresh = now; + _microphoneSourcesFuture = _resolveMicrophoneSources(groups); + } + + return _microphoneSourcesFuture!; + } + + String _microphoneSourcesKey(List groups) { + final parts = []; + for (final group in groups) { + final leftDevice = group.leftDevice; + final rightDevice = group.rightDevice; + if (leftDevice != null) { + parts.add('${leftDevice.deviceId}:left'); + } + if (rightDevice != null) { + parts.add('${rightDevice.deviceId}:right'); + } + if (leftDevice == null && rightDevice == null) { + parts.add( + '${group.primary.deviceId}:${group.primaryPosition?.name ?? 'unknown'}', + ); + } + } + return parts.join('|'); + } + + Future<_AudioMicrophoneSourceInfo?> _resolveMicrophoneSource({ + required Wearable wearable, + required DevicePosition? position, + }) async { + if (!wearable.hasCapability()) { + return null; + } + + final resolvedPosition = + position ?? await _readMicrophoneSourcePosition(wearable); + final sideLabel = _microphoneSourceSideLabel(resolvedPosition); + if (sideLabel == null) { + return null; + } + + try { + final microphone = + await wearable.requireCapability().getMicrophone(); + final microphoneLabel = _microphoneSourceLabel(microphone); + if (microphoneLabel == null) { + return null; + } + return _AudioMicrophoneSourceInfo( + sideLabel: sideLabel, + microphoneLabel: microphoneLabel, + ); + } catch (_) { + return null; + } + } + + Future _readMicrophoneSourcePosition( + Wearable wearable, + ) async { + if (!wearable.hasCapability()) { + return null; + } + try { + return await wearable.requireCapability().position; + } catch (_) { + return null; + } + } + + int _compareMicrophoneSources( + _AudioMicrophoneSourceInfo a, + _AudioMicrophoneSourceInfo b, + ) { + return _microphoneSourceSortRank(a.sideLabel) + .compareTo(_microphoneSourceSortRank(b.sideLabel)); + } + + int _microphoneSourceSortRank(String sideLabel) { + return switch (sideLabel) { + 'L' => 0, + 'R' => 1, + _ => 2, + }; + } + + String? _microphoneSourceSideLabel(DevicePosition? position) { + return switch (position) { + DevicePosition.left => 'L', + DevicePosition.right => 'R', + _ => null, + }; + } + + String? _microphoneSourceLabel(Microphone microphone) { + final normalized = microphone.key.toLowerCase().replaceAll( + RegExp(r'[^a-z0-9]'), + '', + ); + if (normalized.contains('inner') || normalized.contains('internal')) { + return 'Inner'; + } + if (normalized.contains('outer') || normalized.contains('external')) { + return 'Outer'; + } + return null; + } +} + +class _AudioHeaderInfo { + final String? deviceName; + final String? stereoBadgeLabel; + + const _AudioHeaderInfo({ + required this.deviceName, + required this.stereoBadgeLabel, + }); +} + +/// Recorder values that affect audio metadata, excluding waveform samples. +class _AudioRecorderMetadata { + final String? selectedInputLabel; + final int microphoneConfigurationRevision; + + const _AudioRecorderMetadata({ + required this.selectedInputLabel, + required this.microphoneConfigurationRevision, + }); + + @override + bool operator ==(Object other) { + return other is _AudioRecorderMetadata && + other.selectedInputLabel == selectedInputLabel && + other.microphoneConfigurationRevision == + microphoneConfigurationRevision; + } + + @override + int get hashCode => Object.hash( + selectedInputLabel, + microphoneConfigurationRevision, + ); +} + +class _AudioMicrophoneSourceInfo { + final String sideLabel; + final String microphoneLabel; + + const _AudioMicrophoneSourceInfo({ + required this.sideLabel, + required this.microphoneLabel, + }); + + String get label => '$microphoneLabel ($sideLabel)'; +} + +class _AudioStereoBadge extends StatelessWidget { + final String label; + + const _AudioStereoBadge({ + required this.label, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration( + color: colorScheme.primary.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: colorScheme.primary.withValues(alpha: 0.24), + ), + ), + child: Text( + label, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.primary, + fontWeight: FontWeight.w700, + ), + ), + ); + } +} + +class _AudioLevelChart extends StatelessWidget { + static const double _sampleIntervalSeconds = 0.1; + static const double _windowSeconds = 5; + static const int _maxSamples = 51; + static const double _maxAbsLevel = 100; + + final List waveformData; + final Future> microphoneSourcesFuture; + + const _AudioLevelChart({ + required this.waveformData, + required this.microphoneSourcesFuture, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final bars = _buildBars(colorScheme.primary); + + final chartData = LineChartData( + minX: -_windowSeconds, + maxX: 0, + minY: -_maxAbsLevel, + maxY: _maxAbsLevel, + lineTouchData: const LineTouchData( + enabled: false, + handleBuiltInTouches: false, + ), + gridData: FlGridData( + show: true, + drawVerticalLine: true, + getDrawingHorizontalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + getDrawingVerticalLine: (_) => FlLine( + color: colorScheme.outline.withValues(alpha: 0.2), + strokeWidth: 1, + ), + ), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + axisNameWidget: PlatformText( + '%', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + axisNameSize: 16, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 34, + minIncluded: false, + maxIncluded: false, + getTitlesWidget: (value, meta) { + final isBoundaryTick = (value + _maxAbsLevel).abs() < 1e-6 || + (value - _maxAbsLevel).abs() < 1e-6; + if (isBoundaryTick) { + return const SizedBox.shrink(); + } + return SideTitleWidget( + meta: meta, + space: 6, + child: SizedBox( + width: 30, + child: Text( + _formatYAxisTick(value), + maxLines: 1, + softWrap: false, + overflow: TextOverflow.fade, + textAlign: TextAlign.right, + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ); + }, + ), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + bottomTitles: AxisTitles( + axisNameSize: 0, + sideTitles: SideTitles( + showTitles: true, + reservedSize: 20, + interval: 1, + minIncluded: true, + maxIncluded: true, + getTitlesWidget: (value, meta) => SideTitleWidget( + meta: meta, + child: Text( + _formatXAxisTick(value), + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + ), + ), + ), + ), + borderData: FlBorderData( + show: true, + border: Border( + left: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + bottom: BorderSide( + color: colorScheme.outline.withValues(alpha: 0.28), + ), + ), + ), + lineBarsData: bars, + ); + + return Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 2, 2, 0), + child: LineChart( + chartData, + duration: const Duration(milliseconds: 0), + ), + ), + ), + Padding( + padding: const EdgeInsets.only(top: 6), + child: LayoutBuilder( + builder: (context, constraints) => Row( + children: [ + Expanded( + child: FutureBuilder>( + future: microphoneSourcesFuture, + builder: (context, snapshot) { + final sources = snapshot.data ?? const []; + if (sources.isEmpty) { + return const SizedBox.shrink(); + } + final minChipWidth = constraints.maxWidth > 70 + ? constraints.maxWidth - 70 + : 0.0; + return SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: minChipWidth, + ), + child: Align( + alignment: Alignment.centerLeft, + child: Row( + mainAxisSize: MainAxisSize.min, + children: sources + .map( + (source) => Padding( + padding: const EdgeInsets.only(right: 6), + child: _AudioMicrophoneSourceChip( + label: source.label, + ), + ), + ) + .toList(growable: false), + ), + ), + ), + ); + }, + ), + ), + const SizedBox(width: 8), + Text( + 'Time (s)', + style: theme.textTheme.labelSmall?.copyWith( + color: colorScheme.onSurfaceVariant, + fontWeight: FontWeight.w700, + ), + ), + ], + ), + ), + ), + ], + ); + } + + List _buildBars(Color color) { + if (waveformData.isEmpty) { + return const []; + } + + final startIndex = waveformData.length > _maxSamples + ? waveformData.length - _maxSamples + : 0; + final visibleData = waveformData.sublist(startIndex); + + return visibleData.asMap().entries.map((entry) { + final samplesFromNewest = visibleData.length - 1 - entry.key; + final x = -samplesFromNewest * _sampleIntervalSeconds; + final level = (entry.value * 100).clamp(0.0, _maxAbsLevel).toDouble(); + return LineChartBarData( + spots: [ + FlSpot(x, -level), + FlSpot(x, level), + ], + isCurved: false, + barWidth: 3, + color: color, + isStrokeCapRound: true, + dotData: const FlDotData(show: false), + belowBarData: BarAreaData(show: false), + ); + }).toList(growable: false); + } + + String _formatXAxisTick(double value) { + final rounded = value.roundToDouble(); + if ((value - rounded).abs() < 0.05) { + return rounded.toInt().toString(); + } + return value.toStringAsFixed(1); + } + + String _formatYAxisTick(double value) { + final abs = value.abs(); + if (abs >= 100) { + return value.toStringAsFixed(0); + } + if (abs >= 1) { + return value.toStringAsFixed(1).replaceFirst(RegExp(r'\.0$'), ''); + } + return '0'; + } +} + +class _AudioMicrophoneSourceChip extends StatelessWidget { + final String label; + + const _AudioMicrophoneSourceChip({ + required this.label, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final colorScheme = theme.colorScheme; + final axisColor = colorScheme.primary; + + return IgnorePointer( + child: FilterChip( + label: Text( + label, + style: theme.textTheme.labelMedium?.copyWith( + color: axisColor.withValues(alpha: 0.95), + fontWeight: FontWeight.w700, + fontSize: 10.5, + ), + ), + avatar: Container( + width: 7, + height: 7, + decoration: BoxDecoration( + color: axisColor, + shape: BoxShape.circle, + ), + ), + selected: true, + onSelected: (_) {}, + showCheckmark: false, + visualDensity: const VisualDensity( + horizontal: -3, + vertical: -3, + ), + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + labelPadding: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 4), + selectedColor: axisColor.withValues(alpha: 0.18), + backgroundColor: axisColor.withValues(alpha: 0.18), + side: BorderSide( + color: axisColor.withValues(alpha: 0.28), + ), + ), + ); + } +} From ad02706ebdf2a40451e09f82dfb93c9a2f3946d8 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Thu, 21 May 2026 15:12:20 +0200 Subject: [PATCH 27/34] feat(audio): add selectable microphone recording source --- .../lib/models/audio_input_source.dart | 84 ++++ .../sensor_recorder_provider_io.dart | 290 ++++++++--- .../sensor_recorder_provider_web.dart | 53 +- .../ble_microphone_streaming_row.dart | 55 --- .../microphone_configuration_card.dart | 452 ++++++++++++++++++ .../sensor_configuration_view.dart | 73 ++- .../local_recorder/recording_controls.dart | 2 +- .../values/system_microphone_audio_chart.dart | 2 +- .../test/models/audio_input_source_test.dart | 37 ++ 9 files changed, 870 insertions(+), 178 deletions(-) create mode 100644 open_wearable/lib/models/audio_input_source.dart delete mode 100644 open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart create mode 100644 open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart create mode 100644 open_wearable/test/models/audio_input_source_test.dart diff --git a/open_wearable/lib/models/audio_input_source.dart b/open_wearable/lib/models/audio_input_source.dart new file mode 100644 index 00000000..c35fef1c --- /dev/null +++ b/open_wearable/lib/models/audio_input_source.dart @@ -0,0 +1,84 @@ +/// Describes an audio input that can be used for local audio recordings. +/// +/// The app owns this model instead of exposing the recorder plugin's device +/// type through the UI. That keeps persisted selections and widgets isolated +/// from platform plugin implementation details. +class AudioInputSource { + /// Stable identifier used by the platform recorder to select this source. + final String id; + + /// Human-readable label surfaced by the platform. + final String label; + + /// Coarse category used for icons and explanatory UI. + final AudioInputSourceKind kind; + + /// Whether this option delegates source selection to the operating system. + final bool isSystemDefault; + + const AudioInputSource({ + required this.id, + required this.label, + required this.kind, + this.isSystemDefault = false, + }); + + /// The synthetic source that lets the OS pick its current default input. + static const systemDefault = AudioInputSource( + id: '__system_default_audio_input__', + label: 'System Default', + kind: AudioInputSourceKind.systemDefault, + isSystemDefault: true, + ); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + return other is AudioInputSource && + other.id == id && + other.label == label && + other.kind == kind && + other.isSystemDefault == isSystemDefault; + } + + @override + int get hashCode => Object.hash(id, label, kind, isSystemDefault); +} + +/// Coarse audio input categories used to keep UI copy and icons consistent. +enum AudioInputSourceKind { + systemDefault, + builtIn, + bluetooth, + wearable, + external, + unknown, +} + +/// Classifies a platform microphone label for display purposes. +AudioInputSourceKind classifyAudioInputSourceLabel(String label) { + final normalized = label.toLowerCase(); + if (normalized.contains('openearable') || + normalized.contains('open earable') || + normalized.contains('wearable')) { + return AudioInputSourceKind.wearable; + } + if (normalized.contains('bluetooth') || + normalized.contains('ble') || + normalized.contains('headset') || + normalized.contains('airpods')) { + return AudioInputSourceKind.bluetooth; + } + if (normalized.contains('built-in') || + normalized.contains('builtin') || + RegExp(r'\bphone\b').hasMatch(normalized) || + normalized.contains('internal')) { + return AudioInputSourceKind.builtIn; + } + if (normalized.contains('usb') || + normalized.contains('external') || + normalized.contains('line in')) { + return AudioInputSourceKind.external; + } + return AudioInputSourceKind.unknown; +} diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart index 411ae815..62d6fb51 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -6,6 +6,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:path_provider/path_provider.dart'; import 'package:record/record.dart'; +import '../models/audio_input_source.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; @@ -38,31 +39,44 @@ class SensorRecorderProvider with ChangeNotifier { String? _currentDirectory; DateTime? _recordingStart; final AudioRecorder _audioRecorder = AudioRecorder(); + static const Duration _audioInputRefreshInterval = Duration(seconds: 3); bool _isAudioRecording = false; String? _currentAudioPath; StreamSubscription? _amplitudeSub; + Timer? _audioInputRefreshTimer; + List _availableInputDevices = const []; + List _audioInputSources = const [ + AudioInputSource.systemDefault, + ]; + AudioInputSource? _selectedAudioInputSource; + AudioInputSource? _appliedAudioInputSource; bool get isRecording => _isRecording; bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; + List get audioInputSources => + List.unmodifiable(_audioInputSources); + AudioInputSource? get selectedAudioInputSource => _selectedAudioInputSource; + AudioInputSource? get appliedAudioInputSource => _appliedAudioInputSource; + bool get isAudioInputEnabled => + _appliedAudioInputSource != null || + _isStreamingActive || + _isAudioRecording; + bool get isAudioMonitoringActive => _isStreamingActive; + bool get isAudioInputSelectionPending => !_sameAudioInputSource( + _selectedAudioInputSource, + _appliedAudioInputSource, + ); final List _waveformData = []; final int _waveformRevision = 0; int get waveformRevision => _waveformRevision; List get waveformData => List.unmodifiable(_waveformData); - InputDevice? _selectedBLEDevice; - - /// Label for the currently selected BLE microphone input, when available. - String? get selectedBLEDeviceLabel => _selectedBLEDevice?.label; - int _microphoneConfigurationRevision = 0; int get microphoneConfigurationRevision => _microphoneConfigurationRevision; - bool _isBLEMicrophoneStreamingEnabled = false; - bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; - void notifyMicrophoneConfigurationChanged() { _bumpMicrophoneConfigurationRevision(); Future.delayed(_microphoneConfigurationSettleDelay, () { @@ -82,50 +96,200 @@ class SensorRecorderProvider with ChangeNotifier { String? _streamingPath; bool _isStreamingActive = false; - Future _selectBLEDevice() async { + /// Starts periodic microphone discovery while microphone settings UI exists. + void startAudioInputSourceRefresh() { + if (_audioInputRefreshTimer != null) { + return; + } + unawaited(refreshAudioInputSources()); + _audioInputRefreshTimer = Timer.periodic( + _audioInputRefreshInterval, + (_) => unawaited(refreshAudioInputSources()), + ); + } + + /// Stops periodic microphone discovery when no UI needs it. + void stopAudioInputSourceRefresh() { + _audioInputRefreshTimer?.cancel(); + _audioInputRefreshTimer = null; + } + + /// Refreshes the platform microphone list used by the virtual microphone row. + Future refreshAudioInputSources() async { try { final devices = await _audioRecorder.listInputDevices(); - - try { - _selectedBLEDevice = devices.firstWhere( - (device) => - device.label.toLowerCase().contains('bluetooth') || - device.label.toLowerCase().contains('ble') || - device.label.toLowerCase().contains('headset') || - device.label.toLowerCase().contains('openearable'), - ); - logger.i("Selected audio input device: ${_selectedBLEDevice!.label}"); - } catch (e) { - _selectedBLEDevice = null; - logger.w("No BLE headset found"); + final uniqueDevices = []; + final seenDeviceIds = {}; + for (final device in devices) { + if (seenDeviceIds.add(device.id)) { + uniqueDevices.add(device); + } + } + final nextSources = [ + AudioInputSource.systemDefault, + ...uniqueDevices.map( + (device) => AudioInputSource( + id: device.id, + label: device.label, + kind: classifyAudioInputSourceLabel(device.label), + ), + ), + ]; + if (!_sameInputDevices(_availableInputDevices, uniqueDevices) || + !_sameAudioInputSources(_audioInputSources, nextSources)) { + _availableInputDevices = uniqueDevices; + _audioInputSources = nextSources; + notifyListeners(); } } catch (e) { - logger.e("Error selecting BLE device: $e"); - _selectedBLEDevice = null; + logger.e("Error listing audio input devices: $e"); + } + } + + /// Selects the app-local microphone source used by local recordings. + /// + /// Passing `null` turns audio capture off while leaving wearable sensor + /// configuration untouched. + Future selectAudioInputSource(AudioInputSource? source) async { + if (_isAudioRecording) { + logger.w("Cannot change audio input while recording is active"); + return; + } + + if (_sameAudioInputSource(_selectedAudioInputSource, source)) { + return; + } + + _selectedAudioInputSource = source; + notifyListeners(); + } + + /// Enables or disables audio capture without changing the remembered source. + Future setAudioInputEnabled(bool enabled) async { + if (enabled) { + _selectedAudioInputSource ??= AudioInputSource.systemDefault; + await refreshAudioInputSources(); + } else { + await selectAudioInputSource(null); + } + notifyListeners(); + } + + InputDevice? _inputDeviceForSource(AudioInputSource source) { + if (source.isSystemDefault) { + return null; + } + for (final device in _availableInputDevices) { + if (device.id == source.id) { + return device; + } + } + return null; + } + + bool _sameAudioInputSource( + AudioInputSource? left, + AudioInputSource? right, + ) { + if (left == null || right == null) { + return left == null && right == null; } + return left.id == right.id; } - Future startBLEMicrophoneStream() async { - if (!kIsWeb && !Platform.isAndroid) { - logger.w("BLE microphone streaming only supported on Android"); + bool _sameInputDevices(List left, List right) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (left[i].id != right[i].id || left[i].label != right[i].label) { + return false; + } + } + return true; + } + + bool _sameAudioInputSources( + List left, + List right, + ) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (left[i] != right[i]) { + return false; + } + } + return true; + } + + Future startAudioMonitoring() async { + return _startAudioMonitoring(); + } + + Future stopAudioMonitoring() async { + await _stopAudioMonitoring(); + } + + /// Applies the pending microphone selection to the live monitoring stream. + /// + /// Selecting a source in the sensor configuration tab only changes local + /// pending state. Calling this method mirrors the wearable profile apply + /// flow by starting or stopping the actual microphone stream. + Future applySelectedAudioInputSource() async { + if (_isAudioRecording) { + logger.w("Cannot apply audio input while recording is active"); + return false; + } + if (!isAudioInputSelectionPending) { return false; } + final selectedSource = _selectedAudioInputSource; + if (selectedSource == null) { + await stopAudioMonitoring(); + _appliedAudioInputSource = null; + _waveformData.clear(); + notifyListeners(); + return true; + } + + if (_isStreamingActive) { + await stopAudioMonitoring(); + } + _appliedAudioInputSource = selectedSource; + final started = await startAudioMonitoring(); + if (started) { + notifyListeners(); + } else { + _appliedAudioInputSource = null; + notifyListeners(); + } + return started; + } + + Future _startAudioMonitoring() async { if (_isStreamingActive) { - logger.i("BLE microphone streaming already active"); + logger.i("Audio input monitoring already active"); return true; } try { + final source = _appliedAudioInputSource; + if (source == null) { + logger.w("No audio input selected for monitoring"); + return false; + } if (!await _audioRecorder.hasPermission()) { - logger.w("No microphone permission for streaming"); + logger.w("No microphone permission for monitoring"); return false; } - await _selectBLEDevice(); - - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, cannot start streaming"); + await refreshAudioInputSources(); + final selectedDevice = _inputDeviceForSource(source); + if (!source.isSystemDefault && selectedDevice == null) { + logger.w("Selected audio input is unavailable: ${source.label}"); return false; } @@ -144,12 +308,11 @@ class SensorRecorderProvider with ChangeNotifier { sampleRate: 48000, bitRate: 768000, numChannels: 1, - device: _selectedBLEDevice, + device: selectedDevice, ); await _audioRecorder.start(config, path: _streamingPath!); _isStreamingActive = true; - _isBLEMicrophoneStreamingEnabled = true; // Set up amplitude monitoring for waveform display _amplitudeSub?.cancel(); @@ -167,21 +330,20 @@ class SensorRecorderProvider with ChangeNotifier { }); logger.i( - "BLE microphone streaming started with device: ${_selectedBLEDevice!.label}", + "Audio monitoring started with input: ${source.label}", ); notifyListeners(); return true; } catch (e) { - logger.e("Failed to start BLE microphone streaming: $e"); + logger.e("Failed to start audio monitoring: $e"); _isStreamingActive = false; - _isBLEMicrophoneStreamingEnabled = false; _streamingPath = null; notifyListeners(); return false; } } - Future stopBLEMicrophoneStream() async { + Future _stopAudioMonitoring() async { if (!_isStreamingActive) { return; } @@ -191,7 +353,6 @@ class SensorRecorderProvider with ChangeNotifier { _amplitudeSub?.cancel(); _amplitudeSub = null; _isStreamingActive = false; - _isBLEMicrophoneStreamingEnabled = false; _waveformData.clear(); // Clean up temporary streaming file @@ -207,10 +368,10 @@ class SensorRecorderProvider with ChangeNotifier { _streamingPath = null; } - logger.i("BLE microphone streaming stopped"); + logger.i("Audio monitoring stopped"); notifyListeners(); } catch (e) { - logger.e("Error stopping BLE microphone streaming: $e"); + logger.e("Error stopping audio monitoring: $e"); } } @@ -239,20 +400,14 @@ class SensorRecorderProvider with ChangeNotifier { rethrow; } - await _startAudioRecording( - dirname, - ); + await _startAudioRecording(dirname); notifyListeners(); } Future _startAudioRecording(String recordingFolderPath) async { - if (!kIsWeb && !Platform.isAndroid) return; - - // Only start recording if BLE microphone streaming is enabled - if (!_isBLEMicrophoneStreamingEnabled) { - logger - .w("BLE microphone streaming not enabled, skipping audio recording"); + final source = _appliedAudioInputSource; + if (source == null) { return; } @@ -283,10 +438,12 @@ class SensorRecorderProvider with ChangeNotifier { return; } - await _selectBLEDevice(); - - if (_selectedBLEDevice == null) { - logger.w("No BLE headset detected, skipping audio recording"); + await refreshAudioInputSources(); + final selectedDevice = _inputDeviceForSource(source); + if (!source.isSystemDefault && selectedDevice == null) { + logger.w( + "Selected audio input is unavailable, skipping audio recording: ${source.label}", + ); return; } @@ -304,7 +461,7 @@ class SensorRecorderProvider with ChangeNotifier { sampleRate: 48000, // Set to 48kHz for BLE audio quality bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps numChannels: 1, - device: _selectedBLEDevice, + device: selectedDevice, ); await _audioRecorder.start(config, path: audioPath); @@ -312,7 +469,7 @@ class SensorRecorderProvider with ChangeNotifier { _isAudioRecording = true; logger.i( - "Audio recording started: $_currentAudioPath with device: ${_selectedBLEDevice?.label ?? 'default'}", + "Audio recording started: $_currentAudioPath with input: ${source.label}", ); _amplitudeSub = _audioRecorder @@ -352,11 +509,16 @@ class SensorRecorderProvider with ChangeNotifier { logger.e("Error stopping audio recording: $e"); } - // Restart streaming if it was enabled before recording - if (!turnOffMic && - _isBLEMicrophoneStreamingEnabled && - !_isStreamingActive) { - unawaited(startBLEMicrophoneStream()); + if (turnOffMic) { + unawaited(() async { + await selectAudioInputSource(null); + await stopAudioMonitoring(); + _appliedAudioInputSource = null; + _waveformData.clear(); + notifyListeners(); + }()); + } else if (_selectedAudioInputSource != null) { + unawaited(applySelectedAudioInputSource()); } notifyListeners(); @@ -596,8 +758,8 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { _disposed = true; - // Stop streaming - stopBLEMicrophoneStream(); + stopAudioInputSourceRefresh(); + stopAudioMonitoring(); // Stop recording _audioRecorder.stop().then((_) { diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart index 81278aaf..42102cda 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart @@ -6,6 +6,7 @@ import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage_web.dart'; +import '../models/audio_input_source.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; @@ -22,37 +23,63 @@ class SensorRecorderProvider with ChangeNotifier { bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; + List get audioInputSources => const []; + AudioInputSource? get selectedAudioInputSource => null; + AudioInputSource? get appliedAudioInputSource => null; + bool get isAudioInputEnabled => false; + bool get isAudioMonitoringActive => false; + bool get isAudioInputSelectionPending => false; final List _waveformData = []; final int _waveformRevision = 0; int get waveformRevision => _waveformRevision; List get waveformData => List.unmodifiable(_waveformData); - /// Web does not expose a selected BLE microphone input. - String? get selectedBLEDeviceLabel => null; - int _microphoneConfigurationRevision = 0; int get microphoneConfigurationRevision => _microphoneConfigurationRevision; - bool _isBLEMicrophoneStreamingEnabled = false; - bool get isBLEMicrophoneStreamingEnabled => _isBLEMicrophoneStreamingEnabled; - void notifyMicrophoneConfigurationChanged() { _microphoneConfigurationRevision++; notifyListeners(); } - Future startBLEMicrophoneStream() async { - logger.w('BLE microphone streaming is not supported on web.'); - return false; + Future refreshAudioInputSources() async { + logger.w('Audio input selection is not supported on web yet.'); } - Future stopBLEMicrophoneStream() async { - _isBLEMicrophoneStreamingEnabled = false; + void startAudioInputSourceRefresh() {} + + void stopAudioInputSourceRefresh() {} + + Future selectAudioInputSource(AudioInputSource? source) async { + if (source != null) { + logger.w('Audio input recording is not supported on web yet.'); + } _waveformData.clear(); notifyListeners(); } + Future setAudioInputEnabled(bool enabled) async { + if (enabled) { + logger.w('Audio input recording is not supported on web yet.'); + } + await selectAudioInputSource(null); + } + + Future startAudioMonitoring() async { + logger.w('Audio input monitoring is not supported on web yet.'); + return false; + } + + Future stopAudioMonitoring() async { + await selectAudioInputSource(null); + } + + Future applySelectedAudioInputSource() async { + logger.w('Audio input monitoring is not supported on web yet.'); + return false; + } + Future startRecording(String dirname) async { if (_isRecording) { return; @@ -118,10 +145,6 @@ class SensorRecorderProvider with ChangeNotifier { _currentDirectory = null; - if (!turnOffMic && _isBLEMicrophoneStreamingEnabled) { - unawaited(startBLEMicrophoneStream()); - } - notifyListeners(); } diff --git a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart b/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart deleted file mode 100644 index 13dfb3e1..00000000 --- a/open_wearable/lib/widgets/sensors/configuration/ble_microphone_streaming_row.dart +++ /dev/null @@ -1,55 +0,0 @@ -import 'dart:io'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; -import 'package:provider/provider.dart'; -import '../../../view_models/sensor_recorder_provider_facade.dart'; - -class BLEMicrophoneStreamingRow extends StatelessWidget { - const BLEMicrophoneStreamingRow({super.key}); - - @override - Widget build(BuildContext context) { - if (kIsWeb || !Platform.isAndroid) { - return const SizedBox.shrink(); - } - - return Consumer( - builder: (context, recorderProvider, child) { - final isStreamingEnabled = - recorderProvider.isBLEMicrophoneStreamingEnabled; - - return PlatformListTile( - title: PlatformText('BLE Microphone Streaming'), - subtitle: PlatformText( - isStreamingEnabled - ? 'Microphone stream is active' - : 'Enable to start microphone streaming', - ), - trailing: PlatformSwitch( - value: isStreamingEnabled, - onChanged: (value) async { - if (value) { - final success = - await recorderProvider.startBLEMicrophoneStream(); - if (!success && context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: PlatformText( - 'Failed to start BLE microphone streaming. ' - 'Make sure a BLE headset is connected and microphone permission is granted.', - ), - backgroundColor: Colors.red, - ), - ); - } - } else { - await recorderProvider.stopBLEMicrophoneStream(); - } - }, - ), - ); - }, - ); - } -} diff --git a/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart new file mode 100644 index 00000000..7b73d5df --- /dev/null +++ b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart @@ -0,0 +1,452 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/audio_input_source.dart'; +import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; +import 'package:provider/provider.dart'; + +/// Displays app-local microphone recording as a virtual sensor configuration. +/// +/// The card intentionally mirrors wearable sensor configuration rows while +/// writing only to [SensorRecorderProvider]. It does not apply settings to a +/// physical wearable. +class MicrophoneConfigurationCard extends StatefulWidget { + const MicrophoneConfigurationCard({super.key}); + + @override + State createState() => + _MicrophoneConfigurationCardState(); +} + +class _MicrophoneConfigurationCardState + extends State with WidgetsBindingObserver { + bool _refreshStarted = false; + SensorRecorderProvider? _recorderProvider; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + final provider = context.read(); + if (!identical(_recorderProvider, provider)) { + _recorderProvider?.stopAudioInputSourceRefresh(); + _recorderProvider = provider; + _refreshStarted = false; + } + if (_refreshStarted) { + return; + } + _refreshStarted = true; + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + _recorderProvider?.startAudioInputSourceRefresh(); + }); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + if (state == AppLifecycleState.resumed) { + _recorderProvider?.refreshAudioInputSources(); + } + } + + @override + void dispose() { + _recorderProvider?.stopAudioInputSourceRefresh(); + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, recorderProvider, _) { + final selectedSource = recorderProvider.selectedAudioInputSource; + final isPending = recorderProvider.isAudioInputSelectionPending; + final isApplied = !isPending && selectedSource != null; + final isOn = selectedSource != null || isPending; + final colorScheme = Theme.of(context).colorScheme; + const sensorOnGreen = Color(0xFF2E7D32); + final accentColor = isPending + ? colorScheme.primary + : (isApplied ? sensorOnGreen : colorScheme.outline); + + return Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), + child: Row( + children: [ + Expanded( + child: PlatformText( + 'Microphone', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + IconButton( + tooltip: 'Refresh audio inputs', + onPressed: () => + recorderProvider.refreshAudioInputSources(), + icon: const Icon(Icons.refresh_rounded, size: 20), + ), + ], + ), + ), + Padding( + padding: const EdgeInsets.fromLTRB(12, 2, 12, 2), + child: Material( + color: Colors.transparent, + child: InkWell( + borderRadius: BorderRadius.circular(8), + onTap: () => + _openMicrophoneSheet(context, recorderProvider), + child: Padding( + padding: const EdgeInsets.fromLTRB(2, 8, 2, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Container( + width: isOn ? 3 : 2, + height: 26, + decoration: BoxDecoration( + color: accentColor.withValues( + alpha: isOn ? 0.7 : 0.6, + ), + borderRadius: BorderRadius.circular(999), + ), + ), + const SizedBox(width: 6), + Icon( + _iconForSource(selectedSource), + size: 14, + color: isOn ? accentColor : colorScheme.outline, + ), + const SizedBox(width: 7), + Expanded( + child: Text( + 'Audio Input', + maxLines: 1, + softWrap: false, + overflow: TextOverflow.ellipsis, + style: Theme.of(context) + .textTheme + .bodyMedium + ?.copyWith( + color: isOn ? accentColor : null, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(width: 6), + _SourcePill( + label: selectedSource?.label ?? 'Off', + foreground: isOn + ? accentColor + : colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 2), + Icon( + Icons.chevron_right_rounded, + size: 16, + color: colorScheme.onSurfaceVariant, + ), + ], + ), + ), + ), + ), + ), + const SizedBox(height: 6), + ], + ), + ); + }, + ); + } + + void _openMicrophoneSheet( + BuildContext context, + SensorRecorderProvider recorderProvider, + ) { + showPlatformModalSheet( + context: context, + builder: (modalContext) { + return ChangeNotifierProvider.value( + value: recorderProvider, + child: SafeArea( + child: SizedBox( + height: MediaQuery.of(modalContext).size.height * 0.52, + child: Material( + color: Theme.of(modalContext).colorScheme.surface, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 12, 14, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Microphone', + style: Theme.of(modalContext) + .textTheme + .titleMedium + ?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 2), + Text( + 'Select the audio source recorded with local sessions.', + style: Theme.of(modalContext) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(modalContext) + .colorScheme + .onSurfaceVariant, + ), + ), + ], + ), + ), + const SizedBox(width: 8), + IconButton( + tooltip: 'Close', + onPressed: () => Navigator.of(modalContext).pop(), + icon: const Icon(Icons.close_rounded, size: 20), + ), + ], + ), + ), + const Expanded(child: _MicrophoneConfigurationDetail()), + ], + ), + ), + ), + ), + ); + }, + ); + } + + IconData _iconForSource(AudioInputSource? source) { + if (source == null) { + return Icons.mic_off_rounded; + } + return switch (source.kind) { + AudioInputSourceKind.systemDefault => Icons.settings_voice_rounded, + AudioInputSourceKind.builtIn => Icons.phone_android_rounded, + AudioInputSourceKind.bluetooth => Icons.bluetooth_audio_rounded, + AudioInputSourceKind.wearable => Icons.hearing_rounded, + AudioInputSourceKind.external => Icons.cable_rounded, + AudioInputSourceKind.unknown => Icons.mic_rounded, + }; + } +} + +class _MicrophoneConfigurationDetail extends StatelessWidget { + const _MicrophoneConfigurationDetail(); + + static const String _offSelectionKey = '__audio_input_off__'; + + @override + Widget build(BuildContext context) { + final recorderProvider = context.watch(); + final sources = recorderProvider.audioInputSources; + final selected = _resolveSelectedSource( + sources, + recorderProvider.selectedAudioInputSource, + ); + final colorScheme = Theme.of(context).colorScheme; + final dropdownValues = [ + ...sources, + if (selected != null && !sources.contains(selected)) selected, + ]; + final selectedKey = selected?.id ?? _offSelectionKey; + + return ListView( + padding: const EdgeInsets.fromLTRB(12, 10, 12, 12), + children: [ + Text( + 'Audio Source', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.w700, + ), + ), + const SizedBox(height: 3), + Text( + recorderProvider.isRecording + ? 'Audio source changes are locked while recording.' + : 'Choose a source, then apply profiles to start monitoring.', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: selectedKey, + isExpanded: true, + decoration: InputDecoration( + isDense: true, + filled: false, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.outlineVariant.withValues(alpha: 0.55), + ), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: colorScheme.primary.withValues(alpha: 0.6), + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 7, + ), + ), + items: [ + const DropdownMenuItem( + value: _offSelectionKey, + child: Text('Off'), + ), + ...dropdownValues.map( + (source) => DropdownMenuItem( + value: source.id, + child: Text(source.label), + ), + ), + ], + onChanged: recorderProvider.isRecording + ? null + : (key) async { + if (key == null || key == _offSelectionKey) { + await recorderProvider.selectAudioInputSource(null); + return; + } + await recorderProvider.selectAudioInputSource( + dropdownValues.firstWhere((source) => source.id == key), + ); + }, + ), + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: TextButton.icon( + onPressed: recorderProvider.refreshAudioInputSources, + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Refresh inputs'), + ), + ), + ], + ); + } + + AudioInputSource? _resolveSelectedSource( + List sources, + AudioInputSource? selected, + ) { + if (selected == null) { + return null; + } + for (final source in sources) { + if (source.id == selected.id) { + return source; + } + } + return selected; + } +} + +class _SourcePill extends StatelessWidget { + final String label; + final Color foreground; + + const _SourcePill({ + required this.label, + required this.foreground, + }); + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + + return SizedBox( + height: 22, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 7), + decoration: BoxDecoration( + color: colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: foreground.withValues(alpha: 0.42), + ), + ), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 150, minWidth: 38), + child: Text( + label, + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: foreground, + fontWeight: FontWeight.w700, + ), + ), + ), + ), + ); + } +} + +class _PendingStatePill extends StatelessWidget { + final Color accentColor; + + const _PendingStatePill({required this.accentColor}); + + @override + Widget build(BuildContext context) { + return SizedBox( + height: 22, + child: Container( + alignment: Alignment.center, + padding: const EdgeInsets.symmetric(horizontal: 8), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: BorderRadius.circular(999), + border: Border.all( + color: accentColor.withValues(alpha: 0.42), + ), + ), + child: Text( + 'Pending', + style: Theme.of(context).textTheme.labelMedium?.copyWith( + color: accentColor, + fontWeight: FontWeight.w700, + ), + ), + ), + ); + } +} diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index bc9fb59c..e013fcbc 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -8,6 +8,7 @@ import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:open_wearable/view_models/wearables_provider.dart'; import 'package:open_wearable/widgets/app_toast.dart'; import 'package:open_wearable/widgets/sensors/sensor_page_spacing.dart'; +import 'package:open_wearable/widgets/sensors/configuration/microphone_configuration_card.dart'; import 'package:open_wearable/widgets/sensors/configuration/sensor_configuration_device_row.dart'; import 'package:provider/provider.dart'; @@ -35,11 +36,18 @@ class SensorConfigurationView extends StatelessWidget { WearablesProvider wearablesProvider, ) { if (wearablesProvider.wearables.isEmpty) { - return Center( - child: PlatformText( - "No devices connected", - style: Theme.of(context).textTheme.titleLarge, - ), + return ListView( + padding: SensorPageSpacing.pagePaddingWithBottomInset(context), + children: [ + const MicrophoneConfigurationCard(), + const SizedBox(height: 12), + _buildApplyConfigButton( + context, + targets: const <_ConfigApplyTarget>[], + ), + const SizedBox(height: 12), + const Center(child: Text('No devices connected')), + ], ); } @@ -67,6 +75,7 @@ class SensorConfigurationView extends StatelessWidget { wearablesProvider: wearablesProvider, ); final sections = [ + const MicrophoneConfigurationCard(), ...groups.map( (group) => _buildGroupConfigurationRow( group: group, @@ -254,30 +263,10 @@ class SensorConfigurationView extends StatelessWidget { BuildContext context, { required List<_ConfigApplyTarget> targets, }) async { - if (targets.isEmpty) { - await showPlatformDialog( - context: context, - builder: (dialogContext) => PlatformAlertDialog( - title: PlatformText('No configurable devices'), - content: PlatformText( - 'Connect a wearable with configurable sensors to apply settings.', - ), - actions: [ - PlatformDialogAction( - child: PlatformText('OK'), - onPressed: () => Navigator.of(dialogContext).pop(), - ), - ], - ), - ); - return; - } - + final recorderProvider = context.read(); + final audioApplied = await recorderProvider.applySelectedAudioInputSource(); int actionableCount = 0; - final recorderProvider = - Provider.of(context, listen: false); - bool shouldEnableMicrophoneStreaming = false; bool didApplyMicrophoneConfiguration = false; for (final target in targets) { @@ -294,11 +283,6 @@ class SensorConfigurationView extends StatelessWidget { final SensorConfigurationValue value = entry.$2; if (config.name.toLowerCase().contains('microphone')) { didApplyMicrophoneConfiguration = true; - final options = - target.provider.getSelectedConfigurationOptions(config); - if (options.any((opt) => opt is StreamSensorConfigOption)) { - shouldEnableMicrophoneStreaming = true; - } } // Always push the selected canonical value to the primary device on // apply. This also heals primary-side drift/unknown states. @@ -314,14 +298,6 @@ class SensorConfigurationView extends StatelessWidget { config.setConfiguration(value); } - if (shouldEnableMicrophoneStreaming && - !recorderProvider.isBLEMicrophoneStreamingEnabled) { - await recorderProvider.startBLEMicrophoneStream(); - } else if (!shouldEnableMicrophoneStreaming && - recorderProvider.isBLEMicrophoneStreamingEnabled) { - await recorderProvider.stopBLEMicrophoneStream(); - } - logger.d( "Applied ${primaryEntriesToApply.length} primary and ${mirroredEntriesToApply.length} mirrored sensor settings for ${target.primaryDevice.name}", ); @@ -335,7 +311,7 @@ class SensorConfigurationView extends StatelessWidget { return; } - if (actionableCount == 0) { + if (actionableCount == 0 && !audioApplied) { AppToast.show( context, message: 'No pending sensor settings to apply.', @@ -347,7 +323,10 @@ class SensorConfigurationView extends StatelessWidget { AppToast.show( context, - message: 'Sensor settings applied.', + message: actionConfigMessage( + appliedSensorSettings: actionableCount, + appliedAudioInput: audioApplied, + ), type: AppToastType.success, icon: Icons.check_circle_outline_rounded, ); @@ -355,6 +334,16 @@ class SensorConfigurationView extends StatelessWidget { (onSetConfigPressed ?? () {})(); } + String actionConfigMessage({ + required int appliedSensorSettings, + required bool appliedAudioInput, + }) { + if (appliedSensorSettings == 0 && appliedAudioInput) { + return 'Microphone setting applied.'; + } + return 'Sensor settings applied.'; + } + Widget _buildThroughputWarningBanner(BuildContext context) { final colorScheme = Theme.of(context).colorScheme; diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart index 56f403de..1b8134bf 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -61,7 +61,7 @@ class _RecordingControls extends State { final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); await Future.wait(futures); - await recorder.stopBLEMicrophoneStream(); + await recorder.selectAudioInputSource(null); } await widget.updateRecordingsList(); } catch (e) { diff --git a/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart index 2e50355d..924e7a3a 100644 --- a/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart +++ b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart @@ -43,7 +43,7 @@ class _SystemMicrophoneAudioChartState Widget build(BuildContext context) { return Selector( selector: (context, recorderProvider) => _AudioRecorderMetadata( - selectedInputLabel: recorderProvider.selectedBLEDeviceLabel, + selectedInputLabel: recorderProvider.appliedAudioInputSource?.label, microphoneConfigurationRevision: recorderProvider.microphoneConfigurationRevision, ), diff --git a/open_wearable/test/models/audio_input_source_test.dart b/open_wearable/test/models/audio_input_source_test.dart new file mode 100644 index 00000000..91bbabaf --- /dev/null +++ b/open_wearable/test/models/audio_input_source_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:open_wearable/models/audio_input_source.dart'; + +void main() { + group('AudioInputSource', () { + test('classifies common microphone labels for display', () { + expect( + classifyAudioInputSourceLabel('OpenEarable Ring Microphone'), + AudioInputSourceKind.wearable, + ); + expect( + classifyAudioInputSourceLabel('Bluetooth Headset'), + AudioInputSourceKind.bluetooth, + ); + expect( + classifyAudioInputSourceLabel('Built-in Phone Microphone'), + AudioInputSourceKind.builtIn, + ); + expect( + classifyAudioInputSourceLabel('USB Audio Interface'), + AudioInputSourceKind.external, + ); + expect( + classifyAudioInputSourceLabel('Studio Microphone'), + AudioInputSourceKind.unknown, + ); + }); + + test('represents the system default as an app-owned synthetic source', () { + expect(AudioInputSource.systemDefault.isSystemDefault, isTrue); + expect( + AudioInputSource.systemDefault.kind, + AudioInputSourceKind.systemDefault, + ); + }); + }); +} From fa31cd4c44a32e5ebc0e9d67de7c53f229f12b37 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 26 May 2026 10:34:57 +0200 Subject: [PATCH 28/34] chore(microphone_configuration_card): removed unused code --- .../microphone_configuration_card.dart | 31 ------------------- 1 file changed, 31 deletions(-) diff --git a/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart index 7b73d5df..ad3bdeff 100644 --- a/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart +++ b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart @@ -419,34 +419,3 @@ class _SourcePill extends StatelessWidget { ); } } - -class _PendingStatePill extends StatelessWidget { - final Color accentColor; - - const _PendingStatePill({required this.accentColor}); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 22, - child: Container( - alignment: Alignment.center, - padding: const EdgeInsets.symmetric(horizontal: 8), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surface, - borderRadius: BorderRadius.circular(999), - border: Border.all( - color: accentColor.withValues(alpha: 0.42), - ), - ), - child: Text( - 'Pending', - style: Theme.of(context).textTheme.labelMedium?.copyWith( - color: accentColor, - fontWeight: FontWeight.w700, - ), - ), - ), - ); - } -} From d80c06d4a8a2749a836ed0ba3b1ebcfb0b8795cd Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 26 May 2026 13:46:40 +0200 Subject: [PATCH 29/34] fix(audio): harden recorder monitoring lifecycle --- .../sensor_recorder_provider_io.dart | 160 ++++++++++-------- .../sensor_recorder_provider_web.dart | 5 +- .../local_recorder/local_recorder_view.dart | 11 +- .../local_recorder/recording_controls.dart | 9 +- 4 files changed, 104 insertions(+), 81 deletions(-) diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart index 62d6fb51..9c96702a 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -70,8 +70,6 @@ class SensorRecorderProvider with ChangeNotifier { ); final List _waveformData = []; - final int _waveformRevision = 0; - int get waveformRevision => _waveformRevision; List get waveformData => List.unmodifiable(_waveformData); int _microphoneConfigurationRevision = 0; @@ -89,7 +87,7 @@ class SensorRecorderProvider with ChangeNotifier { void _bumpMicrophoneConfigurationRevision() { _microphoneConfigurationRevision++; - notifyListeners(); + _notifyListenersIfActive(); } // Path for temporary streaming file @@ -139,7 +137,7 @@ class SensorRecorderProvider with ChangeNotifier { !_sameAudioInputSources(_audioInputSources, nextSources)) { _availableInputDevices = uniqueDevices; _audioInputSources = nextSources; - notifyListeners(); + _notifyListenersIfActive(); } } catch (e) { logger.e("Error listing audio input devices: $e"); @@ -161,7 +159,7 @@ class SensorRecorderProvider with ChangeNotifier { } _selectedAudioInputSource = source; - notifyListeners(); + _notifyListenersIfActive(); } /// Enables or disables audio capture without changing the remembered source. @@ -172,7 +170,7 @@ class SensorRecorderProvider with ChangeNotifier { } else { await selectAudioInputSource(null); } - notifyListeners(); + _notifyListenersIfActive(); } InputDevice? _inputDeviceForSource(AudioInputSource source) { @@ -248,23 +246,23 @@ class SensorRecorderProvider with ChangeNotifier { final selectedSource = _selectedAudioInputSource; if (selectedSource == null) { - await stopAudioMonitoring(); + await _stopAudioMonitoring(); _appliedAudioInputSource = null; _waveformData.clear(); - notifyListeners(); + _notifyListenersIfActive(); return true; } if (_isStreamingActive) { - await stopAudioMonitoring(); + await _stopAudioMonitoring(clearWaveform: false); } _appliedAudioInputSource = selectedSource; final started = await startAudioMonitoring(); if (started) { - notifyListeners(); + _notifyListenersIfActive(); } else { _appliedAudioInputSource = null; - notifyListeners(); + _notifyListenersIfActive(); } return started; } @@ -326,24 +324,28 @@ class SensorRecorderProvider with ChangeNotifier { _waveformData.removeAt(0); } - notifyListeners(); + _notifyListenersIfActive(); }); logger.i( "Audio monitoring started with input: ${source.label}", ); - notifyListeners(); + _notifyListenersIfActive(); return true; } catch (e) { logger.e("Failed to start audio monitoring: $e"); _isStreamingActive = false; _streamingPath = null; - notifyListeners(); + _notifyListenersIfActive(); return false; } } - Future _stopAudioMonitoring() async { + /// Stops the temporary monitoring session without changing the applied source. + Future _stopAudioMonitoring({ + bool clearWaveform = true, + bool notify = true, + }) async { if (!_isStreamingActive) { return; } @@ -353,23 +355,15 @@ class SensorRecorderProvider with ChangeNotifier { _amplitudeSub?.cancel(); _amplitudeSub = null; _isStreamingActive = false; - _waveformData.clear(); - - // Clean up temporary streaming file - if (_streamingPath != null) { - try { - final file = File(_streamingPath!); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Ignore cleanup errors - } - _streamingPath = null; + if (clearWaveform) { + _waveformData.clear(); } + await _deleteStreamingFile(); logger.i("Audio monitoring stopped"); - notifyListeners(); + if (notify) { + _notifyListenersIfActive(); + } } catch (e) { logger.e("Error stopping audio monitoring: $e"); } @@ -388,7 +382,7 @@ class SensorRecorderProvider with ChangeNotifier { await _startRecorderForWearable(wearable, dirname); } _isRecording = true; - notifyListeners(); + _notifyListenersIfActive(); } catch (e, st) { logger.e('Failed to start recording: $e\n$st'); _stopAllRecorderStreams(); @@ -396,13 +390,13 @@ class SensorRecorderProvider with ChangeNotifier { _currentDirectory = null; _recordingStart = null; _isRecording = false; - notifyListeners(); + _notifyListenersIfActive(); rethrow; } await _startAudioRecording(dirname); - notifyListeners(); + _notifyListenersIfActive(); } Future _startAudioRecording(String recordingFolderPath) async { @@ -411,30 +405,15 @@ class SensorRecorderProvider with ChangeNotifier { return; } - // Stop streaming session before starting actual recording - if (_isStreamingActive) { - await _audioRecorder.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - _isStreamingActive = false; - - // Clean up temporary streaming file - if (_streamingPath != null) { - try { - final file = File(_streamingPath!); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - // Ignore cleanup errors - } - _streamingPath = null; - } + final shouldRestoreMonitoring = _isStreamingActive; + if (shouldRestoreMonitoring) { + await _stopAudioMonitoring(clearWaveform: false, notify: false); } try { if (!await _audioRecorder.hasPermission()) { logger.w("No microphone permission for recording"); + await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); return; } @@ -444,12 +423,14 @@ class SensorRecorderProvider with ChangeNotifier { logger.w( "Selected audio input is unavailable, skipping audio recording: ${source.label}", ); + await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); return; } const encoder = AudioEncoder.wav; if (!await _audioRecorder.isEncoderSupported(encoder)) { logger.w("WAV encoder not supported"); + await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); return; } @@ -482,15 +463,17 @@ class SensorRecorderProvider with ChangeNotifier { _waveformData.removeAt(0); } - notifyListeners(); + _notifyListenersIfActive(); }); } catch (e) { logger.e("Failed to start audio recording: $e"); _isAudioRecording = false; + await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); } } - void stopRecording(bool turnOffMic) async { + /// Stops active wearable and audio recording streams and finalizes files. + Future stopRecording(bool turnOffMic) async { _isRecording = false; _recordingStart = null; _recordingFilepathsBySensorIdentity.clear(); @@ -510,18 +493,48 @@ class SensorRecorderProvider with ChangeNotifier { } if (turnOffMic) { - unawaited(() async { - await selectAudioInputSource(null); - await stopAudioMonitoring(); - _appliedAudioInputSource = null; - _waveformData.clear(); - notifyListeners(); - }()); - } else if (_selectedAudioInputSource != null) { - unawaited(applySelectedAudioInputSource()); + await selectAudioInputSource(null); + await _stopAudioMonitoring(); + _appliedAudioInputSource = null; + _waveformData.clear(); + _notifyListenersIfActive(); + } else if (_appliedAudioInputSource != null) { + await startAudioMonitoring(); } - notifyListeners(); + _notifyListenersIfActive(); + } + + /// Restarts monitoring when recording could not take over the audio input. + Future _restoreAudioMonitoringIfNeeded(bool shouldRestore) async { + if (!shouldRestore || _disposed || _appliedAudioInputSource == null) { + return; + } + await _startAudioMonitoring(); + } + + /// Deletes the temporary file used by the live monitoring recorder session. + Future _deleteStreamingFile() async { + final streamingPath = _streamingPath; + if (streamingPath == null) { + return; + } + _streamingPath = null; + try { + final file = File(streamingPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + logger.w("Failed to delete temporary audio monitoring file: $e"); + } + } + + /// Notifies listeners only while this provider is still mounted. + void _notifyListenersIfActive() { + if (!_disposed) { + notifyListeners(); + } } Recorder? getRecorder(Wearable wearable, Sensor sensor) { @@ -759,16 +772,21 @@ class SensorRecorderProvider with ChangeNotifier { void dispose() { _disposed = true; stopAudioInputSourceRefresh(); - stopAudioMonitoring(); - - // Stop recording - _audioRecorder.stop().then((_) { - _audioRecorder.dispose(); - }).catchError((e) { - logger.e("Error stopping audio in dispose: $e"); - }); _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isStreamingActive = false; + _isAudioRecording = false; _waveformData.clear(); + unawaited(() async { + try { + await _audioRecorder.stop(); + await _deleteStreamingFile(); + } catch (e) { + logger.e("Error stopping audio in dispose: $e"); + } finally { + await _audioRecorder.dispose(); + } + }()); for (final wearable in _recorders.keys.toList()) { _disposeWearable(wearable); } diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart index 42102cda..8f6f964f 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart @@ -31,8 +31,6 @@ class SensorRecorderProvider with ChangeNotifier { bool get isAudioInputSelectionPending => false; final List _waveformData = []; - final int _waveformRevision = 0; - int get waveformRevision => _waveformRevision; List get waveformData => List.unmodifiable(_waveformData); int _microphoneConfigurationRevision = 0; @@ -108,7 +106,8 @@ class SensorRecorderProvider with ChangeNotifier { notifyListeners(); } - void stopRecording(bool turnOffMic) async { + /// Stops active web recording sessions and persists their buffered data. + Future stopRecording(bool turnOffMic) async { if (!_isRecording) { return; } diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart index fe6e6644..b3a5765b 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_view.dart @@ -135,10 +135,15 @@ class _LocalRecorderViewState extends State { _isHandlingStopAction = true; }); + final wearablesProvider = mode == _StopRecordingMode.stopAndTurnOffSensors + ? context.read() + : null; + try { - recorder.stopRecording(mode == _StopRecordingMode.stopAndTurnOffSensors); - if (mode == _StopRecordingMode.stopAndTurnOffSensors) { - final wearablesProvider = context.read(); + await recorder.stopRecording( + mode == _StopRecordingMode.stopAndTurnOffSensors, + ); + if (wearablesProvider != null) { final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); await Future.wait(futures); diff --git a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart index 1b8134bf..176ca503 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/recording_controls.dart @@ -54,14 +54,15 @@ class _RecordingControls extends State { _isHandlingStopAction = true; }); + final wearablesProvider = + turnOffSensors ? context.read() : null; + try { - recorder.stopRecording(turnOffSensors); - if (turnOffSensors) { - final wearablesProvider = context.read(); + await recorder.stopRecording(turnOffSensors); + if (wearablesProvider != null) { final futures = wearablesProvider.sensorConfigurationProviders.values .map((provider) => provider.turnOffAllSensors()); await Future.wait(futures); - await recorder.selectAudioInputSource(null); } await widget.updateRecordingsList(); } catch (e) { From d69cb043111814a41f81eee22905fae095d70265 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 26 May 2026 15:46:59 +0200 Subject: [PATCH 30/34] feat(audio): support cross-platform microphone recording --- open_wearable/ios/Runner/Info.plist | 2 + .../view_models/audio_input_controller.dart | 336 +++++++++ .../sensor_recorder_provider_io.dart | 659 +++++++----------- .../sensor_recorder_provider_web.dart | 303 +++++++- .../local_recorder_file_actions_io.dart | 2 +- .../local_recorder/local_recorder_models.dart | 6 +- .../local_recorder_storage_io.dart | 25 +- .../local_recorder_storage_web.dart | 25 +- .../values/system_microphone_audio_chart.dart | 5 +- .../macos/Runner/DebugProfile.entitlements | 2 + open_wearable/macos/Runner/Info.plist | 2 + .../macos/Runner/Release.entitlements | 2 + .../audio_input_controller_test.dart | 93 +++ 13 files changed, 1000 insertions(+), 462 deletions(-) create mode 100644 open_wearable/lib/view_models/audio_input_controller.dart create mode 100644 open_wearable/test/view_models/audio_input_controller_test.dart diff --git a/open_wearable/ios/Runner/Info.plist b/open_wearable/ios/Runner/Info.plist index fede948b..3953846e 100644 --- a/open_wearable/ios/Runner/Info.plist +++ b/open_wearable/ios/Runner/Info.plist @@ -43,6 +43,8 @@ This app uses the local network to host a webserver for tools integration. NSMotionUsageDescription This app requires access to device motion in order to provide sensor data. + NSMicrophoneUsageDescription + This app records microphone audio with local sensor recording sessions. NSPhotoLibraryUsageDescription Needed for optional file selection functionality. UIApplicationSupportsIndirectInputEvents diff --git a/open_wearable/lib/view_models/audio_input_controller.dart b/open_wearable/lib/view_models/audio_input_controller.dart new file mode 100644 index 00000000..45b7671c --- /dev/null +++ b/open_wearable/lib/view_models/audio_input_controller.dart @@ -0,0 +1,336 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; + +import 'package:open_wearable/models/audio_input_source.dart'; +import 'package:open_wearable/models/logger.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; + +/// Platform-specific audio recorder operations used by [AudioInputController]. +/// +/// Implementations own the underlying recorder plugin/session details. The +/// controller owns user-facing selection state, apply semantics, monitoring +/// lifecycle, and waveform buffering. +abstract class AudioInputPlatform { + /// Lists available microphone inputs, including any synthetic default source. + Future> listAudioInputSources(); + + /// Starts live monitoring for [source]. + Future startMonitoring( + AudioInputSource source, + ValueChanged onLevel, + ); + + /// Stops live monitoring without changing selected or applied source state. + Future stopMonitoring(); + + /// Starts recording [source] into [recordingFolderPath]. + Future startRecording( + AudioInputSource source, + String recordingFolderPath, + ValueChanged onLevel, + ); + + /// Stops audio recording and returns any files that must be persisted by the + /// caller. IO platforms usually return an empty list because files are already + /// written directly to disk; web returns in-memory files. + Future> stopRecording(); + + /// Releases platform resources. + Future dispose(); +} + +/// Owns cross-platform microphone input state and lifecycle transitions. +/// +/// This class intentionally contains no `dart:io` or browser APIs. It keeps the +/// virtual microphone row, Apply Profiles behavior, live chart waveform, and +/// recording/monitoring handoff identical across platforms. +class AudioInputController extends ChangeNotifier { + static const Duration _sourceRefreshInterval = Duration(seconds: 3); + static const int _maxWaveformSamples = 100; + + final AudioInputPlatform _platform; + + Timer? _sourceRefreshTimer; + List _sources = const [AudioInputSource.systemDefault]; + AudioInputSource? _selectedSource; + AudioInputSource? _appliedSource; + bool _isMonitoringActive = false; + bool _isRecordingActive = false; + bool _disposed = false; + + final List _waveformData = []; + + AudioInputController({required AudioInputPlatform platform}) + : _platform = platform; + + /// Available input sources shown in the microphone configuration dropdown. + List get sources => List.unmodifiable(_sources); + + /// Pending microphone input selected in the configuration UI. + AudioInputSource? get selectedSource => _selectedSource; + + /// Microphone input currently applied to monitoring/recording. + AudioInputSource? get appliedSource => _appliedSource; + + /// Whether any audio input is applied or actively using the recorder. + bool get isEnabled => + _appliedSource != null || _isMonitoringActive || _isRecordingActive; + + /// Whether live monitoring is currently active. + bool get isMonitoringActive => _isMonitoringActive; + + /// Whether microphone recording is currently active. + bool get isRecordingActive => _isRecordingActive; + + /// Whether the selected source differs from the applied source. + bool get hasPendingSelection => !_sameSource(_selectedSource, _appliedSource); + + /// Recent normalized audio levels for waveform display. + List get waveformData => List.unmodifiable(_waveformData); + + /// Starts periodic source discovery while the configuration UI is mounted. + void startSourceRefresh() { + if (_sourceRefreshTimer != null) { + return; + } + unawaited(refreshSources()); + _sourceRefreshTimer = Timer.periodic( + _sourceRefreshInterval, + (_) => unawaited(refreshSources()), + ); + } + + /// Stops periodic source discovery. + void stopSourceRefresh() { + _sourceRefreshTimer?.cancel(); + _sourceRefreshTimer = null; + } + + /// Refreshes the platform microphone source list. + Future refreshSources() async { + try { + final nextSources = await _platform.listAudioInputSources(); + if (!_sameSourceList(_sources, nextSources)) { + _sources = nextSources; + _notifyListenersIfActive(); + } + } catch (e) { + logger.e("Error listing audio input sources: $e"); + } + } + + /// Selects the pending source used on the next Apply Profiles action. + Future selectSource(AudioInputSource? source) async { + if (_isRecordingActive) { + logger.w("Cannot change audio input while recording is active"); + return; + } + if (_sameSource(_selectedSource, source)) { + return; + } + _selectedSource = source; + _notifyListenersIfActive(); + } + + /// Enables or disables audio input without forgetting the chosen source. + Future setEnabled(bool enabled) async { + if (enabled) { + _selectedSource ??= AudioInputSource.systemDefault; + await refreshSources(); + } else { + await selectSource(null); + } + _notifyListenersIfActive(); + } + + /// Applies the pending source to live monitoring. + Future applySelectedSource() async { + if (_isRecordingActive) { + logger.w("Cannot apply audio input while recording is active"); + return false; + } + if (!hasPendingSelection) { + return false; + } + + final selectedSource = _selectedSource; + if (selectedSource == null) { + await _stopMonitoring(); + _appliedSource = null; + _clearWaveform(); + _notifyListenersIfActive(); + return true; + } + + if (_isMonitoringActive) { + await _stopMonitoring(clearWaveform: false); + } + _appliedSource = selectedSource; + final started = await _startMonitoring(); + if (!started) { + _appliedSource = null; + } + _notifyListenersIfActive(); + return started; + } + + /// Starts live monitoring for the currently applied source. + Future startMonitoring() { + return _startMonitoring(); + } + + /// Stops live monitoring. + Future stopMonitoring() { + return _stopMonitoring(); + } + + /// Starts audio recording for the currently applied source. + Future startRecording(String recordingFolderPath) async { + final source = _appliedSource; + if (source == null) { + return; + } + + final shouldRestoreMonitoring = _isMonitoringActive; + if (shouldRestoreMonitoring) { + await _stopMonitoring(clearWaveform: false, notify: false); + } + + final started = await _platform.startRecording( + source, + recordingFolderPath, + _appendLevel, + ); + _isRecordingActive = started; + if (!started) { + await _restoreMonitoringIfNeeded(shouldRestoreMonitoring); + } + _notifyListenersIfActive(); + } + + /// Stops audio recording and optionally turns the applied microphone off. + Future> stopRecording({ + required bool turnOffMic, + }) async { + final draftFiles = _isRecordingActive + ? await _platform.stopRecording() + : const []; + _isRecordingActive = false; + + if (turnOffMic) { + await selectSource(null); + await _stopMonitoring(); + _appliedSource = null; + _clearWaveform(); + _notifyListenersIfActive(); + } else if (_appliedSource != null) { + await _startMonitoring(); + } + + _notifyListenersIfActive(); + return draftFiles; + } + + Future _startMonitoring() async { + if (_isMonitoringActive) { + return true; + } + final source = _appliedSource; + if (source == null) { + return false; + } + final started = await _platform.startMonitoring(source, _appendLevel); + _isMonitoringActive = started; + if (!started) { + _notifyListenersIfActive(); + return false; + } + _notifyListenersIfActive(); + return true; + } + + Future _stopMonitoring({ + bool clearWaveform = true, + bool notify = true, + }) async { + if (!_isMonitoringActive) { + if (clearWaveform && _waveformData.isNotEmpty) { + _clearWaveform(); + if (notify) { + _notifyListenersIfActive(); + } + } + return; + } + try { + await _platform.stopMonitoring(); + } catch (e) { + logger.e("Error stopping audio monitoring: $e"); + } finally { + _isMonitoringActive = false; + if (clearWaveform) { + _clearWaveform(); + } + if (notify) { + _notifyListenersIfActive(); + } + } + } + + Future _restoreMonitoringIfNeeded(bool shouldRestore) async { + if (!shouldRestore || _disposed || _appliedSource == null) { + return; + } + await _startMonitoring(); + } + + void _appendLevel(double level) { + _waveformData.add(level.clamp(0.0, 1.0)); + if (_waveformData.length > _maxWaveformSamples) { + _waveformData.removeAt(0); + } + _notifyListenersIfActive(); + } + + void _clearWaveform() { + _waveformData.clear(); + } + + bool _sameSource(AudioInputSource? left, AudioInputSource? right) { + if (left == null || right == null) { + return left == null && right == null; + } + return left.id == right.id; + } + + bool _sameSourceList( + List left, + List right, + ) { + if (left.length != right.length) { + return false; + } + for (var i = 0; i < left.length; i++) { + if (left[i] != right[i]) { + return false; + } + } + return true; + } + + void _notifyListenersIfActive() { + if (!_disposed) { + notifyListeners(); + } + } + + @override + void dispose() { + _disposed = true; + stopSourceRefresh(); + unawaited(_platform.dispose()); + _waveformData.clear(); + super.dispose(); + } +} diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart index 9c96702a..1dd3a367 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -9,6 +9,8 @@ import 'package:record/record.dart'; import '../models/audio_input_source.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; +import '../widgets/sensors/local_recorder/local_recorder_models.dart'; +import 'audio_input_controller.dart'; /// Runtime recorder state for connected wearables and sensors. /// @@ -33,44 +35,26 @@ class SensorRecorderProvider with ChangeNotifier { final Map _recordingFilepathsBySensorIdentity = {}; Future _pendingSynchronization = Future.value(); bool _disposed = false; + late final AudioInputController _audioInput = AudioInputController( + platform: _IoAudioInputPlatform(), + )..addListener(_notifyListenersIfActive); bool _isRecording = false; bool _hasSensorsConnected = false; String? _currentDirectory; DateTime? _recordingStart; - final AudioRecorder _audioRecorder = AudioRecorder(); - static const Duration _audioInputRefreshInterval = Duration(seconds: 3); - bool _isAudioRecording = false; - String? _currentAudioPath; - StreamSubscription? _amplitudeSub; - Timer? _audioInputRefreshTimer; - List _availableInputDevices = const []; - List _audioInputSources = const [ - AudioInputSource.systemDefault, - ]; - AudioInputSource? _selectedAudioInputSource; - AudioInputSource? _appliedAudioInputSource; bool get isRecording => _isRecording; bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; - List get audioInputSources => - List.unmodifiable(_audioInputSources); - AudioInputSource? get selectedAudioInputSource => _selectedAudioInputSource; - AudioInputSource? get appliedAudioInputSource => _appliedAudioInputSource; - bool get isAudioInputEnabled => - _appliedAudioInputSource != null || - _isStreamingActive || - _isAudioRecording; - bool get isAudioMonitoringActive => _isStreamingActive; - bool get isAudioInputSelectionPending => !_sameAudioInputSource( - _selectedAudioInputSource, - _appliedAudioInputSource, - ); - - final List _waveformData = []; - List get waveformData => List.unmodifiable(_waveformData); + List get audioInputSources => _audioInput.sources; + AudioInputSource? get selectedAudioInputSource => _audioInput.selectedSource; + AudioInputSource? get appliedAudioInputSource => _audioInput.appliedSource; + bool get isAudioInputEnabled => _audioInput.isEnabled; + bool get isAudioMonitoringActive => _audioInput.isMonitoringActive; + bool get isAudioInputSelectionPending => _audioInput.hasPendingSelection; + List get waveformData => _audioInput.waveformData; int _microphoneConfigurationRevision = 0; int get microphoneConfigurationRevision => _microphoneConfigurationRevision; @@ -90,58 +74,19 @@ class SensorRecorderProvider with ChangeNotifier { _notifyListenersIfActive(); } - // Path for temporary streaming file - String? _streamingPath; - bool _isStreamingActive = false; - /// Starts periodic microphone discovery while microphone settings UI exists. void startAudioInputSourceRefresh() { - if (_audioInputRefreshTimer != null) { - return; - } - unawaited(refreshAudioInputSources()); - _audioInputRefreshTimer = Timer.periodic( - _audioInputRefreshInterval, - (_) => unawaited(refreshAudioInputSources()), - ); + _audioInput.startSourceRefresh(); } /// Stops periodic microphone discovery when no UI needs it. void stopAudioInputSourceRefresh() { - _audioInputRefreshTimer?.cancel(); - _audioInputRefreshTimer = null; + _audioInput.stopSourceRefresh(); } /// Refreshes the platform microphone list used by the virtual microphone row. Future refreshAudioInputSources() async { - try { - final devices = await _audioRecorder.listInputDevices(); - final uniqueDevices = []; - final seenDeviceIds = {}; - for (final device in devices) { - if (seenDeviceIds.add(device.id)) { - uniqueDevices.add(device); - } - } - final nextSources = [ - AudioInputSource.systemDefault, - ...uniqueDevices.map( - (device) => AudioInputSource( - id: device.id, - label: device.label, - kind: classifyAudioInputSourceLabel(device.label), - ), - ), - ]; - if (!_sameInputDevices(_availableInputDevices, uniqueDevices) || - !_sameAudioInputSources(_audioInputSources, nextSources)) { - _availableInputDevices = uniqueDevices; - _audioInputSources = nextSources; - _notifyListenersIfActive(); - } - } catch (e) { - logger.e("Error listing audio input devices: $e"); - } + await _audioInput.refreshSources(); } /// Selects the app-local microphone source used by local recordings. @@ -149,85 +94,20 @@ class SensorRecorderProvider with ChangeNotifier { /// Passing `null` turns audio capture off while leaving wearable sensor /// configuration untouched. Future selectAudioInputSource(AudioInputSource? source) async { - if (_isAudioRecording) { - logger.w("Cannot change audio input while recording is active"); - return; - } - - if (_sameAudioInputSource(_selectedAudioInputSource, source)) { - return; - } - - _selectedAudioInputSource = source; - _notifyListenersIfActive(); + await _audioInput.selectSource(source); } /// Enables or disables audio capture without changing the remembered source. Future setAudioInputEnabled(bool enabled) async { - if (enabled) { - _selectedAudioInputSource ??= AudioInputSource.systemDefault; - await refreshAudioInputSources(); - } else { - await selectAudioInputSource(null); - } - _notifyListenersIfActive(); - } - - InputDevice? _inputDeviceForSource(AudioInputSource source) { - if (source.isSystemDefault) { - return null; - } - for (final device in _availableInputDevices) { - if (device.id == source.id) { - return device; - } - } - return null; - } - - bool _sameAudioInputSource( - AudioInputSource? left, - AudioInputSource? right, - ) { - if (left == null || right == null) { - return left == null && right == null; - } - return left.id == right.id; - } - - bool _sameInputDevices(List left, List right) { - if (left.length != right.length) { - return false; - } - for (var i = 0; i < left.length; i++) { - if (left[i].id != right[i].id || left[i].label != right[i].label) { - return false; - } - } - return true; - } - - bool _sameAudioInputSources( - List left, - List right, - ) { - if (left.length != right.length) { - return false; - } - for (var i = 0; i < left.length; i++) { - if (left[i] != right[i]) { - return false; - } - } - return true; + await _audioInput.setEnabled(enabled); } Future startAudioMonitoring() async { - return _startAudioMonitoring(); + return _audioInput.startMonitoring(); } Future stopAudioMonitoring() async { - await _stopAudioMonitoring(); + await _audioInput.stopMonitoring(); } /// Applies the pending microphone selection to the live monitoring stream. @@ -236,137 +116,7 @@ class SensorRecorderProvider with ChangeNotifier { /// pending state. Calling this method mirrors the wearable profile apply /// flow by starting or stopping the actual microphone stream. Future applySelectedAudioInputSource() async { - if (_isAudioRecording) { - logger.w("Cannot apply audio input while recording is active"); - return false; - } - if (!isAudioInputSelectionPending) { - return false; - } - - final selectedSource = _selectedAudioInputSource; - if (selectedSource == null) { - await _stopAudioMonitoring(); - _appliedAudioInputSource = null; - _waveformData.clear(); - _notifyListenersIfActive(); - return true; - } - - if (_isStreamingActive) { - await _stopAudioMonitoring(clearWaveform: false); - } - _appliedAudioInputSource = selectedSource; - final started = await startAudioMonitoring(); - if (started) { - _notifyListenersIfActive(); - } else { - _appliedAudioInputSource = null; - _notifyListenersIfActive(); - } - return started; - } - - Future _startAudioMonitoring() async { - if (_isStreamingActive) { - logger.i("Audio input monitoring already active"); - return true; - } - - try { - final source = _appliedAudioInputSource; - if (source == null) { - logger.w("No audio input selected for monitoring"); - return false; - } - if (!await _audioRecorder.hasPermission()) { - logger.w("No microphone permission for monitoring"); - return false; - } - - await refreshAudioInputSources(); - final selectedDevice = _inputDeviceForSource(source); - if (!source.isSystemDefault && selectedDevice == null) { - logger.w("Selected audio input is unavailable: ${source.label}"); - return false; - } - - const encoder = AudioEncoder.wav; - if (!await _audioRecorder.isEncoderSupported(encoder)) { - logger.w("WAV encoder not supported"); - return false; - } - - final tempDir = await getTemporaryDirectory(); - _streamingPath = - '${tempDir.path}/ble_stream_${DateTime.now().millisecondsSinceEpoch}.wav'; - - final config = RecordConfig( - encoder: encoder, - sampleRate: 48000, - bitRate: 768000, - numChannels: 1, - device: selectedDevice, - ); - - await _audioRecorder.start(config, path: _streamingPath!); - _isStreamingActive = true; - - // Set up amplitude monitoring for waveform display - _amplitudeSub?.cancel(); - _amplitudeSub = _audioRecorder - .onAmplitudeChanged(const Duration(milliseconds: 100)) - .listen((amp) { - final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 1.0)); - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - - _notifyListenersIfActive(); - }); - - logger.i( - "Audio monitoring started with input: ${source.label}", - ); - _notifyListenersIfActive(); - return true; - } catch (e) { - logger.e("Failed to start audio monitoring: $e"); - _isStreamingActive = false; - _streamingPath = null; - _notifyListenersIfActive(); - return false; - } - } - - /// Stops the temporary monitoring session without changing the applied source. - Future _stopAudioMonitoring({ - bool clearWaveform = true, - bool notify = true, - }) async { - if (!_isStreamingActive) { - return; - } - - try { - await _audioRecorder.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - _isStreamingActive = false; - if (clearWaveform) { - _waveformData.clear(); - } - await _deleteStreamingFile(); - - logger.i("Audio monitoring stopped"); - if (notify) { - _notifyListenersIfActive(); - } - } catch (e) { - logger.e("Error stopping audio monitoring: $e"); - } + return _audioInput.applySelectedSource(); } Future startRecording(String dirname) async { @@ -394,142 +144,22 @@ class SensorRecorderProvider with ChangeNotifier { rethrow; } - await _startAudioRecording(dirname); + await _audioInput.startRecording(dirname); _notifyListenersIfActive(); } - Future _startAudioRecording(String recordingFolderPath) async { - final source = _appliedAudioInputSource; - if (source == null) { - return; - } - - final shouldRestoreMonitoring = _isStreamingActive; - if (shouldRestoreMonitoring) { - await _stopAudioMonitoring(clearWaveform: false, notify: false); - } - - try { - if (!await _audioRecorder.hasPermission()) { - logger.w("No microphone permission for recording"); - await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); - return; - } - - await refreshAudioInputSources(); - final selectedDevice = _inputDeviceForSource(source); - if (!source.isSystemDefault && selectedDevice == null) { - logger.w( - "Selected audio input is unavailable, skipping audio recording: ${source.label}", - ); - await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); - return; - } - - const encoder = AudioEncoder.wav; - if (!await _audioRecorder.isEncoderSupported(encoder)) { - logger.w("WAV encoder not supported"); - await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); - return; - } - - final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); - final audioPath = '$recordingFolderPath/audio_$timestamp.wav'; - - final config = RecordConfig( - encoder: encoder, - sampleRate: 48000, // Set to 48kHz for BLE audio quality - bitRate: 768000, // 16-bit * 48kHz * 1 channel = 768 kbps - numChannels: 1, - device: selectedDevice, - ); - - await _audioRecorder.start(config, path: audioPath); - _currentAudioPath = audioPath; - _isAudioRecording = true; - - logger.i( - "Audio recording started: $_currentAudioPath with input: ${source.label}", - ); - - _amplitudeSub = _audioRecorder - .onAmplitudeChanged(const Duration(milliseconds: 100)) - .listen((amp) { - final normalized = (amp.current + 50) / 50; - _waveformData.add(normalized.clamp(0.0, 1.0)); - - if (_waveformData.length > 100) { - _waveformData.removeAt(0); - } - - _notifyListenersIfActive(); - }); - } catch (e) { - logger.e("Failed to start audio recording: $e"); - _isAudioRecording = false; - await _restoreAudioMonitoringIfNeeded(shouldRestoreMonitoring); - } - } - /// Stops active wearable and audio recording streams and finalizes files. Future stopRecording(bool turnOffMic) async { _isRecording = false; _recordingStart = null; _recordingFilepathsBySensorIdentity.clear(); _stopAllRecorderStreams(); - try { - if (_isAudioRecording) { - final path = await _audioRecorder.stop(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - _isAudioRecording = false; - - logger.i("Audio recording saved to: $path"); - _currentAudioPath = null; - } - } catch (e) { - logger.e("Error stopping audio recording: $e"); - } - - if (turnOffMic) { - await selectAudioInputSource(null); - await _stopAudioMonitoring(); - _appliedAudioInputSource = null; - _waveformData.clear(); - _notifyListenersIfActive(); - } else if (_appliedAudioInputSource != null) { - await startAudioMonitoring(); - } + await _audioInput.stopRecording(turnOffMic: turnOffMic); _notifyListenersIfActive(); } - /// Restarts monitoring when recording could not take over the audio input. - Future _restoreAudioMonitoringIfNeeded(bool shouldRestore) async { - if (!shouldRestore || _disposed || _appliedAudioInputSource == null) { - return; - } - await _startAudioMonitoring(); - } - - /// Deletes the temporary file used by the live monitoring recorder session. - Future _deleteStreamingFile() async { - final streamingPath = _streamingPath; - if (streamingPath == null) { - return; - } - _streamingPath = null; - try { - final file = File(streamingPath); - if (await file.exists()) { - await file.delete(); - } - } catch (e) { - logger.w("Failed to delete temporary audio monitoring file: $e"); - } - } - /// Notifies listeners only while this provider is still mounted. void _notifyListenersIfActive() { if (!_disposed) { @@ -771,22 +401,8 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { _disposed = true; - stopAudioInputSourceRefresh(); - _amplitudeSub?.cancel(); - _amplitudeSub = null; - _isStreamingActive = false; - _isAudioRecording = false; - _waveformData.clear(); - unawaited(() async { - try { - await _audioRecorder.stop(); - await _deleteStreamingFile(); - } catch (e) { - logger.e("Error stopping audio in dispose: $e"); - } finally { - await _audioRecorder.dispose(); - } - }()); + _audioInput.removeListener(_notifyListenersIfActive); + _audioInput.dispose(); for (final wearable in _recorders.keys.toList()) { _disposeWearable(wearable); } @@ -795,3 +411,230 @@ class SensorRecorderProvider with ChangeNotifier { super.dispose(); } } + +class _IoAudioInputPlatform implements AudioInputPlatform { + final AudioRecorder _audioRecorder = AudioRecorder(); + StreamSubscription? _amplitudeSub; + List _availableInputDevices = const []; + String? _streamingPath; + String? _currentAudioPath; + bool _isMonitoringActive = false; + bool _isRecordingActive = false; + + @override + Future> listAudioInputSources() async { + final devices = await _audioRecorder.listInputDevices(); + final uniqueDevices = []; + final seenDeviceIds = {}; + for (final device in devices) { + if (seenDeviceIds.add(device.id)) { + uniqueDevices.add(device); + } + } + _availableInputDevices = uniqueDevices; + return [ + AudioInputSource.systemDefault, + ...uniqueDevices.map( + (device) => AudioInputSource( + id: device.id, + label: device.label, + kind: classifyAudioInputSourceLabel(device.label), + ), + ), + ]; + } + + @override + Future startMonitoring( + AudioInputSource source, + ValueChanged onLevel, + ) async { + if (_isMonitoringActive) { + return true; + } + try { + final selectedDevice = await _inputDeviceForSource(source); + if (selectedDevice == _UnavailableInputDevice.instance) { + logger.w("Selected audio input is unavailable: ${source.label}"); + return false; + } + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for monitoring"); + return false; + } + const encoder = AudioEncoder.wav; + if (!await _audioRecorder.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + return false; + } + + final tempDir = await getTemporaryDirectory(); + _streamingPath = + '${tempDir.path}/audio_monitor_${DateTime.now().millisecondsSinceEpoch}.wav'; + await _audioRecorder.start( + RecordConfig( + encoder: encoder, + sampleRate: 48000, + bitRate: 768000, + numChannels: 1, + device: selectedDevice, + ), + path: _streamingPath!, + ); + _isMonitoringActive = true; + _listenToAmplitude(onLevel); + logger.i("Audio monitoring started with input: ${source.label}"); + return true; + } catch (e) { + logger.e("Failed to start audio monitoring: $e"); + _isMonitoringActive = false; + _streamingPath = null; + return false; + } + } + + @override + Future stopMonitoring() async { + if (!_isMonitoringActive) { + return; + } + try { + await _audioRecorder.stop(); + await _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isMonitoringActive = false; + await _deleteStreamingFile(); + logger.i("Audio monitoring stopped"); + } catch (e) { + logger.e("Error stopping audio monitoring: $e"); + } + } + + @override + Future startRecording( + AudioInputSource source, + String recordingFolderPath, + ValueChanged onLevel, + ) async { + try { + final selectedDevice = await _inputDeviceForSource(source); + if (selectedDevice == _UnavailableInputDevice.instance) { + logger.w( + "Selected audio input is unavailable, skipping audio recording: ${source.label}", + ); + return false; + } + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for recording"); + return false; + } + const encoder = AudioEncoder.wav; + if (!await _audioRecorder.isEncoderSupported(encoder)) { + logger.w("WAV encoder not supported"); + return false; + } + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + final audioPath = '$recordingFolderPath/audio_$timestamp.wav'; + await _audioRecorder.start( + RecordConfig( + encoder: encoder, + sampleRate: 48000, + bitRate: 768000, + numChannels: 1, + device: selectedDevice, + ), + path: audioPath, + ); + _currentAudioPath = audioPath; + _isRecordingActive = true; + _listenToAmplitude(onLevel); + logger.i("Audio recording started: $_currentAudioPath"); + return true; + } catch (e) { + logger.e("Failed to start audio recording: $e"); + _isRecordingActive = false; + return false; + } + } + + @override + Future> stopRecording() async { + if (!_isRecordingActive) { + return const []; + } + try { + final path = await _audioRecorder.stop(); + await _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isRecordingActive = false; + logger.i("Audio recording saved to: $path"); + _currentAudioPath = null; + } catch (e) { + logger.e("Error stopping audio recording: $e"); + } + return const []; + } + + Future _inputDeviceForSource(AudioInputSource source) async { + if (source.isSystemDefault) { + return null; + } + if (_availableInputDevices.isEmpty) { + await listAudioInputSources(); + } + for (final device in _availableInputDevices) { + if (device.id == source.id) { + return device; + } + } + return _UnavailableInputDevice.instance; + } + + void _listenToAmplitude(ValueChanged onLevel) { + unawaited(_amplitudeSub?.cancel()); + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen((amp) => onLevel(_normalizeAmplitude(amp))); + } + + double _normalizeAmplitude(Amplitude amplitude) { + return ((amplitude.current + 50) / 50).clamp(0.0, 1.0); + } + + Future _deleteStreamingFile() async { + final streamingPath = _streamingPath; + if (streamingPath == null) { + return; + } + _streamingPath = null; + try { + final file = File(streamingPath); + if (await file.exists()) { + await file.delete(); + } + } catch (e) { + logger.w("Failed to delete temporary audio monitoring file: $e"); + } + } + + @override + Future dispose() async { + await _amplitudeSub?.cancel(); + _amplitudeSub = null; + try { + await _audioRecorder.stop(); + await _deleteStreamingFile(); + } catch (e) { + logger.e("Error stopping audio in dispose: $e"); + } finally { + await _audioRecorder.dispose(); + } + } +} + +class _UnavailableInputDevice extends InputDevice { + static const _UnavailableInputDevice instance = _UnavailableInputDevice._(); + + const _UnavailableInputDevice._() + : super(id: '__unavailable_input_device__', label: 'Unavailable'); +} diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart index 8f6f964f..99181f5a 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_web.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_web.dart @@ -1,7 +1,9 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/foundation.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart' hide logger; +import 'package:record/record.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_storage_web.dart'; @@ -9,13 +11,18 @@ import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_stor import '../models/audio_input_source.dart'; import '../models/logger.dart'; import '../models/sensor_streams.dart'; +import 'audio_input_controller.dart'; class SensorRecorderProvider with ChangeNotifier { final Map _wearablesById = {}; final Map> _sensorSubscriptions = {}; final Map _sessions = {}; + late final AudioInputController _audioInput = AudioInputController( + platform: _WebAudioInputPlatform(), + )..addListener(_notifyListenersIfActive); bool _isRecording = false; bool _hasSensorsConnected = false; + bool _disposed = false; String? _currentDirectory; DateTime? _recordingStart; @@ -23,59 +30,52 @@ class SensorRecorderProvider with ChangeNotifier { bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; - List get audioInputSources => const []; - AudioInputSource? get selectedAudioInputSource => null; - AudioInputSource? get appliedAudioInputSource => null; - bool get isAudioInputEnabled => false; - bool get isAudioMonitoringActive => false; - bool get isAudioInputSelectionPending => false; - - final List _waveformData = []; - List get waveformData => List.unmodifiable(_waveformData); + List get audioInputSources => _audioInput.sources; + AudioInputSource? get selectedAudioInputSource => _audioInput.selectedSource; + AudioInputSource? get appliedAudioInputSource => _audioInput.appliedSource; + bool get isAudioInputEnabled => _audioInput.isEnabled; + bool get isAudioMonitoringActive => _audioInput.isMonitoringActive; + bool get isAudioInputSelectionPending => _audioInput.hasPendingSelection; + List get waveformData => _audioInput.waveformData; int _microphoneConfigurationRevision = 0; int get microphoneConfigurationRevision => _microphoneConfigurationRevision; void notifyMicrophoneConfigurationChanged() { _microphoneConfigurationRevision++; - notifyListeners(); + _notifyListenersIfActive(); } Future refreshAudioInputSources() async { - logger.w('Audio input selection is not supported on web yet.'); + await _audioInput.refreshSources(); } - void startAudioInputSourceRefresh() {} + void startAudioInputSourceRefresh() { + _audioInput.startSourceRefresh(); + } - void stopAudioInputSourceRefresh() {} + void stopAudioInputSourceRefresh() { + _audioInput.stopSourceRefresh(); + } Future selectAudioInputSource(AudioInputSource? source) async { - if (source != null) { - logger.w('Audio input recording is not supported on web yet.'); - } - _waveformData.clear(); - notifyListeners(); + await _audioInput.selectSource(source); } Future setAudioInputEnabled(bool enabled) async { - if (enabled) { - logger.w('Audio input recording is not supported on web yet.'); - } - await selectAudioInputSource(null); + await _audioInput.setEnabled(enabled); } Future startAudioMonitoring() async { - logger.w('Audio input monitoring is not supported on web yet.'); - return false; + return _audioInput.startMonitoring(); } Future stopAudioMonitoring() async { - await selectAudioInputSource(null); + await _audioInput.stopMonitoring(); } Future applySelectedAudioInputSource() async { - logger.w('Audio input monitoring is not supported on web yet.'); - return false; + return _audioInput.applySelectedSource(); } Future startRecording(String dirname) async { @@ -103,7 +103,9 @@ class SensorRecorderProvider with ChangeNotifier { await persistRecordingFolderFiles(dirname, initialDrafts); } - notifyListeners(); + await _audioInput.startRecording(dirname); + + _notifyListenersIfActive(); } /// Stops active web recording sessions and persists their buffered data. @@ -124,6 +126,10 @@ class SensorRecorderProvider with ChangeNotifier { await session.dispose(); } + final audioDraftFiles = await _audioInput.stopRecording( + turnOffMic: turnOffMic, + ); + if (folderPath != null) { final draftFiles = sessions .where((session) => session.content.isNotEmpty) @@ -134,6 +140,7 @@ class SensorRecorderProvider with ChangeNotifier { ), ) .toList(); + draftFiles.addAll(audioDraftFiles); if (draftFiles.isEmpty) { await deleteRecordingFolder(folderPath); @@ -144,7 +151,7 @@ class SensorRecorderProvider with ChangeNotifier { _currentDirectory = null; - notifyListeners(); + _notifyListenersIfActive(); } Future addWearable(Wearable wearable) async { @@ -196,7 +203,13 @@ class SensorRecorderProvider with ChangeNotifier { void _updateConnected() { _hasSensorsConnected = _wearablesById.isNotEmpty; logger.i('Has sensors connected: $_hasSensorsConnected'); - notifyListeners(); + _notifyListenersIfActive(); + } + + void _notifyListenersIfActive() { + if (!_disposed) { + notifyListeners(); + } } Future _startRecordingForWearable( @@ -277,17 +290,245 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { + _disposed = true; + _audioInput.removeListener(_notifyListenersIfActive); + _audioInput.dispose(); unawaited(_cancelSensorSubscriptions(_sensorSubscriptions.keys)); for (final session in _sessions.values) { unawaited(session.dispose()); } _sessions.clear(); _wearablesById.clear(); - _waveformData.clear(); super.dispose(); } } +class _WebAudioInputPlatform implements AudioInputPlatform { + static const int _sampleRate = 48000; + static const int _numChannels = 1; + + final AudioRecorder _audioRecorder = AudioRecorder(); + StreamSubscription? _amplitudeSub; + StreamSubscription? _audioStreamSub; + BytesBuilder? _audioRecordingBytes; + List _availableInputDevices = const []; + bool _isStreamingActive = false; + bool _isRecordingActive = false; + + @override + Future> listAudioInputSources() async { + await _audioRecorder.hasPermission(); + final devices = await _audioRecorder.listInputDevices(); + final uniqueDevices = []; + final seenDeviceIds = {}; + for (final device in devices) { + if (seenDeviceIds.add(device.id)) { + uniqueDevices.add(device); + } + } + _availableInputDevices = uniqueDevices; + return [ + AudioInputSource.systemDefault, + ...uniqueDevices.map( + (device) => AudioInputSource( + id: device.id, + label: device.label.isEmpty ? 'Microphone' : device.label, + kind: classifyAudioInputSourceLabel(device.label), + ), + ), + ]; + } + + @override + Future startMonitoring( + AudioInputSource source, + ValueChanged onLevel, + ) { + return _startAudioStream( + source: source, + recording: false, + onLevel: onLevel, + ); + } + + @override + Future stopMonitoring() { + return _stopAudioStream(); + } + + @override + Future startRecording( + AudioInputSource source, + String recordingFolderPath, + ValueChanged onLevel, + ) { + return _startAudioStream( + source: source, + recording: true, + onLevel: onLevel, + ); + } + + @override + Future> stopRecording() async { + if (!_isRecordingActive) { + return const []; + } + final audioBytes = _audioRecordingBytes; + await _stopAudioStream(clearRecordingBytes: false); + final pcmBytes = audioBytes?.toBytes(); + _audioRecordingBytes = null; + if (pcmBytes == null || pcmBytes.isEmpty) { + return const []; + } + final timestamp = DateTime.now().toIso8601String().replaceAll(':', '-'); + return [ + LocalRecorderDraftFile( + name: 'audio_$timestamp.wav', + mimeType: 'audio/wav', + bytes: _buildWavBytes( + pcmBytes: pcmBytes, + sampleRate: _sampleRate, + numChannels: _numChannels, + ), + ), + ]; + } + + Future _startAudioStream({ + required AudioInputSource source, + required bool recording, + required ValueChanged onLevel, + }) async { + if (_isStreamingActive || _isRecordingActive) { + return true; + } + try { + if (!await _audioRecorder.hasPermission()) { + logger.w("No microphone permission for web audio recording"); + return false; + } + final selectedDevice = await _inputDeviceForSource(source); + if (selectedDevice == _UnavailableInputDevice.instance) { + logger.w("Selected web audio input is unavailable: ${source.label}"); + return false; + } + final stream = await _audioRecorder.startStream( + RecordConfig( + encoder: AudioEncoder.pcm16bits, + sampleRate: _sampleRate, + numChannels: _numChannels, + device: selectedDevice, + ), + ); + _audioRecordingBytes = recording ? BytesBuilder(copy: false) : null; + _audioStreamSub = stream.listen((chunk) { + _audioRecordingBytes?.add(chunk); + }); + _isRecordingActive = recording; + _isStreamingActive = !recording; + unawaited(_amplitudeSub?.cancel()); + _amplitudeSub = _audioRecorder + .onAmplitudeChanged(const Duration(milliseconds: 100)) + .listen((amp) => onLevel(_normalizeAmplitude(amp))); + return true; + } catch (e) { + logger.e("Failed to start web audio stream: $e"); + _isRecordingActive = false; + _isStreamingActive = false; + _audioRecordingBytes = null; + return false; + } + } + + Future _stopAudioStream({bool clearRecordingBytes = true}) async { + if (!_isStreamingActive && !_isRecordingActive) { + return; + } + try { + await _audioRecorder.stop(); + await _audioStreamSub?.cancel(); + _audioStreamSub = null; + await _amplitudeSub?.cancel(); + _amplitudeSub = null; + _isStreamingActive = false; + _isRecordingActive = false; + if (clearRecordingBytes) { + _audioRecordingBytes = null; + } + } catch (e) { + logger.e("Error stopping web audio stream: $e"); + } + } + + Future _inputDeviceForSource(AudioInputSource source) async { + if (source.isSystemDefault) { + return null; + } + if (_availableInputDevices.isEmpty) { + await listAudioInputSources(); + } + for (final device in _availableInputDevices) { + if (device.id == source.id) { + return device; + } + } + return _UnavailableInputDevice.instance; + } + + double _normalizeAmplitude(Amplitude amplitude) { + return ((amplitude.current + 50) / 50).clamp(0.0, 1.0); + } + + Uint8List _buildWavBytes({ + required Uint8List pcmBytes, + required int sampleRate, + required int numChannels, + }) { + const headerSize = 44; + const bitsPerSample = 16; + final bytesPerSample = bitsPerSample ~/ 8; + final byteRate = sampleRate * numChannels * bytesPerSample; + final blockAlign = numChannels * bytesPerSample; + final data = Uint8List(headerSize + pcmBytes.length); + final view = ByteData.view(data.buffer); + _writeAscii(view, 0, 'RIFF'); + view.setUint32(4, data.length - 8, Endian.little); + _writeAscii(view, 8, 'WAVE'); + _writeAscii(view, 12, 'fmt '); + view.setUint32(16, 16, Endian.little); + view.setUint16(20, 1, Endian.little); + view.setUint16(22, numChannels, Endian.little); + view.setUint32(24, sampleRate, Endian.little); + view.setUint32(28, byteRate, Endian.little); + view.setUint16(32, blockAlign, Endian.little); + view.setUint16(34, bitsPerSample, Endian.little); + _writeAscii(view, 36, 'data'); + view.setUint32(40, pcmBytes.length, Endian.little); + data.setRange(headerSize, data.length, pcmBytes); + return data; + } + + void _writeAscii(ByteData view, int offset, String value) { + for (var i = 0; i < value.length; i++) { + view.setUint8(offset + i, value.codeUnitAt(i)); + } + } + + @override + Future dispose() async { + await _stopAudioStream(); + await _audioRecorder.dispose(); + } +} + +class _UnavailableInputDevice extends InputDevice { + static const _UnavailableInputDevice instance = _UnavailableInputDevice._(); + + const _UnavailableInputDevice._() + : super(id: '__unavailable_input_device__', label: 'Unavailable'); +} + class _WebRecordingSession { final String fileName; final Sensor sensor; diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart index 99f8184a..ef30f037 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_file_actions_io.dart @@ -28,7 +28,7 @@ Future localRecorderOpenRecordingFile( ) async { final result = await OpenFile.open( file.path, - type: 'text/comma-separated-values', + type: file.mimeType, ); if (result.type != ResultType.done) { diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart index adb923ac..bbb89ef0 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_models.dart @@ -1,3 +1,5 @@ +import 'dart:typed_data'; + class LocalRecorderRecordingFile { final String path; final String name; @@ -32,11 +34,13 @@ class LocalRecorderDraftFile { final String name; final String content; final String mimeType; + final Uint8List? bytes; const LocalRecorderDraftFile({ required this.name, - required this.content, + this.content = '', this.mimeType = 'text/csv', + this.bytes, }); } diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart index b755e76d..b3d4212c 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_io.dart @@ -9,8 +9,8 @@ import 'local_recorder_models.dart'; /// Helper to get the base name of a file or directory across platforms. String localRecorderBasename(String path) => path.split(RegExp(r'[\\/]+')).last; -/// Standardizes access to the recordings root for Apple platforms (iOS & macOS). -Future _getAppleRecordingsDirectory() async { +/// Standardizes access to the recordings root for app-sandboxed platforms. +Future _getAppRecordingsDirectory() async { final appDocDir = await getApplicationDocumentsDirectory(); final dirPath = '${appDocDir.path}/Recordings'; final dir = Directory(dirPath); @@ -29,11 +29,14 @@ Future _getRecordingsRootDirectory() async { return getExternalStorageDirectory(); } - if (Platform.isIOS || Platform.isMacOS) { - return _getAppleRecordingsDirectory(); + if (Platform.isIOS || + Platform.isMacOS || + Platform.isLinux || + Platform.isWindows) { + return _getAppRecordingsDirectory(); } - return null; + return _getAppRecordingsDirectory(); } Future pickRecordingDirectory() async { @@ -103,6 +106,7 @@ LocalRecorderRecordingFolder _directoryToFolder(Directory directory) { name: localRecorderBasename(file.path), sizeBytes: file.lengthSync(), updatedAt: file.statSync().modified, + mimeType: _mimeTypeForPath(file.path), ), ) .toList() @@ -115,3 +119,14 @@ LocalRecorderRecordingFolder _directoryToFolder(Directory directory) { files: files, ); } + +String _mimeTypeForPath(String path) { + final lower = path.toLowerCase(); + if (lower.endsWith('.wav')) { + return 'audio/wav'; + } + if (lower.endsWith('.csv')) { + return 'text/csv'; + } + return 'application/octet-stream'; +} diff --git a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart index 30cd9e66..4d668f2a 100644 --- a/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart +++ b/open_wearable/lib/widgets/sensors/local_recorder/local_recorder_storage_web.dart @@ -89,18 +89,19 @@ LocalRecorderRecordingFolder _createFolder({ }) { final folderPath = path ?? 'web-${DateTime.now().microsecondsSinceEpoch}'; final now = DateTime.now(); - final recordingFiles = files - .map( - (file) => _WebRecordingFile( - path: '$folderPath/${file.name}', - name: file.name, - sizeBytes: utf8.encode(file.content).length, - updatedAt: now, - mimeType: file.mimeType, - contentBase64: base64Encode(utf8.encode(file.content)), - ), - ) - .toList(); + final recordingFiles = files.map( + (file) { + final bytes = file.bytes ?? Uint8List.fromList(utf8.encode(file.content)); + return _WebRecordingFile( + path: '$folderPath/${file.name}', + name: file.name, + sizeBytes: bytes.length, + updatedAt: now, + mimeType: file.mimeType, + contentBase64: base64Encode(bytes), + ); + }, + ).toList(); return LocalRecorderRecordingFolder( path: folderPath, diff --git a/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart index 924e7a3a..27a7558d 100644 --- a/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart +++ b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart @@ -1,7 +1,4 @@ -import 'dart:io'; - import 'package:fl_chart/fl_chart.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; @@ -23,7 +20,7 @@ class SystemMicrophoneAudioChart extends StatefulWidget { }); /// Whether the current platform can render the system microphone chart. - static bool get isSupported => !kIsWeb && Platform.isAndroid; + static bool get isSupported => true; @override State createState() => diff --git a/open_wearable/macos/Runner/DebugProfile.entitlements b/open_wearable/macos/Runner/DebugProfile.entitlements index 14a39b7e..27acdb98 100644 --- a/open_wearable/macos/Runner/DebugProfile.entitlements +++ b/open_wearable/macos/Runner/DebugProfile.entitlements @@ -8,6 +8,8 @@ com.apple.security.device.bluetooth + com.apple.security.device.audio-input + com.apple.security.network.server diff --git a/open_wearable/macos/Runner/Info.plist b/open_wearable/macos/Runner/Info.plist index 53c2fb77..8fe38f45 100644 --- a/open_wearable/macos/Runner/Info.plist +++ b/open_wearable/macos/Runner/Info.plist @@ -26,6 +26,8 @@ OpenWearable requires Bluetooth access to connect to your wearable device. NSBluetoothPeripheralUsageDescription OpenWearable requires Bluetooth access to connect to your wearable device. + NSMicrophoneUsageDescription + This app records microphone audio with local sensor recording sessions. NSHumanReadableCopyright $(PRODUCT_COPYRIGHT) NSMainNibFile diff --git a/open_wearable/macos/Runner/Release.entitlements b/open_wearable/macos/Runner/Release.entitlements index c63c9510..f02ee084 100644 --- a/open_wearable/macos/Runner/Release.entitlements +++ b/open_wearable/macos/Runner/Release.entitlements @@ -6,6 +6,8 @@ com.apple.security.device.bluetooth + com.apple.security.device.audio-input + com.apple.security.network.server diff --git a/open_wearable/test/view_models/audio_input_controller_test.dart b/open_wearable/test/view_models/audio_input_controller_test.dart new file mode 100644 index 00000000..b037b420 --- /dev/null +++ b/open_wearable/test/view_models/audio_input_controller_test.dart @@ -0,0 +1,93 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:logger/logger.dart'; +import 'package:open_wearable/models/audio_input_source.dart'; +import 'package:open_wearable/models/logger.dart'; +import 'package:open_wearable/view_models/audio_input_controller.dart'; +import 'package:open_wearable/widgets/sensors/local_recorder/local_recorder_models.dart'; + +void main() { + setUpAll(() { + initLogger(Logger()); + }); + + group('AudioInputController', () { + test('clears applied source when turning off after stop failure', () async { + final platform = _FakeAudioInputPlatform( + throwWhenStoppingMonitoring: true, + ); + final controller = AudioInputController(platform: platform); + + await controller.selectSource(AudioInputSource.systemDefault); + expect(await controller.applySelectedSource(), isTrue); + + platform.emitLevel(0.8); + expect(controller.appliedSource, AudioInputSource.systemDefault); + expect(controller.isMonitoringActive, isTrue); + expect(controller.waveformData, isNotEmpty); + + await controller.selectSource(null); + expect(controller.hasPendingSelection, isTrue); + + expect(await controller.applySelectedSource(), isTrue); + + expect(controller.selectedSource, isNull); + expect(controller.appliedSource, isNull); + expect(controller.hasPendingSelection, isFalse); + expect(controller.isMonitoringActive, isFalse); + expect(controller.waveformData, isEmpty); + }); + }); +} + +class _FakeAudioInputPlatform implements AudioInputPlatform { + _FakeAudioInputPlatform({this.throwWhenStoppingMonitoring = false}); + + final bool throwWhenStoppingMonitoring; + ValueChanged? _onLevel; + + void emitLevel(double level) { + _onLevel?.call(level); + } + + @override + Future> listAudioInputSources() async { + return const [AudioInputSource.systemDefault]; + } + + @override + Future startMonitoring( + AudioInputSource source, + ValueChanged onLevel, + ) async { + _onLevel = onLevel; + return true; + } + + @override + Future stopMonitoring() async { + if (throwWhenStoppingMonitoring) { + throw StateError('stop failed'); + } + _onLevel = null; + } + + @override + Future startRecording( + AudioInputSource source, + String recordingFolderPath, + ValueChanged onLevel, + ) async { + _onLevel = onLevel; + return true; + } + + @override + Future> stopRecording() async { + _onLevel = null; + return const []; + } + + @override + Future dispose() async {} +} From c16ae2df8102f234b4f19cd2a917f7183011aafa Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Tue, 26 May 2026 15:47:07 +0200 Subject: [PATCH 31/34] build(ios): set minimum deployment target to 13.1 --- open_wearable/ios/Flutter/AppFrameworkInfo.plist | 2 +- open_wearable/ios/Podfile | 2 +- open_wearable/ios/Runner.xcodeproj/project.pbxproj | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/open_wearable/ios/Flutter/AppFrameworkInfo.plist b/open_wearable/ios/Flutter/AppFrameworkInfo.plist index 4ffccfc3..020f8c12 100644 --- a/open_wearable/ios/Flutter/AppFrameworkInfo.plist +++ b/open_wearable/ios/Flutter/AppFrameworkInfo.plist @@ -11,7 +11,7 @@ CFBundleInfoDictionaryVersion 6.0 MinimumOSVersion - 13.0 + 13.1 CFBundleName App CFBundlePackageType diff --git a/open_wearable/ios/Podfile b/open_wearable/ios/Podfile index fad4db75..f8637ab4 100644 --- a/open_wearable/ios/Podfile +++ b/open_wearable/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '16.0' +platform :ios, '13.1' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/open_wearable/ios/Runner.xcodeproj/project.pbxproj b/open_wearable/ios/Runner.xcodeproj/project.pbxproj index c65acc9f..3db37d40 100644 --- a/open_wearable/ios/Runner.xcodeproj/project.pbxproj +++ b/open_wearable/ios/Runner.xcodeproj/project.pbxproj @@ -472,7 +472,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -602,7 +602,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -653,7 +653,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 13.1; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; From a05f87e8761aa1d6774beb8f21234d99cd2f0c83 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 27 May 2026 11:50:21 +0200 Subject: [PATCH 32/34] feat(audio): implement platform availability check for local microphone capture, disable on macOS --- .../lib/models/audio_input_availability.dart | 15 +++++ .../sensor_recorder_provider_io.dart | 59 ++++++++++++------- .../microphone_configuration_card.dart | 4 ++ .../sensor_configuration_view.dart | 9 ++- .../values/system_microphone_audio_chart.dart | 3 +- open_wearable/pubspec.lock | 24 ++++---- 6 files changed, 76 insertions(+), 38 deletions(-) create mode 100644 open_wearable/lib/models/audio_input_availability.dart diff --git a/open_wearable/lib/models/audio_input_availability.dart b/open_wearable/lib/models/audio_input_availability.dart new file mode 100644 index 00000000..78c15ad7 --- /dev/null +++ b/open_wearable/lib/models/audio_input_availability.dart @@ -0,0 +1,15 @@ +import 'package:flutter/foundation.dart'; + +/// Platform availability for app-local microphone capture. +/// +/// macOS is disabled because the current recorder plugin can hang while +/// stopping file-backed microphone sessions on that platform. +class AudioInputAvailability { + /// Whether local microphone selection, monitoring, and recording are enabled. + static bool get isSupported { + if (kIsWeb) { + return true; + } + return defaultTargetPlatform != TargetPlatform.macOS; + } +} diff --git a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart index 1dd3a367..f9884544 100644 --- a/open_wearable/lib/view_models/sensor_recorder_provider_io.dart +++ b/open_wearable/lib/view_models/sensor_recorder_provider_io.dart @@ -35,9 +35,7 @@ class SensorRecorderProvider with ChangeNotifier { final Map _recordingFilepathsBySensorIdentity = {}; Future _pendingSynchronization = Future.value(); bool _disposed = false; - late final AudioInputController _audioInput = AudioInputController( - platform: _IoAudioInputPlatform(), - )..addListener(_notifyListenersIfActive); + AudioInputController? _audioInput; bool _isRecording = false; bool _hasSensorsConnected = false; @@ -48,13 +46,30 @@ class SensorRecorderProvider with ChangeNotifier { bool get hasSensorsConnected => _hasSensorsConnected; String? get currentDirectory => _currentDirectory; DateTime? get recordingStart => _recordingStart; - List get audioInputSources => _audioInput.sources; - AudioInputSource? get selectedAudioInputSource => _audioInput.selectedSource; - AudioInputSource? get appliedAudioInputSource => _audioInput.appliedSource; - bool get isAudioInputEnabled => _audioInput.isEnabled; - bool get isAudioMonitoringActive => _audioInput.isMonitoringActive; - bool get isAudioInputSelectionPending => _audioInput.hasPendingSelection; - List get waveformData => _audioInput.waveformData; + List get audioInputSources => + _supportedAudioInput?.sources ?? const []; + AudioInputSource? get selectedAudioInputSource => + _supportedAudioInput?.selectedSource; + AudioInputSource? get appliedAudioInputSource => + _supportedAudioInput?.appliedSource; + bool get isAudioInputEnabled => _supportedAudioInput?.isEnabled ?? false; + bool get isAudioMonitoringActive => + _supportedAudioInput?.isMonitoringActive ?? false; + bool get isAudioInputSelectionPending => + _supportedAudioInput?.hasPendingSelection ?? false; + List get waveformData => + _supportedAudioInput?.waveformData ?? const []; + + bool get _isAudioInputSupported => !Platform.isMacOS; + + AudioInputController? get _supportedAudioInput { + if (!_isAudioInputSupported) { + return null; + } + return _audioInput ??= AudioInputController( + platform: _IoAudioInputPlatform(), + )..addListener(_notifyListenersIfActive); + } int _microphoneConfigurationRevision = 0; int get microphoneConfigurationRevision => _microphoneConfigurationRevision; @@ -76,17 +91,17 @@ class SensorRecorderProvider with ChangeNotifier { /// Starts periodic microphone discovery while microphone settings UI exists. void startAudioInputSourceRefresh() { - _audioInput.startSourceRefresh(); + _supportedAudioInput?.startSourceRefresh(); } /// Stops periodic microphone discovery when no UI needs it. void stopAudioInputSourceRefresh() { - _audioInput.stopSourceRefresh(); + _audioInput?.stopSourceRefresh(); } /// Refreshes the platform microphone list used by the virtual microphone row. Future refreshAudioInputSources() async { - await _audioInput.refreshSources(); + await _supportedAudioInput?.refreshSources(); } /// Selects the app-local microphone source used by local recordings. @@ -94,20 +109,20 @@ class SensorRecorderProvider with ChangeNotifier { /// Passing `null` turns audio capture off while leaving wearable sensor /// configuration untouched. Future selectAudioInputSource(AudioInputSource? source) async { - await _audioInput.selectSource(source); + await _supportedAudioInput?.selectSource(source); } /// Enables or disables audio capture without changing the remembered source. Future setAudioInputEnabled(bool enabled) async { - await _audioInput.setEnabled(enabled); + await _supportedAudioInput?.setEnabled(enabled); } Future startAudioMonitoring() async { - return _audioInput.startMonitoring(); + return _supportedAudioInput?.startMonitoring() ?? false; } Future stopAudioMonitoring() async { - await _audioInput.stopMonitoring(); + await _audioInput?.stopMonitoring(); } /// Applies the pending microphone selection to the live monitoring stream. @@ -116,7 +131,7 @@ class SensorRecorderProvider with ChangeNotifier { /// pending state. Calling this method mirrors the wearable profile apply /// flow by starting or stopping the actual microphone stream. Future applySelectedAudioInputSource() async { - return _audioInput.applySelectedSource(); + return _supportedAudioInput?.applySelectedSource() ?? false; } Future startRecording(String dirname) async { @@ -144,7 +159,7 @@ class SensorRecorderProvider with ChangeNotifier { rethrow; } - await _audioInput.startRecording(dirname); + await _supportedAudioInput?.startRecording(dirname); _notifyListenersIfActive(); } @@ -155,7 +170,7 @@ class SensorRecorderProvider with ChangeNotifier { _recordingStart = null; _recordingFilepathsBySensorIdentity.clear(); _stopAllRecorderStreams(); - await _audioInput.stopRecording(turnOffMic: turnOffMic); + await _audioInput?.stopRecording(turnOffMic: turnOffMic); _notifyListenersIfActive(); } @@ -401,8 +416,8 @@ class SensorRecorderProvider with ChangeNotifier { @override void dispose() { _disposed = true; - _audioInput.removeListener(_notifyListenersIfActive); - _audioInput.dispose(); + _audioInput?.removeListener(_notifyListenersIfActive); + _audioInput?.dispose(); for (final wearable in _recorders.keys.toList()) { _disposeWearable(wearable); } diff --git a/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart index ad3bdeff..1d6a0083 100644 --- a/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart +++ b/open_wearable/lib/widgets/sensors/configuration/microphone_configuration_card.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; +import 'package:open_wearable/models/audio_input_availability.dart'; import 'package:open_wearable/models/audio_input_source.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; import 'package:provider/provider.dart'; @@ -12,6 +13,9 @@ import 'package:provider/provider.dart'; class MicrophoneConfigurationCard extends StatefulWidget { const MicrophoneConfigurationCard({super.key}); + /// Whether app-local microphone configuration should be visible. + static bool get isSupported => AudioInputAvailability.isSupported; + @override State createState() => _MicrophoneConfigurationCardState(); diff --git a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart index e013fcbc..997fe379 100644 --- a/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart +++ b/open_wearable/lib/widgets/sensors/configuration/sensor_configuration_view.dart @@ -39,8 +39,10 @@ class SensorConfigurationView extends StatelessWidget { return ListView( padding: SensorPageSpacing.pagePaddingWithBottomInset(context), children: [ - const MicrophoneConfigurationCard(), - const SizedBox(height: 12), + if (MicrophoneConfigurationCard.isSupported) ...[ + const MicrophoneConfigurationCard(), + const SizedBox(height: 12), + ], _buildApplyConfigButton( context, targets: const <_ConfigApplyTarget>[], @@ -75,7 +77,8 @@ class SensorConfigurationView extends StatelessWidget { wearablesProvider: wearablesProvider, ); final sections = [ - const MicrophoneConfigurationCard(), + if (MicrophoneConfigurationCard.isSupported) + const MicrophoneConfigurationCard(), ...groups.map( (group) => _buildGroupConfigurationRow( group: group, diff --git a/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart index 27a7558d..b9ecb708 100644 --- a/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart +++ b/open_wearable/lib/widgets/sensors/values/system_microphone_audio_chart.dart @@ -2,6 +2,7 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_platform_widgets/flutter_platform_widgets.dart'; import 'package:open_earable_flutter/open_earable_flutter.dart'; +import 'package:open_wearable/models/audio_input_availability.dart'; import 'package:open_wearable/models/device_name_formatter.dart'; import 'package:open_wearable/models/wearable_display_group.dart'; import 'package:open_wearable/view_models/sensor_recorder_provider_facade.dart'; @@ -20,7 +21,7 @@ class SystemMicrophoneAudioChart extends StatefulWidget { }); /// Whether the current platform can render the system microphone chart. - static bool get isSupported => true; + static bool get isSupported => AudioInputAvailability.isSupported; @override State createState() => diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 3929fb39..32541497 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -884,50 +884,50 @@ packages: dependency: "direct main" description: name: record - sha256: d5b6b334f3ab02460db6544e08583c942dbf23e3504bf1e14fd4cbe3d9409277 + sha256: "10911465138fafacef459a780564e883e01bd48eabf87ab20543684884492870" url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "6.2.1" record_android: dependency: transitive description: name: record_android - sha256: "94783f08403aed33ffb68797bf0715b0812eb852f3c7985644c945faea462ba1" + sha256: eb1732e42d0d2a1895b8db86e4fc917287e6d8491b6ed59918aea8bed6c69de4 url: "https://pub.dev" source: hosted - version: "1.5.1" + version: "1.5.2" record_ios: dependency: transitive description: name: record_ios - sha256: "8df7c136131bd05efc19256af29b2ba6ccc000ccc2c80d4b6b6d7a8d21a3b5a9" + sha256: c051fb48edd7a0e265daafb9108730dc827c27b551728a3fdfb3ef69efd89c73 url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.2.1" record_linux: dependency: transitive description: name: record_linux - sha256: c31a35cc158cd666fc6395f7f56fc054f31685571684be6b97670a27649ce5c7 + sha256: "31181787bf7eccb0e298835836b69b3cd0a903863b75d70e937de3dec71cd8f3" url: "https://pub.dev" source: hosted - version: "1.3.0" + version: "1.3.1" record_macos: dependency: transitive description: name: record_macos - sha256: "084902e63fc9c0c224c29203d6c75f0bdf9b6a40536c9d916393c8f4c4256488" + sha256: cfe1b61435e27db418bf513dc36820d10c9f7eb1843786c2c9a52e07e2f4f627 url: "https://pub.dev" source: hosted - version: "1.2.1" + version: "1.2.2" record_platform_interface: dependency: transitive description: name: record_platform_interface - sha256: "8a81dbc4e14e1272a285bbfef6c9136d070a47d9b0d1f40aa6193516253ee2f6" + sha256: "8e56cbe06c6984137fb86132ff03459f29938d927496d9b2d0962e2d6345d488" url: "https://pub.dev" source: hosted - version: "1.5.0" + version: "1.6.0" record_use: dependency: transitive description: From 27ccd2db8a25b191ec2d1e4475db6bbf64e4e537 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 27 May 2026 13:41:55 +0200 Subject: [PATCH 33/34] feat(app_upgrade): add highlights for version 1.3.0 with audio recording features --- .../lib/models/app_upgrade_registry.dart | 39 +++++++++++++++++++ open_wearable/pubspec.yaml | 2 +- .../models/app_upgrade_registry_test.dart | 10 ++--- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/open_wearable/lib/models/app_upgrade_registry.dart b/open_wearable/lib/models/app_upgrade_registry.dart index b3e7cc04..a7e5fe16 100644 --- a/open_wearable/lib/models/app_upgrade_registry.dart +++ b/open_wearable/lib/models/app_upgrade_registry.dart @@ -103,6 +103,45 @@ class AppUpgradeRegistry { ), ], ), + AppUpgradeHighlight( + version: '1.3.0', + eyebrow: 'OpenWearables 1.3.0', + title: 'Record audio alongside\nyour sensor data', + summary: + 'Capture microphone audio in the local recorder and export synchronized session files.', + heroDescription: + 'The local recorder now supports audio capture, so experiments can pair wearable sensor streams ' + 'with microphone recordings from the app. Select an input, monitor the waveform, and save audio ' + 'together with the rest of the session.', + accentColor: Color(0xFF8F6A67), + useHeroGradient: false, + features: [ + AppUpgradeFeatureHighlight( + icon: Icons.mic_rounded, + title: 'Audio recorder', + description: + 'Record microphone audio directly from the app as part of a local recording session.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.graphic_eq_rounded, + title: 'Live waveform monitoring', + description: + 'Preview incoming audio levels before and during capture to verify the selected input.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.input_rounded, + title: 'Selectable input source', + description: + 'Choose from available microphone inputs and apply the selected source before recording.', + ), + AppUpgradeFeatureHighlight( + icon: Icons.folder_zip_rounded, + title: 'Session exports', + description: + 'Audio files are saved with the same recording session as sensor data for easier review and sharing.', + ), + ], + ), ]; /// Returns the configured highlight for [version], if any. diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 381ca02b..305d96de 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 1.2.0+1 +version: 1.3.0+1 environment: sdk: ^3.6.0 diff --git a/open_wearable/test/models/app_upgrade_registry_test.dart b/open_wearable/test/models/app_upgrade_registry_test.dart index e6970637..382068ed 100644 --- a/open_wearable/test/models/app_upgrade_registry_test.dart +++ b/open_wearable/test/models/app_upgrade_registry_test.dart @@ -3,13 +3,13 @@ import 'package:open_wearable/models/app_upgrade_registry.dart'; void main() { group('AppUpgradeRegistry', () { - test('registers version 1.2.0 as the latest upgrade highlight', () { - final highlight = AppUpgradeRegistry.forVersion('1.2.0'); + test('registers version 1.3.0 as the latest upgrade highlight', () { + final highlight = AppUpgradeRegistry.forVersion('1.3.0'); expect(highlight, isNotNull); - expect(highlight?.version, '1.2.0'); - expect(AppUpgradeRegistry.latest?.version, '1.2.0'); - expect(AppUpgradeRegistry.all.first.version, '1.2.0'); + expect(highlight?.version, '1.3.0'); + expect(AppUpgradeRegistry.latest?.version, '1.3.0'); + expect(AppUpgradeRegistry.all.first.version, '1.3.0'); }); }); } From 28174d248cc3bd783309aed9b592ee4efa6495d5 Mon Sep 17 00:00:00 2001 From: Dennis <45356478+DennisMoschina@users.noreply.github.com> Date: Wed, 27 May 2026 15:16:01 +0200 Subject: [PATCH 34/34] chore(dependencies): update open_earable_flutter to version 2.3.9 --- open_wearable/pubspec.lock | 4 ++-- open_wearable/pubspec.yaml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/open_wearable/pubspec.lock b/open_wearable/pubspec.lock index 32541497..7d977cee 100644 --- a/open_wearable/pubspec.lock +++ b/open_wearable/pubspec.lock @@ -612,10 +612,10 @@ packages: dependency: "direct main" description: name: open_earable_flutter - sha256: ddacc55c036487b5be86e997e636faea81f861a5c6bddbdaaa6984f4d9d8bd40 + sha256: "7e00b89c1738a61d24852140bb10d5a36e4a687c7c81e8fe5eef01dc0f7c25ac" url: "https://pub.dev" source: hosted - version: "2.3.8" + version: "2.3.9" open_file: dependency: "direct main" description: diff --git a/open_wearable/pubspec.yaml b/open_wearable/pubspec.yaml index 305d96de..b76ea0f2 100644 --- a/open_wearable/pubspec.yaml +++ b/open_wearable/pubspec.yaml @@ -35,7 +35,7 @@ dependencies: # Use with the CupertinoIcons class for iOS style icons. cupertino_icons: ^1.0.8 open_file: ^3.3.2 - open_earable_flutter: ^2.3.8 + open_earable_flutter: ^2.3.9 universal_ble: ^2.0.1 flutter_platform_widgets: ^10.0.1 provider: ^6.1.2