neoagent 3.1.1-beta.9 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +2 -2
- package/README.md +5 -1
- package/docs/architecture.md +1 -1
- package/docs/benchmarking.md +68 -87
- package/docs/capabilities.md +1 -1
- package/docs/clients-and-devices.md +3 -4
- package/docs/configuration.md +1 -1
- package/docs/devices.md +1 -2
- package/docs/health.md +24 -0
- package/docs/index.md +2 -2
- package/docs/integrations-architecture.md +6 -0
- package/docs/integrations.md +15 -1
- package/docs/models.md +1 -1
- package/docs/persistence.md +1 -1
- package/docs/why-neoagent.md +1 -1
- package/flutter_app/README.md +0 -5
- package/flutter_app/android/app/src/main/AndroidManifest.xml +0 -14
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/MainActivity.kt +0 -98
- package/flutter_app/lib/main.dart +0 -6
- package/flutter_app/lib/main_app_shell.dart +0 -2
- package/flutter_app/lib/main_controller.dart +2 -867
- package/flutter_app/lib/main_devices.dart +0 -73
- package/flutter_app/lib/main_integrations.dart +339 -187
- package/flutter_app/lib/main_launcher.dart +2 -212
- package/flutter_app/lib/main_models.dart +0 -259
- package/flutter_app/lib/main_navigation.dart +2 -14
- package/flutter_app/lib/main_operations.dart +322 -364
- package/flutter_app/lib/main_runtime.dart +9 -208
- package/flutter_app/lib/main_settings.dart +17 -199
- package/flutter_app/lib/main_shared.dart +20 -591
- package/flutter_app/lib/main_timeline.dart +26 -122
- package/flutter_app/lib/main_voice_assistant.dart +0 -5
- package/flutter_app/lib/src/backend_client.dart +0 -102
- package/flutter_app/lib/src/desktop_companion_io.dart +1 -65
- package/flutter_app/lib/src/desktop_companion_stub.dart +0 -12
- package/flutter_app/linux/flutter/generated_plugin_registrant.cc +0 -4
- package/flutter_app/linux/flutter/generated_plugins.cmake +0 -1
- package/flutter_app/macos/Flutter/GeneratedPluginRegistrant.swift +0 -2
- package/flutter_app/macos/Podfile.lock +56 -5
- package/flutter_app/pubspec.lock +0 -7
- package/flutter_app/pubspec.yaml +0 -2
- package/flutter_app/web/index.html +174 -0
- package/flutter_app/windows/flutter/generated_plugin_registrant.cc +0 -3
- package/flutter_app/windows/flutter/generated_plugins.cmake +0 -1
- package/landing/index.html +0 -2
- package/lib/manager.js +1 -1
- package/lib/schema_migrations.js +75 -0
- package/package.json +1 -4
- package/server/db/database.js +1 -133
- package/server/http/middleware.js +2 -16
- package/server/http/routes.js +0 -2
- package/server/public/.last_build_id +1 -1
- package/server/public/assets/NOTICES +0 -25
- package/server/public/assets/fonts/MaterialIcons-Regular.otf +0 -0
- package/server/public/flutter_bootstrap.js +1 -1
- package/server/public/index.html +174 -0
- package/server/public/main.dart.js +88081 -90036
- package/server/routes/auth.js +11 -2
- package/server/routes/settings.js +0 -5
- package/server/routes/voice_assistant.js +1 -128
- package/server/services/account/erasure.js +0 -3
- package/server/services/ai/deliverables/artifact_helpers.js +1 -1
- package/server/services/ai/loop/conversation_loop.js +6 -2
- package/server/services/ai/loop/tool_dispatch.js +0 -3
- package/server/services/ai/messagingFallback.js +1 -1
- package/server/services/ai/settings.js +0 -6
- package/server/services/ai/toolEvidence.js +1 -4
- package/server/services/ai/tools.js +0 -234
- package/server/services/desktop/auth.js +0 -3
- package/server/services/desktop/registry.js +2 -50
- package/server/services/integrations/neoarchive/provider.js +616 -0
- package/server/services/integrations/neorecall/client.js +140 -0
- package/server/services/integrations/neorecall/constants.js +87 -0
- package/server/services/integrations/neorecall/provider.js +163 -0
- package/server/services/integrations/registry.js +4 -2
- package/server/services/manager.js +0 -22
- package/server/services/memory/manager.js +44 -19
- package/server/services/memory/retrieval_reasoning.js +36 -14
- package/server/services/messaging/whatsapp.js +18 -0
- package/server/services/security/tool_categories.js +0 -5
- package/server/services/social_video/service.js +2 -20
- package/server/services/tasks/adapters/index.js +1 -1
- package/server/services/tasks/integration_runtime.js +3 -67
- package/server/services/tasks/runtime.js +1 -6
- package/server/services/tasks/task_repository.js +6 -13
- package/server/services/timeline/service.js +0 -224
- package/server/services/voice/message.js +0 -24
- package/server/services/voice/providers.js +42 -1
- package/server/services/voice/runtimeManager.js +9 -5
- package/server/services/voice/turnRunner.js +0 -22
- package/server/services/wearable/service.js +0 -5
- package/docs/recordings-and-health.md +0 -41
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingBootReceiver.kt +0 -21
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingForegroundService.kt +0 -586
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingStateStore.kt +0 -78
- package/flutter_app/android/app/src/main/kotlin/com/neoagent/flutter_app/recording/RecordingUploadClient.kt +0 -104
- package/flutter_app/lib/main_recordings.dart +0 -920
- package/flutter_app/lib/src/desktop_ocr_bridge.dart +0 -2
- package/flutter_app/lib/src/desktop_ocr_bridge_io.dart +0 -125
- package/flutter_app/lib/src/desktop_ocr_bridge_stub.dart +0 -30
- package/flutter_app/lib/src/desktop_passive_history.dart +0 -332
- package/flutter_app/lib/src/recording_bridge.dart +0 -232
- package/flutter_app/lib/src/recording_bridge_io.dart +0 -1027
- package/flutter_app/lib/src/recording_bridge_stub.dart +0 -120
- package/flutter_app/lib/src/recording_bridge_web.dart +0 -702
- package/flutter_app/lib/src/recording_chunk_queue.dart +0 -149
- package/flutter_app/lib/src/recording_chunk_queue_io.dart +0 -182
- package/flutter_app/lib/src/recording_payloads.dart +0 -95
- package/flutter_app/third_party/desktop_audio_capture/LICENSE +0 -21
- package/flutter_app/third_party/desktop_audio_capture/README.md +0 -262
- package/flutter_app/third_party/desktop_audio_capture/lib/audio_capture.dart +0 -65
- package/flutter_app/third_party/desktop_audio_capture/lib/config/mic_audio_config.dart +0 -153
- package/flutter_app/third_party/desktop_audio_capture/lib/config/system_adudio_config.dart +0 -110
- package/flutter_app/third_party/desktop_audio_capture/lib/mic/mic_audio_capture.dart +0 -461
- package/flutter_app/third_party/desktop_audio_capture/lib/model/audio_status.dart +0 -91
- package/flutter_app/third_party/desktop_audio_capture/lib/model/decibel_data.dart +0 -106
- package/flutter_app/third_party/desktop_audio_capture/lib/model/input_device_type.dart +0 -219
- package/flutter_app/third_party/desktop_audio_capture/lib/system/system_audio_capture.dart +0 -336
- package/flutter_app/third_party/desktop_audio_capture/linux/CMakeLists.txt +0 -101
- package/flutter_app/third_party/desktop_audio_capture/linux/audio_capture_plugin.cc +0 -692
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/audio_capture_plugin.h +0 -35
- package/flutter_app/third_party/desktop_audio_capture/linux/include/audio_capture/mic_capture_plugin.h +0 -36
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/audio_capture_plugin.h +0 -32
- package/flutter_app/third_party/desktop_audio_capture/linux/include/desktop_audio_capture/mic_capture_plugin.h +0 -32
- package/flutter_app/third_party/desktop_audio_capture/linux/mic_capture_plugin.cc +0 -878
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/AudioCapturePlugin.swift +0 -27
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/MicCapturePlugin.swift +0 -1172
- package/flutter_app/third_party/desktop_audio_capture/macos/Classes/SystemCapturePlugin.swift +0 -655
- package/flutter_app/third_party/desktop_audio_capture/macos/Resources/PrivacyInfo.xcprivacy +0 -12
- package/flutter_app/third_party/desktop_audio_capture/macos/desktop_audio_capture.podspec +0 -30
- package/flutter_app/third_party/desktop_audio_capture/pubspec.yaml +0 -87
- package/flutter_app/third_party/desktop_audio_capture/windows/CMakeLists.txt +0 -105
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.cpp +0 -80
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin.h +0 -31
- package/flutter_app/third_party/desktop_audio_capture/windows/audio_capture_plugin_c_api.cpp +0 -12
- package/flutter_app/third_party/desktop_audio_capture/windows/include/audio_capture/audio_capture_plugin_c_api.h +0 -23
- package/flutter_app/third_party/desktop_audio_capture/windows/include/desktop_audio_capture/audio_capture_plugin.h +0 -25
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.cpp +0 -1117
- package/flutter_app/third_party/desktop_audio_capture/windows/mic_capture_plugin.h +0 -115
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.cpp +0 -777
- package/flutter_app/third_party/desktop_audio_capture/windows/system_audio_capture_plugin.h +0 -87
- package/server/routes/recordings.js +0 -335
- package/server/routes/screenHistory.js +0 -190
- package/server/services/ai/recordingInsights.js +0 -105
- package/server/services/integrations/neomail/provider.js +0 -993
- package/server/services/recordings/deepgram.js +0 -58
- package/server/services/recordings/manager.js +0 -1273
- package/server/services/tasks/adapters/neomail_email_received.js +0 -46
- package/server/services/voice/screenshotContext.js +0 -73
- package/server/services/voice/turnCoordinator.js +0 -51
|
@@ -1,702 +0,0 @@
|
|
|
1
|
-
// ignore_for_file: avoid_web_libraries_in_flutter, deprecated_member_use
|
|
2
|
-
|
|
3
|
-
import 'dart:async';
|
|
4
|
-
import 'dart:html' as html;
|
|
5
|
-
import 'dart:js_util' as js_util;
|
|
6
|
-
import 'dart:typed_data';
|
|
7
|
-
|
|
8
|
-
import 'diagnostics_logger.dart';
|
|
9
|
-
import 'recording_bridge.dart';
|
|
10
|
-
import 'recording_chunk_queue.dart';
|
|
11
|
-
|
|
12
|
-
RecordingBridge createPlatformRecordingBridge() => WebRecordingBridge();
|
|
13
|
-
|
|
14
|
-
class WebRecordingBridge extends RecordingBridge {
|
|
15
|
-
RecordingRuntimeStatus _status = const RecordingRuntimeStatus(
|
|
16
|
-
supportsScreenAndMic: true,
|
|
17
|
-
supportsBackgroundMic: false,
|
|
18
|
-
platformLabel: 'Browser screen + microphone recorder',
|
|
19
|
-
);
|
|
20
|
-
|
|
21
|
-
html.MediaStream? _displayStream;
|
|
22
|
-
html.MediaStream? _systemAudioStream;
|
|
23
|
-
html.MediaStream? _microphoneStream;
|
|
24
|
-
html.MediaRecorder? _screenRecorder;
|
|
25
|
-
html.MediaRecorder? _systemAudioRecorder;
|
|
26
|
-
html.MediaRecorder? _microphoneRecorder;
|
|
27
|
-
final Map<String, int> _nextSequenceBySource = <String, int>{};
|
|
28
|
-
final Map<String, int> _lastEndMsBySource = <String, int>{};
|
|
29
|
-
// One durable, retry-until-success queue per source so a failed upload is
|
|
30
|
-
// re-sent instead of silently dropped, and a stall in one source does not
|
|
31
|
-
// block the others.
|
|
32
|
-
final Map<String, MemoryChunkUploadQueue> _uploadQueues =
|
|
33
|
-
<String, MemoryChunkUploadQueue>{};
|
|
34
|
-
Stopwatch? _stopwatch;
|
|
35
|
-
String? _baseUrl;
|
|
36
|
-
String? _sessionId;
|
|
37
|
-
bool _stopping = false;
|
|
38
|
-
StreamSubscription<html.Event>? _displayEndedSub;
|
|
39
|
-
static const Set<String> _safeUploadHeaderKeys = <String>{
|
|
40
|
-
'content-type',
|
|
41
|
-
'x-recording-sequence',
|
|
42
|
-
'x-recording-start-ms',
|
|
43
|
-
'x-recording-end-ms',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
@override
|
|
47
|
-
RecordingRuntimeStatus get status => _status;
|
|
48
|
-
|
|
49
|
-
void _log(
|
|
50
|
-
String event, {
|
|
51
|
-
Map<String, Object?> data = const <String, Object?>{},
|
|
52
|
-
Object? error,
|
|
53
|
-
StackTrace? stackTrace,
|
|
54
|
-
}) {
|
|
55
|
-
AppDiagnostics.log(
|
|
56
|
-
'recording.bridge.web',
|
|
57
|
-
event,
|
|
58
|
-
data: data,
|
|
59
|
-
error: error,
|
|
60
|
-
stackTrace: stackTrace,
|
|
61
|
-
);
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
@override
|
|
65
|
-
Future<void> refreshStatus() async {}
|
|
66
|
-
|
|
67
|
-
@override
|
|
68
|
-
Future<void> startWebRecording({
|
|
69
|
-
required String baseUrl,
|
|
70
|
-
required String sessionId,
|
|
71
|
-
}) async {
|
|
72
|
-
_log(
|
|
73
|
-
'start_web.request',
|
|
74
|
-
data: <String, Object?>{
|
|
75
|
-
'sessionId': sessionId,
|
|
76
|
-
'baseUrl': baseUrl,
|
|
77
|
-
'alreadyActive': _status.active,
|
|
78
|
-
},
|
|
79
|
-
);
|
|
80
|
-
if (_status.active) {
|
|
81
|
-
throw const RecordingBridgeException(
|
|
82
|
-
'A recording is already in progress.',
|
|
83
|
-
);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
final mediaDevices = html.window.navigator.mediaDevices;
|
|
88
|
-
if (mediaDevices == null) {
|
|
89
|
-
throw const RecordingBridgeException(
|
|
90
|
-
'This browser does not expose media device APIs.',
|
|
91
|
-
);
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
final displayStream = await js_util.promiseToFuture<html.MediaStream>(
|
|
95
|
-
js_util.callMethod(mediaDevices, 'getDisplayMedia', <Object?>[
|
|
96
|
-
js_util.jsify(<String, Object>{'video': true, 'audio': true}),
|
|
97
|
-
]),
|
|
98
|
-
);
|
|
99
|
-
if (displayStream.getVideoTracks().isEmpty) {
|
|
100
|
-
throw const RecordingBridgeException(
|
|
101
|
-
'Screen sharing was cancelled before capture started.',
|
|
102
|
-
);
|
|
103
|
-
}
|
|
104
|
-
if (displayStream.getAudioTracks().isEmpty) {
|
|
105
|
-
displayStream.getTracks().forEach((track) => track.stop());
|
|
106
|
-
throw const RecordingBridgeException(
|
|
107
|
-
'Screen share did not include audio. Share a tab/window and enable audio.',
|
|
108
|
-
);
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
final microphoneStream = await mediaDevices.getUserMedia(
|
|
112
|
-
<String, dynamic>{
|
|
113
|
-
'audio': <String, dynamic>{
|
|
114
|
-
'channelCount': 1,
|
|
115
|
-
'echoCancellation': false,
|
|
116
|
-
'noiseSuppression': false,
|
|
117
|
-
'autoGainControl': false,
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
);
|
|
121
|
-
if (microphoneStream.getAudioTracks().isEmpty) {
|
|
122
|
-
displayStream.getTracks().forEach((track) => track.stop());
|
|
123
|
-
throw const RecordingBridgeException(
|
|
124
|
-
'Microphone permission is required to start recording.',
|
|
125
|
-
);
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
// Split the display capture: video-only feeds the screen-analysis source,
|
|
129
|
-
// while the shared system/tab audio gets its own transcribable source so
|
|
130
|
-
// remote participants are captured in the transcript (the mic alone does
|
|
131
|
-
// not reliably pick up other speakers, e.g. when wearing headphones).
|
|
132
|
-
final screenVideoStream = html.MediaStream();
|
|
133
|
-
for (final track in displayStream.getVideoTracks()) {
|
|
134
|
-
screenVideoStream.addTrack(track);
|
|
135
|
-
}
|
|
136
|
-
final systemAudioStream = html.MediaStream();
|
|
137
|
-
for (final track in displayStream.getAudioTracks()) {
|
|
138
|
-
systemAudioStream.addTrack(track);
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
_baseUrl = baseUrl;
|
|
142
|
-
_sessionId = sessionId;
|
|
143
|
-
_displayStream = displayStream;
|
|
144
|
-
_systemAudioStream = systemAudioStream;
|
|
145
|
-
_microphoneStream = microphoneStream;
|
|
146
|
-
_nextSequenceBySource
|
|
147
|
-
..clear()
|
|
148
|
-
..addAll(<String, int>{'screen': 0, 'system': 0, 'microphone': 0});
|
|
149
|
-
_lastEndMsBySource
|
|
150
|
-
..clear()
|
|
151
|
-
..addAll(<String, int>{'screen': 0, 'system': 0, 'microphone': 0});
|
|
152
|
-
_initUploadQueues(<String>['screen', 'system', 'microphone']);
|
|
153
|
-
_stopwatch = Stopwatch()..start();
|
|
154
|
-
|
|
155
|
-
final screenMimeType = _pickMimeType(<String>[
|
|
156
|
-
'video/webm;codecs=vp9,opus',
|
|
157
|
-
'video/webm;codecs=vp8,opus',
|
|
158
|
-
'video/webm',
|
|
159
|
-
]);
|
|
160
|
-
final audioMimeType = _pickMimeType(<String>[
|
|
161
|
-
'audio/webm;codecs=opus',
|
|
162
|
-
'audio/webm',
|
|
163
|
-
]);
|
|
164
|
-
|
|
165
|
-
_screenRecorder = html.MediaRecorder(
|
|
166
|
-
screenVideoStream,
|
|
167
|
-
screenMimeType == null
|
|
168
|
-
? null
|
|
169
|
-
: <String, String>{'mimeType': screenMimeType},
|
|
170
|
-
);
|
|
171
|
-
|
|
172
|
-
_displayEndedSub = displayStream.getVideoTracks().first.onEnded.listen((
|
|
173
|
-
_,
|
|
174
|
-
) {
|
|
175
|
-
unawaited(_handleExternalStop());
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
_bindRecorder(
|
|
179
|
-
recorder: _screenRecorder!,
|
|
180
|
-
sourceKey: 'screen',
|
|
181
|
-
mimeType: screenMimeType ?? 'video/webm',
|
|
182
|
-
);
|
|
183
|
-
_systemAudioRecorder = html.MediaRecorder(
|
|
184
|
-
systemAudioStream,
|
|
185
|
-
audioMimeType == null ? null : <String, String>{'mimeType': audioMimeType},
|
|
186
|
-
);
|
|
187
|
-
_bindRecorder(
|
|
188
|
-
recorder: _systemAudioRecorder!,
|
|
189
|
-
sourceKey: 'system',
|
|
190
|
-
mimeType: audioMimeType ?? 'audio/webm',
|
|
191
|
-
);
|
|
192
|
-
_microphoneRecorder = html.MediaRecorder(
|
|
193
|
-
microphoneStream,
|
|
194
|
-
audioMimeType == null ? null : <String, String>{'mimeType': audioMimeType},
|
|
195
|
-
);
|
|
196
|
-
_bindRecorder(
|
|
197
|
-
recorder: _microphoneRecorder!,
|
|
198
|
-
sourceKey: 'microphone',
|
|
199
|
-
mimeType: audioMimeType ?? 'audio/webm',
|
|
200
|
-
);
|
|
201
|
-
|
|
202
|
-
_screenRecorder!.start(4000);
|
|
203
|
-
_systemAudioRecorder!.start(4000);
|
|
204
|
-
_microphoneRecorder!.start(4000);
|
|
205
|
-
_status = _status.copyWith(
|
|
206
|
-
active: true,
|
|
207
|
-
paused: false,
|
|
208
|
-
sessionId: sessionId,
|
|
209
|
-
startedAt: DateTime.now(),
|
|
210
|
-
errorMessage: null,
|
|
211
|
-
);
|
|
212
|
-
_log(
|
|
213
|
-
'start_web.done',
|
|
214
|
-
data: <String, Object?>{
|
|
215
|
-
'sessionId': sessionId,
|
|
216
|
-
'screenMimeType': screenMimeType ?? 'video/webm',
|
|
217
|
-
'systemMimeType': audioMimeType ?? 'audio/webm',
|
|
218
|
-
'micMimeType': audioMimeType ?? 'audio/webm',
|
|
219
|
-
},
|
|
220
|
-
);
|
|
221
|
-
notifyListeners();
|
|
222
|
-
} catch (error) {
|
|
223
|
-
await _disposeStreams();
|
|
224
|
-
_status = _status.copyWith(
|
|
225
|
-
active: false,
|
|
226
|
-
paused: false,
|
|
227
|
-
sessionId: null,
|
|
228
|
-
startedAt: null,
|
|
229
|
-
errorMessage: error.toString(),
|
|
230
|
-
);
|
|
231
|
-
_log('start_web.failed', error: error);
|
|
232
|
-
notifyListeners();
|
|
233
|
-
rethrow;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
@override
|
|
238
|
-
Future<void> startWebMicrophoneRecording({
|
|
239
|
-
required String baseUrl,
|
|
240
|
-
required String sessionId,
|
|
241
|
-
}) async {
|
|
242
|
-
_log(
|
|
243
|
-
'start_web_mic_only.request',
|
|
244
|
-
data: <String, Object?>{
|
|
245
|
-
'sessionId': sessionId,
|
|
246
|
-
'baseUrl': baseUrl,
|
|
247
|
-
'alreadyActive': _status.active,
|
|
248
|
-
},
|
|
249
|
-
);
|
|
250
|
-
if (_status.active) {
|
|
251
|
-
throw const RecordingBridgeException(
|
|
252
|
-
'A recording is already in progress.',
|
|
253
|
-
);
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
try {
|
|
257
|
-
final mediaDevices = html.window.navigator.mediaDevices;
|
|
258
|
-
if (mediaDevices == null) {
|
|
259
|
-
throw const RecordingBridgeException(
|
|
260
|
-
'This browser does not expose media device APIs.',
|
|
261
|
-
);
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
final microphoneStream = await mediaDevices.getUserMedia(
|
|
265
|
-
<String, dynamic>{
|
|
266
|
-
'audio': <String, dynamic>{
|
|
267
|
-
'channelCount': 1,
|
|
268
|
-
'echoCancellation': false,
|
|
269
|
-
'noiseSuppression': false,
|
|
270
|
-
'autoGainControl': false,
|
|
271
|
-
},
|
|
272
|
-
},
|
|
273
|
-
);
|
|
274
|
-
if (microphoneStream.getAudioTracks().isEmpty) {
|
|
275
|
-
throw const RecordingBridgeException(
|
|
276
|
-
'Microphone permission is required to start recording.',
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
_baseUrl = baseUrl;
|
|
281
|
-
_sessionId = sessionId;
|
|
282
|
-
_displayStream = null;
|
|
283
|
-
_microphoneStream = microphoneStream;
|
|
284
|
-
_nextSequenceBySource
|
|
285
|
-
..clear()
|
|
286
|
-
..addAll(<String, int>{'microphone': 0});
|
|
287
|
-
_lastEndMsBySource
|
|
288
|
-
..clear()
|
|
289
|
-
..addAll(<String, int>{'microphone': 0});
|
|
290
|
-
_initUploadQueues(<String>['microphone']);
|
|
291
|
-
_stopwatch = Stopwatch()..start();
|
|
292
|
-
|
|
293
|
-
final micMimeType = _pickMimeType(<String>[
|
|
294
|
-
'audio/webm;codecs=opus',
|
|
295
|
-
'audio/webm',
|
|
296
|
-
]);
|
|
297
|
-
|
|
298
|
-
_displayEndedSub = null;
|
|
299
|
-
_screenRecorder = null;
|
|
300
|
-
_microphoneRecorder = html.MediaRecorder(
|
|
301
|
-
microphoneStream,
|
|
302
|
-
micMimeType == null ? null : <String, String>{'mimeType': micMimeType},
|
|
303
|
-
);
|
|
304
|
-
_bindRecorder(
|
|
305
|
-
recorder: _microphoneRecorder!,
|
|
306
|
-
sourceKey: 'microphone',
|
|
307
|
-
mimeType: micMimeType ?? 'audio/webm',
|
|
308
|
-
);
|
|
309
|
-
|
|
310
|
-
_microphoneRecorder!.start(4000);
|
|
311
|
-
_status = _status.copyWith(
|
|
312
|
-
active: true,
|
|
313
|
-
paused: false,
|
|
314
|
-
sessionId: sessionId,
|
|
315
|
-
startedAt: DateTime.now(),
|
|
316
|
-
errorMessage: null,
|
|
317
|
-
);
|
|
318
|
-
_log(
|
|
319
|
-
'start_web_mic_only.done',
|
|
320
|
-
data: <String, Object?>{
|
|
321
|
-
'sessionId': sessionId,
|
|
322
|
-
'micMimeType': micMimeType ?? 'audio/webm',
|
|
323
|
-
},
|
|
324
|
-
);
|
|
325
|
-
notifyListeners();
|
|
326
|
-
} catch (error) {
|
|
327
|
-
await _disposeStreams();
|
|
328
|
-
_status = _status.copyWith(
|
|
329
|
-
active: false,
|
|
330
|
-
paused: false,
|
|
331
|
-
sessionId: null,
|
|
332
|
-
startedAt: null,
|
|
333
|
-
errorMessage: error.toString(),
|
|
334
|
-
);
|
|
335
|
-
_log('start_web_mic_only.failed', error: error);
|
|
336
|
-
notifyListeners();
|
|
337
|
-
rethrow;
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
@override
|
|
342
|
-
Future<void> startBackgroundRecording({
|
|
343
|
-
required String baseUrl,
|
|
344
|
-
required String sessionCookie,
|
|
345
|
-
required String sessionId,
|
|
346
|
-
}) async {
|
|
347
|
-
throw const RecordingBridgeException(
|
|
348
|
-
'Background microphone recording is available on Android only.',
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
@override
|
|
353
|
-
Future<void> startDesktopAudioRecording({
|
|
354
|
-
required String baseUrl,
|
|
355
|
-
required String sessionCookie,
|
|
356
|
-
required String sessionId,
|
|
357
|
-
bool autoShowToolbar = true,
|
|
358
|
-
}) async {
|
|
359
|
-
throw const RecordingBridgeException(
|
|
360
|
-
'Native desktop audio recording is not available in the browser.',
|
|
361
|
-
);
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
@override
|
|
365
|
-
Future<void> pauseBackgroundRecording() async {
|
|
366
|
-
throw const RecordingBridgeException(
|
|
367
|
-
'Pause is only available for Android background recording.',
|
|
368
|
-
);
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
@override
|
|
372
|
-
Future<void> pauseDesktopRecording() async {
|
|
373
|
-
throw const RecordingBridgeException(
|
|
374
|
-
'Desktop recording controls are not available in the browser.',
|
|
375
|
-
);
|
|
376
|
-
}
|
|
377
|
-
|
|
378
|
-
@override
|
|
379
|
-
Future<void> resumeBackgroundRecording() async {
|
|
380
|
-
throw const RecordingBridgeException(
|
|
381
|
-
'Resume is only available for Android background recording.',
|
|
382
|
-
);
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
@override
|
|
386
|
-
Future<void> resumeDesktopRecording() async {
|
|
387
|
-
throw const RecordingBridgeException(
|
|
388
|
-
'Desktop recording controls are not available in the browser.',
|
|
389
|
-
);
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
@override
|
|
393
|
-
Future<void> showFloatingToolbar() async {}
|
|
394
|
-
|
|
395
|
-
@override
|
|
396
|
-
Future<void> hideFloatingToolbar() async {}
|
|
397
|
-
|
|
398
|
-
@override
|
|
399
|
-
Future<void> openMicrophoneSettings() async {
|
|
400
|
-
throw const RecordingBridgeException(
|
|
401
|
-
'Open the browser site settings to manage microphone access.',
|
|
402
|
-
);
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
@override
|
|
406
|
-
Future<void> openSystemAudioSettings() async {
|
|
407
|
-
throw const RecordingBridgeException(
|
|
408
|
-
'Browser screen-share audio permissions are controlled by the browser.',
|
|
409
|
-
);
|
|
410
|
-
}
|
|
411
|
-
|
|
412
|
-
@override
|
|
413
|
-
Future<void> stopActiveRecording({bool notifyEnded = false}) async {
|
|
414
|
-
if (_stopping || !_status.active) {
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
_stopping = true;
|
|
418
|
-
final sessionId = _sessionId;
|
|
419
|
-
_log(
|
|
420
|
-
'stop_active.request',
|
|
421
|
-
data: <String, Object?>{
|
|
422
|
-
'sessionId': sessionId,
|
|
423
|
-
'notifyEnded': notifyEnded,
|
|
424
|
-
'active': _status.active,
|
|
425
|
-
},
|
|
426
|
-
);
|
|
427
|
-
try {
|
|
428
|
-
await _stopRecorders();
|
|
429
|
-
await Future.wait(
|
|
430
|
-
_uploadQueues.values.map((queue) => queue.flush()),
|
|
431
|
-
);
|
|
432
|
-
await _disposeStreams();
|
|
433
|
-
_status = _status.copyWith(
|
|
434
|
-
active: false,
|
|
435
|
-
paused: false,
|
|
436
|
-
startedAt: null,
|
|
437
|
-
sessionId: sessionId,
|
|
438
|
-
);
|
|
439
|
-
_log(
|
|
440
|
-
'stop_active.done',
|
|
441
|
-
data: <String, Object?>{
|
|
442
|
-
'sessionId': sessionId,
|
|
443
|
-
'notifyEnded': notifyEnded,
|
|
444
|
-
},
|
|
445
|
-
);
|
|
446
|
-
notifyListeners();
|
|
447
|
-
if (notifyEnded && sessionId != null && onRecordingStopped != null) {
|
|
448
|
-
_log(
|
|
449
|
-
'stop_active.notify_ended',
|
|
450
|
-
data: <String, Object?>{'sessionId': sessionId},
|
|
451
|
-
);
|
|
452
|
-
await onRecordingStopped!(sessionId);
|
|
453
|
-
}
|
|
454
|
-
} finally {
|
|
455
|
-
_stopping = false;
|
|
456
|
-
}
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
void _initUploadQueues(List<String> sourceKeys) {
|
|
460
|
-
for (final queue in _uploadQueues.values) {
|
|
461
|
-
unawaited(queue.dispose());
|
|
462
|
-
}
|
|
463
|
-
_uploadQueues
|
|
464
|
-
..clear()
|
|
465
|
-
..addEntries(
|
|
466
|
-
sourceKeys.map(
|
|
467
|
-
(sourceKey) => MapEntry<String, MemoryChunkUploadQueue>(
|
|
468
|
-
sourceKey,
|
|
469
|
-
MemoryChunkUploadQueue(
|
|
470
|
-
uploader: _performChunkUpload,
|
|
471
|
-
logger:
|
|
472
|
-
(
|
|
473
|
-
event, {
|
|
474
|
-
Map<String, Object?> data = const <String, Object?>{},
|
|
475
|
-
Object? error,
|
|
476
|
-
}) => _log(event, data: data, error: error),
|
|
477
|
-
),
|
|
478
|
-
),
|
|
479
|
-
),
|
|
480
|
-
);
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
void _bindRecorder({
|
|
484
|
-
required html.MediaRecorder recorder,
|
|
485
|
-
required String sourceKey,
|
|
486
|
-
required String mimeType,
|
|
487
|
-
}) {
|
|
488
|
-
recorder.addEventListener('dataavailable', (html.Event event) {
|
|
489
|
-
final blob = js_util.getProperty<html.Blob?>(event, 'data');
|
|
490
|
-
if (blob == null || blob.size == 0) {
|
|
491
|
-
return;
|
|
492
|
-
}
|
|
493
|
-
final endMs = _stopwatch?.elapsedMilliseconds ?? 0;
|
|
494
|
-
final startMs = _lastEndMsBySource[sourceKey] ?? 0;
|
|
495
|
-
_lastEndMsBySource[sourceKey] = endMs;
|
|
496
|
-
final sequence = _nextSequenceBySource[sourceKey] ?? 0;
|
|
497
|
-
_nextSequenceBySource[sourceKey] = sequence + 1;
|
|
498
|
-
final queue = _uploadQueues[sourceKey];
|
|
499
|
-
if (queue == null) {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
// Read the blob bytes, then hand the chunk to the durable queue, which
|
|
503
|
-
// uploads and retries until the server confirms it (so a transient
|
|
504
|
-
// failure no longer drops the chunk or stalls the rest of the source).
|
|
505
|
-
unawaited(() async {
|
|
506
|
-
try {
|
|
507
|
-
final bytes = await _blobToBytes(blob);
|
|
508
|
-
queue.enqueue(
|
|
509
|
-
PendingChunk(
|
|
510
|
-
sourceKey: sourceKey,
|
|
511
|
-
sequence: sequence,
|
|
512
|
-
startMs: startMs,
|
|
513
|
-
endMs: endMs,
|
|
514
|
-
mimeType: mimeType,
|
|
515
|
-
bytes: bytes,
|
|
516
|
-
),
|
|
517
|
-
);
|
|
518
|
-
} catch (error, stackTrace) {
|
|
519
|
-
_log(
|
|
520
|
-
'chunk.read.failed',
|
|
521
|
-
data: <String, Object?>{
|
|
522
|
-
'sourceKey': sourceKey,
|
|
523
|
-
'sequence': sequence,
|
|
524
|
-
},
|
|
525
|
-
error: error,
|
|
526
|
-
stackTrace: stackTrace,
|
|
527
|
-
);
|
|
528
|
-
}
|
|
529
|
-
}());
|
|
530
|
-
});
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
Future<void> _performChunkUpload(PendingChunk chunk) async {
|
|
534
|
-
final sessionId = _sessionId;
|
|
535
|
-
final baseUrl = _baseUrl;
|
|
536
|
-
if (sessionId == null || baseUrl == null) {
|
|
537
|
-
throw const RecordingBridgeException(
|
|
538
|
-
'Recording session is not initialized.',
|
|
539
|
-
);
|
|
540
|
-
}
|
|
541
|
-
final uri = _resolveUri(baseUrl, '/api/recordings/$sessionId/chunks');
|
|
542
|
-
final headers = <String, String>{
|
|
543
|
-
'Content-Type': chunk.mimeType,
|
|
544
|
-
'X-Recording-Source-Key': chunk.sourceKey,
|
|
545
|
-
'X-Recording-Sequence': '${chunk.sequence}',
|
|
546
|
-
'X-Recording-Start-Ms': '${chunk.startMs}',
|
|
547
|
-
'X-Recording-End-Ms': '${chunk.endMs}',
|
|
548
|
-
};
|
|
549
|
-
_log(
|
|
550
|
-
'chunk.upload.attempt',
|
|
551
|
-
data: <String, Object?>{
|
|
552
|
-
'sessionId': sessionId,
|
|
553
|
-
'sourceKey': chunk.sourceKey,
|
|
554
|
-
'sequence': chunk.sequence,
|
|
555
|
-
'size': chunk.bytes.length,
|
|
556
|
-
'headers': _filterHeadersForLog(headers),
|
|
557
|
-
},
|
|
558
|
-
);
|
|
559
|
-
await html.HttpRequest.request(
|
|
560
|
-
uri.toString(),
|
|
561
|
-
method: 'POST',
|
|
562
|
-
sendData: chunk.bytes,
|
|
563
|
-
requestHeaders: headers,
|
|
564
|
-
withCredentials: true,
|
|
565
|
-
);
|
|
566
|
-
_log(
|
|
567
|
-
'chunk.upload.done',
|
|
568
|
-
data: <String, Object?>{
|
|
569
|
-
'sessionId': sessionId,
|
|
570
|
-
'sourceKey': chunk.sourceKey,
|
|
571
|
-
'sequence': chunk.sequence,
|
|
572
|
-
'size': chunk.bytes.length,
|
|
573
|
-
},
|
|
574
|
-
);
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
Future<void> _handleExternalStop() async {
|
|
578
|
-
final sessionId = _sessionId;
|
|
579
|
-
if (sessionId == null) {
|
|
580
|
-
return;
|
|
581
|
-
}
|
|
582
|
-
_log(
|
|
583
|
-
'external_stop.detected',
|
|
584
|
-
data: <String, Object?>{'sessionId': sessionId},
|
|
585
|
-
);
|
|
586
|
-
await stopActiveRecording(notifyEnded: true);
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
Future<void> _stopRecorders() async {
|
|
590
|
-
final futures = <Future<void>>[];
|
|
591
|
-
if (_screenRecorder != null && _screenRecorder!.state != 'inactive') {
|
|
592
|
-
futures.add(_waitForStop(_screenRecorder!));
|
|
593
|
-
_screenRecorder!.stop();
|
|
594
|
-
}
|
|
595
|
-
if (_systemAudioRecorder != null &&
|
|
596
|
-
_systemAudioRecorder!.state != 'inactive') {
|
|
597
|
-
futures.add(_waitForStop(_systemAudioRecorder!));
|
|
598
|
-
_systemAudioRecorder!.stop();
|
|
599
|
-
}
|
|
600
|
-
if (_microphoneRecorder != null &&
|
|
601
|
-
_microphoneRecorder!.state != 'inactive') {
|
|
602
|
-
futures.add(_waitForStop(_microphoneRecorder!));
|
|
603
|
-
_microphoneRecorder!.stop();
|
|
604
|
-
}
|
|
605
|
-
await Future.wait(futures);
|
|
606
|
-
}
|
|
607
|
-
|
|
608
|
-
Future<void> _disposeStreams() async {
|
|
609
|
-
await _displayEndedSub?.cancel();
|
|
610
|
-
_displayEndedSub = null;
|
|
611
|
-
_displayStream?.getTracks().forEach((track) => track.stop());
|
|
612
|
-
_systemAudioStream?.getTracks().forEach((track) => track.stop());
|
|
613
|
-
_microphoneStream?.getTracks().forEach((track) => track.stop());
|
|
614
|
-
_displayStream = null;
|
|
615
|
-
_systemAudioStream = null;
|
|
616
|
-
_microphoneStream = null;
|
|
617
|
-
_screenRecorder = null;
|
|
618
|
-
_systemAudioRecorder = null;
|
|
619
|
-
_microphoneRecorder = null;
|
|
620
|
-
for (final queue in _uploadQueues.values) {
|
|
621
|
-
await queue.dispose();
|
|
622
|
-
}
|
|
623
|
-
_uploadQueues.clear();
|
|
624
|
-
_stopwatch?.stop();
|
|
625
|
-
_stopwatch = null;
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
Map<String, String> _filterHeadersForLog(Map<String, String> headers) {
|
|
629
|
-
final filtered = <String, String>{};
|
|
630
|
-
headers.forEach((key, value) {
|
|
631
|
-
if (_safeUploadHeaderKeys.contains(key.toLowerCase())) {
|
|
632
|
-
filtered[key] = value;
|
|
633
|
-
}
|
|
634
|
-
});
|
|
635
|
-
return filtered;
|
|
636
|
-
}
|
|
637
|
-
|
|
638
|
-
Future<Uint8List> _blobToBytes(html.Blob blob) {
|
|
639
|
-
final completer = Completer<Uint8List>();
|
|
640
|
-
final reader = html.FileReader();
|
|
641
|
-
reader.onError.first.then((_) {
|
|
642
|
-
if (!completer.isCompleted) {
|
|
643
|
-
completer.completeError(
|
|
644
|
-
const RecordingBridgeException(
|
|
645
|
-
'Could not read recorded browser chunk.',
|
|
646
|
-
),
|
|
647
|
-
);
|
|
648
|
-
}
|
|
649
|
-
});
|
|
650
|
-
reader.onLoadEnd.first.then((_) {
|
|
651
|
-
if (completer.isCompleted) {
|
|
652
|
-
return;
|
|
653
|
-
}
|
|
654
|
-
final result = reader.result;
|
|
655
|
-
if (result is ByteBuffer) {
|
|
656
|
-
completer.complete(result.asUint8List());
|
|
657
|
-
return;
|
|
658
|
-
}
|
|
659
|
-
if (result is Uint8List) {
|
|
660
|
-
completer.complete(result);
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
completer.completeError(
|
|
664
|
-
const RecordingBridgeException(
|
|
665
|
-
'Browser returned an unsupported chunk format.',
|
|
666
|
-
),
|
|
667
|
-
);
|
|
668
|
-
});
|
|
669
|
-
reader.readAsArrayBuffer(blob);
|
|
670
|
-
return completer.future;
|
|
671
|
-
}
|
|
672
|
-
|
|
673
|
-
Future<void> _waitForStop(html.MediaRecorder recorder) {
|
|
674
|
-
final completer = Completer<void>();
|
|
675
|
-
late html.EventListener listener;
|
|
676
|
-
listener = (html.Event _) {
|
|
677
|
-
recorder.removeEventListener('stop', listener);
|
|
678
|
-
if (!completer.isCompleted) {
|
|
679
|
-
completer.complete();
|
|
680
|
-
}
|
|
681
|
-
};
|
|
682
|
-
recorder.addEventListener('stop', listener);
|
|
683
|
-
return completer.future;
|
|
684
|
-
}
|
|
685
|
-
|
|
686
|
-
String? _pickMimeType(List<String> candidates) {
|
|
687
|
-
for (final candidate in candidates) {
|
|
688
|
-
if (html.MediaRecorder.isTypeSupported(candidate)) {
|
|
689
|
-
return candidate;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
return null;
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
Uri _resolveUri(String baseUrl, String path) {
|
|
696
|
-
final trimmed = baseUrl.trim();
|
|
697
|
-
if (trimmed.isEmpty) {
|
|
698
|
-
return Uri.parse(path);
|
|
699
|
-
}
|
|
700
|
-
return Uri.parse(trimmed.replaceFirst(RegExp(r'/$'), '') + path);
|
|
701
|
-
}
|
|
702
|
-
}
|