Merge pull request #342 from cogwheel0/audio-recording-playback-feature
feat(notes): Add audio recording and playback features
This commit is contained in:
@@ -15,3 +15,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
</vector>
|
</vector>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@
|
|||||||
</ripple>
|
</ripple>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,3 +15,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,3 +13,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
</vector>
|
</vector>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -8,3 +8,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@
|
|||||||
</ripple>
|
</ripple>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -54,3 +54,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,3 +130,4 @@
|
|||||||
</LinearLayout>
|
</LinearLayout>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -31,3 +31,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -10,3 +10,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -14,3 +14,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -18,3 +18,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -21,3 +21,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -17,3 +17,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 721 B After Width: | Height: | Size: 722 B |
@@ -39,3 +39,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -11,3 +11,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- audioplayers_darwin (0.0.1):
|
- audio_session (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
|
||||||
- connectivity_plus (0.0.1):
|
- connectivity_plus (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- CryptoSwift (1.8.4)
|
- CryptoSwift (1.8.4)
|
||||||
@@ -55,6 +54,8 @@ PODS:
|
|||||||
- SDWebImageWebPCoder
|
- SDWebImageWebPCoder
|
||||||
- flutter_local_notifications (0.0.1):
|
- flutter_local_notifications (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- flutter_native_splash (2.4.3):
|
||||||
|
- Flutter
|
||||||
- flutter_secure_storage_darwin (10.0.0):
|
- flutter_secure_storage_darwin (10.0.0):
|
||||||
- Flutter
|
- Flutter
|
||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
@@ -66,6 +67,9 @@ PODS:
|
|||||||
- Flutter
|
- Flutter
|
||||||
- irondash_engine_context (0.0.1):
|
- irondash_engine_context (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
- just_audio (0.0.1):
|
||||||
|
- Flutter
|
||||||
|
- FlutterMacOS
|
||||||
- libwebp (1.5.0):
|
- libwebp (1.5.0):
|
||||||
- libwebp/demux (= 1.5.0)
|
- libwebp/demux (= 1.5.0)
|
||||||
- libwebp/mux (= 1.5.0)
|
- libwebp/mux (= 1.5.0)
|
||||||
@@ -138,7 +142,7 @@ PODS:
|
|||||||
- FlutterMacOS
|
- FlutterMacOS
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
|
- audio_session (from `.symlinks/plugins/audio_session/ios`)
|
||||||
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
- connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
|
||||||
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
- device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
|
||||||
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
- file_picker (from `.symlinks/plugins/file_picker/ios`)
|
||||||
@@ -146,11 +150,13 @@ DEPENDENCIES:
|
|||||||
- flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`)
|
- flutter_callkit_incoming (from `.symlinks/plugins/flutter_callkit_incoming/ios`)
|
||||||
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
|
- flutter_image_compress_common (from `.symlinks/plugins/flutter_image_compress_common/ios`)
|
||||||
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
- flutter_local_notifications (from `.symlinks/plugins/flutter_local_notifications/ios`)
|
||||||
|
- flutter_native_splash (from `.symlinks/plugins/flutter_native_splash/ios`)
|
||||||
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
- flutter_secure_storage_darwin (from `.symlinks/plugins/flutter_secure_storage_darwin/darwin`)
|
||||||
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
|
- flutter_tts (from `.symlinks/plugins/flutter_tts/ios`)
|
||||||
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
- home_widget (from `.symlinks/plugins/home_widget/ios`)
|
||||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
|
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
|
||||||
|
- just_audio (from `.symlinks/plugins/just_audio/darwin`)
|
||||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||||
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
- pasteboard (from `.symlinks/plugins/pasteboard/ios`)
|
||||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||||
@@ -184,8 +190,8 @@ SPEC REPOS:
|
|||||||
- SwiftyGif
|
- SwiftyGif
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
audioplayers_darwin:
|
audio_session:
|
||||||
:path: ".symlinks/plugins/audioplayers_darwin/darwin"
|
:path: ".symlinks/plugins/audio_session/ios"
|
||||||
connectivity_plus:
|
connectivity_plus:
|
||||||
:path: ".symlinks/plugins/connectivity_plus/ios"
|
:path: ".symlinks/plugins/connectivity_plus/ios"
|
||||||
device_info_plus:
|
device_info_plus:
|
||||||
@@ -200,6 +206,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
|
:path: ".symlinks/plugins/flutter_image_compress_common/ios"
|
||||||
flutter_local_notifications:
|
flutter_local_notifications:
|
||||||
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
:path: ".symlinks/plugins/flutter_local_notifications/ios"
|
||||||
|
flutter_native_splash:
|
||||||
|
:path: ".symlinks/plugins/flutter_native_splash/ios"
|
||||||
flutter_secure_storage_darwin:
|
flutter_secure_storage_darwin:
|
||||||
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
:path: ".symlinks/plugins/flutter_secure_storage_darwin/darwin"
|
||||||
flutter_tts:
|
flutter_tts:
|
||||||
@@ -210,6 +218,8 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
irondash_engine_context:
|
irondash_engine_context:
|
||||||
:path: ".symlinks/plugins/irondash_engine_context/ios"
|
:path: ".symlinks/plugins/irondash_engine_context/ios"
|
||||||
|
just_audio:
|
||||||
|
:path: ".symlinks/plugins/just_audio/darwin"
|
||||||
package_info_plus:
|
package_info_plus:
|
||||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||||
pasteboard:
|
pasteboard:
|
||||||
@@ -244,7 +254,7 @@ EXTERNAL SOURCES:
|
|||||||
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
audioplayers_darwin: 4f9ca89d92d3d21cec7ec580e78ca888e5fb68bd
|
audio_session: 9bb7f6c970f21241b19f5a3658097ae459681ba0
|
||||||
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
connectivity_plus: cb623214f4e1f6ef8fe7403d580fdad517d2f7dd
|
||||||
CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90
|
CryptoSwift: e64e11850ede528a02a0f3e768cec8e9d92ecb90
|
||||||
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
|
CwlCatchException: 7acc161b299a6de7f0a46a6ed741eae2c8b4d75a
|
||||||
@@ -257,11 +267,13 @@ SPEC CHECKSUMS:
|
|||||||
flutter_callkit_incoming: cb8138af67cda6dd981f7101a5d709003af21502
|
flutter_callkit_incoming: cb8138af67cda6dd981f7101a5d709003af21502
|
||||||
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
flutter_image_compress_common: 1697a328fd72bfb335507c6bca1a65fa5ad87df1
|
||||||
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
flutter_local_notifications: a5a732f069baa862e728d839dd2ebb904737effb
|
||||||
|
flutter_native_splash: c32d145d68aeda5502d5f543ee38c192065986cf
|
||||||
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
flutter_secure_storage_darwin: acdb3f316ed05a3e68f856e0353b133eec373a23
|
||||||
flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833
|
flutter_tts: b88dbc8655d3dc961bc4a796e4e16a4cc1795833
|
||||||
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f
|
||||||
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
|
||||||
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
|
||||||
|
just_audio: 4e391f57b79cad2b0674030a00453ca5ce817eed
|
||||||
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
libwebp: 02b23773aedb6ff1fd38cec7a77b81414c6842a8
|
||||||
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
Mantle: c5aa8794a29a022dfbbfc9799af95f477a69b62d
|
||||||
onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a
|
onnxruntime-c: 7f778680e96145956c0a31945f260321eed2611a
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
CFBundleDisplayName = "Conduit";
|
CFBundleDisplayName = "Conduit";
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ import 'package:freezed_annotation/freezed_annotation.dart';
|
|||||||
part 'note.freezed.dart';
|
part 'note.freezed.dart';
|
||||||
part 'note.g.dart';
|
part 'note.g.dart';
|
||||||
|
|
||||||
|
/// Helper to extract user_id from JSON, falling back to user.id if not present.
|
||||||
|
/// OpenWebUI's NoteItemResponse (list endpoint) doesn't include user_id directly
|
||||||
|
/// but does include the user object with an id field.
|
||||||
|
Object? _readUserId(Map<dynamic, dynamic> json, String key) {
|
||||||
|
// First try the direct user_id field
|
||||||
|
if (json['user_id'] != null) {
|
||||||
|
return json['user_id'];
|
||||||
|
}
|
||||||
|
// Fall back to extracting from user object
|
||||||
|
final user = json['user'];
|
||||||
|
if (user is Map && user['id'] != null) {
|
||||||
|
return user['id'];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/// Content structure for a note, supporting multiple formats.
|
/// Content structure for a note, supporting multiple formats.
|
||||||
@freezed
|
@freezed
|
||||||
sealed class NoteContent with _$NoteContent {
|
sealed class NoteContent with _$NoteContent {
|
||||||
@@ -80,7 +96,11 @@ sealed class Note with _$Note {
|
|||||||
|
|
||||||
const factory Note({
|
const factory Note({
|
||||||
required String id,
|
required String id,
|
||||||
@JsonKey(name: 'user_id') required String userId,
|
|
||||||
|
/// User ID - may be null in list responses (NoteItemResponse)
|
||||||
|
/// Can be extracted from user.id if present
|
||||||
|
@JsonKey(name: 'user_id', readValue: _readUserId) String? userId,
|
||||||
|
|
||||||
required String title,
|
required String title,
|
||||||
|
|
||||||
/// Note content and associated data
|
/// Note content and associated data
|
||||||
|
|||||||
@@ -33,6 +33,28 @@ void _traceApi(String message) {
|
|||||||
DebugLogger.log(message, scope: 'api/trace');
|
DebugLogger.log(message, scope: 'api/trace');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get MIME type from file extension.
|
||||||
|
String? _getMimeType(String fileName) {
|
||||||
|
final ext = fileName.toLowerCase().split('.').last;
|
||||||
|
return switch (ext) {
|
||||||
|
'm4a' => 'audio/mp4',
|
||||||
|
'mp3' => 'audio/mpeg',
|
||||||
|
'wav' => 'audio/wav',
|
||||||
|
'aac' => 'audio/aac',
|
||||||
|
'ogg' => 'audio/ogg',
|
||||||
|
'webm' => 'audio/webm',
|
||||||
|
'mp4' => 'video/mp4',
|
||||||
|
'jpg' || 'jpeg' => 'image/jpeg',
|
||||||
|
'png' => 'image/png',
|
||||||
|
'gif' => 'image/gif',
|
||||||
|
'webp' => 'image/webp',
|
||||||
|
'pdf' => 'application/pdf',
|
||||||
|
'txt' => 'text/plain',
|
||||||
|
'json' => 'application/json',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a health check with proxy detection.
|
/// Result of a health check with proxy detection.
|
||||||
///
|
///
|
||||||
/// This enum distinguishes between different failure modes:
|
/// This enum distinguishes between different failure modes:
|
||||||
@@ -1831,6 +1853,12 @@ class ApiService {
|
|||||||
return response.data as String;
|
return response.data as String;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the URL for a file's content (for direct access/playback).
|
||||||
|
/// This URL can be used directly by audio/video players.
|
||||||
|
String getFileContentUrl(String fileId) {
|
||||||
|
return '$baseUrl/api/v1/files/$fileId/content';
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> deleteFile(String fileId) async {
|
Future<void> deleteFile(String fileId) async {
|
||||||
_traceApi('Deleting file: $fileId');
|
_traceApi('Deleting file: $fileId');
|
||||||
await _dio.delete('/api/v1/files/$fileId');
|
await _dio.delete('/api/v1/files/$fileId');
|
||||||
@@ -3434,7 +3462,7 @@ class ApiService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// File upload for RAG
|
// File upload for RAG
|
||||||
Future<String> uploadFile(String filePath, String fileName) async {
|
Future<String> uploadFile(String filePath, String fileName, {String? contentType}) async {
|
||||||
_traceApi('Starting file upload: $fileName from $filePath');
|
_traceApi('Starting file upload: $fileName from $filePath');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -3444,8 +3472,15 @@ class ApiService {
|
|||||||
throw Exception('File does not exist: $filePath');
|
throw Exception('File does not exist: $filePath');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine content type from file extension if not provided
|
||||||
|
final mimeType = contentType ?? _getMimeType(fileName);
|
||||||
|
|
||||||
final formData = FormData.fromMap({
|
final formData = FormData.fromMap({
|
||||||
'file': await MultipartFile.fromFile(filePath, filename: fileName),
|
'file': await MultipartFile.fromFile(
|
||||||
|
filePath,
|
||||||
|
filename: fileName,
|
||||||
|
contentType: mimeType != null ? DioMediaType.parse(mimeType) : null,
|
||||||
|
),
|
||||||
});
|
});
|
||||||
|
|
||||||
_traceApi('Uploading to /api/v1/files/');
|
_traceApi('Uploading to /api/v1/files/');
|
||||||
@@ -4020,12 +4055,14 @@ class ApiService {
|
|||||||
return (const <Map<String, dynamic>>[], true);
|
return (const <Map<String, dynamic>>[], true);
|
||||||
}
|
}
|
||||||
} on DioException catch (e) {
|
} on DioException catch (e) {
|
||||||
// 403 indicates notes feature is disabled server-side
|
// 401/403 indicates notes feature is disabled server-side or user lacks permission
|
||||||
if (e.response?.statusCode == 403) {
|
// OpenWebUI returns 401 when user doesn't have "features.notes" permission
|
||||||
|
final statusCode = e.response?.statusCode;
|
||||||
|
if (statusCode == 401 || statusCode == 403) {
|
||||||
DebugLogger.log(
|
DebugLogger.log(
|
||||||
'feature-disabled',
|
'feature-disabled',
|
||||||
scope: 'api/notes',
|
scope: 'api/notes',
|
||||||
data: {'status': 403},
|
data: {'status': statusCode},
|
||||||
);
|
);
|
||||||
return (const <Map<String, dynamic>>[], false);
|
return (const <Map<String, dynamic>>[], false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter_tts/flutter_tts.dart';
|
import 'package:flutter_tts/flutter_tts.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
import '../../../core/services/api_service.dart';
|
import '../../../core/services/api_service.dart';
|
||||||
|
import '../../../shared/utils/bytes_audio_source.dart';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// TTS Events
|
// TTS Events
|
||||||
@@ -142,9 +143,15 @@ class TtsManager {
|
|||||||
bool _handlersSet = false;
|
bool _handlersSet = false;
|
||||||
Completer<void>? _initCompleter;
|
Completer<void>? _initCompleter;
|
||||||
|
|
||||||
// AudioPlayer for server TTS
|
// AudioPlayer for server TTS (using just_audio)
|
||||||
final AudioPlayer _player = AudioPlayer();
|
final AudioPlayer _player = AudioPlayer();
|
||||||
bool _playerConfigured = false;
|
bool _playerConfigured = false;
|
||||||
|
StreamSubscription<PlayerState>? _playerStateSub;
|
||||||
|
|
||||||
|
/// Flag to suppress spurious TtsPaused events during chunk transitions.
|
||||||
|
/// When true, the player is actively switching audio sources and pause
|
||||||
|
/// events should not be emitted to listeners.
|
||||||
|
bool _isTransitioningChunks = false;
|
||||||
|
|
||||||
// API service for server TTS (must be set before using server TTS)
|
// API service for server TTS (must be set before using server TTS)
|
||||||
ApiService? _apiService;
|
ApiService? _apiService;
|
||||||
@@ -222,22 +229,27 @@ class TtsManager {
|
|||||||
// Initialize FlutterTts
|
// Initialize FlutterTts
|
||||||
await _ensureTtsInitialized();
|
await _ensureTtsInitialized();
|
||||||
|
|
||||||
// Configure AudioPlayer for all platforms
|
// Configure AudioPlayer for all platforms (using just_audio)
|
||||||
if (!_playerConfigured) {
|
if (!_playerConfigured) {
|
||||||
_player.onPlayerComplete.listen((_) => _onServerAudioComplete());
|
_playerStateSub = _player.playerStateStream.listen((state) {
|
||||||
_player.onPlayerStateChanged.listen((state) {
|
if (state.processingState == ProcessingState.completed) {
|
||||||
if (state == PlayerState.playing) {
|
_onServerAudioComplete();
|
||||||
|
}
|
||||||
|
if (state.playing) {
|
||||||
|
// Clear transition flag when playback actually starts.
|
||||||
|
// This ensures pause events aren't emitted during the brief window
|
||||||
|
// between play() returning and the player entering playing state.
|
||||||
|
_isTransitioningChunks = false;
|
||||||
_emitEvent(const TtsStarted());
|
_emitEvent(const TtsStarted());
|
||||||
} else if (state == PlayerState.paused) {
|
} else if (!state.playing &&
|
||||||
|
state.processingState == ProcessingState.ready &&
|
||||||
|
!_isTransitioningChunks) {
|
||||||
|
// Only emit pause when actually paused, ready, and NOT transitioning
|
||||||
|
// between chunks. During chunk transitions, the player briefly enters
|
||||||
|
// a ready-but-not-playing state which should not emit pause events.
|
||||||
_emitEvent(const TtsPaused());
|
_emitEvent(const TtsPaused());
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Android-specific audio context configuration
|
|
||||||
if (!kIsWeb && Platform.isAndroid) {
|
|
||||||
await _player.setAudioContext(
|
|
||||||
AudioContext(android: const AudioContextAndroid()),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
_playerConfigured = true;
|
_playerConfigured = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -334,7 +346,7 @@ class TtsManager {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
if (session.useServerTts) {
|
if (session.useServerTts) {
|
||||||
await _player.resume();
|
await _player.play();
|
||||||
_emitEvent(const TtsResumed());
|
_emitEvent(const TtsResumed());
|
||||||
} else {
|
} else {
|
||||||
// Device TTS resume is handled by the native handler
|
// Device TTS resume is handled by the native handler
|
||||||
@@ -367,6 +379,7 @@ class TtsManager {
|
|||||||
/// Disposes the manager and releases resources.
|
/// Disposes the manager and releases resources.
|
||||||
Future<void> dispose() async {
|
Future<void> dispose() async {
|
||||||
await stop();
|
await stop();
|
||||||
|
await _playerStateSub?.cancel();
|
||||||
await _player.dispose();
|
await _player.dispose();
|
||||||
await _eventController.close();
|
await _eventController.close();
|
||||||
}
|
}
|
||||||
@@ -690,9 +703,17 @@ class TtsManager {
|
|||||||
_serverCurrentIndex = 0;
|
_serverCurrentIndex = 0;
|
||||||
|
|
||||||
await _player.stop();
|
await _player.stop();
|
||||||
await _player.play(
|
_isTransitioningChunks = true;
|
||||||
BytesSource(firstChunk.bytes, mimeType: firstChunk.mimeType),
|
// Flag will be cleared by state listener when playing=true is received.
|
||||||
);
|
// This prevents race condition where flag is cleared before state fires.
|
||||||
|
try {
|
||||||
|
await _player.setAudioSource(BytesAudioSource(firstChunk.bytes, firstChunk.mimeType));
|
||||||
|
await _player.play();
|
||||||
|
} catch (e) {
|
||||||
|
// Reset flag on error to avoid suppressing future pause events
|
||||||
|
_isTransitioningChunks = false;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
_emitEvent(const TtsChunkStarted(0));
|
_emitEvent(const TtsChunkStarted(0));
|
||||||
|
|
||||||
// Prefetch remaining chunks in background
|
// Prefetch remaining chunks in background
|
||||||
@@ -766,7 +787,16 @@ class TtsManager {
|
|||||||
_serverCurrentIndex = nextIndex;
|
_serverCurrentIndex = nextIndex;
|
||||||
final chunk = _serverAudioBuffer[nextIndex];
|
final chunk = _serverAudioBuffer[nextIndex];
|
||||||
|
|
||||||
await _player.play(BytesSource(chunk.bytes, mimeType: chunk.mimeType));
|
_isTransitioningChunks = true;
|
||||||
|
// Flag will be cleared by state listener when playing=true is received.
|
||||||
|
try {
|
||||||
|
await _player.setAudioSource(BytesAudioSource(chunk.bytes, chunk.mimeType));
|
||||||
|
await _player.play();
|
||||||
|
} catch (e) {
|
||||||
|
// Reset flag on error to avoid suppressing future pause events
|
||||||
|
_isTransitioningChunks = false;
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
_emitEvent(TtsChunkStarted(nextIndex));
|
_emitEvent(TtsChunkStarted(nextIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import 'dart:collection';
|
|||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:audioplayers/audioplayers.dart';
|
|
||||||
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
import 'package:flutter_callkit_incoming/entities/call_event.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:wakelock_plus/wakelock_plus.dart';
|
import 'package:wakelock_plus/wakelock_plus.dart';
|
||||||
|
|
||||||
@@ -12,6 +12,7 @@ import '../../../core/providers/app_providers.dart';
|
|||||||
import '../../../core/services/background_streaming_handler.dart';
|
import '../../../core/services/background_streaming_handler.dart';
|
||||||
import '../../../core/services/callkit_service.dart';
|
import '../../../core/services/callkit_service.dart';
|
||||||
import '../../../core/services/socket_service.dart';
|
import '../../../core/services/socket_service.dart';
|
||||||
|
import '../../../shared/utils/bytes_audio_source.dart';
|
||||||
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
|
import '../../../shared/widgets/markdown/markdown_preprocessor.dart';
|
||||||
import '../providers/chat_providers.dart';
|
import '../providers/chat_providers.dart';
|
||||||
import 'text_to_speech_service.dart';
|
import 'text_to_speech_service.dart';
|
||||||
@@ -64,6 +65,7 @@ class VoiceCallService {
|
|||||||
bool _listeningSuspendedForSpeech = false;
|
bool _listeningSuspendedForSpeech = false;
|
||||||
final Map<int, SpeechAudioChunk> _serverAudioBuffer = {};
|
final Map<int, SpeechAudioChunk> _serverAudioBuffer = {};
|
||||||
final AudioPlayer _serverAudioPlayer = AudioPlayer();
|
final AudioPlayer _serverAudioPlayer = AudioPlayer();
|
||||||
|
StreamSubscription<PlayerState>? _serverAudioStateSub;
|
||||||
int _serverAudioSession = 0;
|
int _serverAudioSession = 0;
|
||||||
int _pendingServerAudioFetches = 0;
|
int _pendingServerAudioFetches = 0;
|
||||||
bool _serverPipelineActive = false;
|
bool _serverPipelineActive = false;
|
||||||
@@ -102,8 +104,10 @@ class VoiceCallService {
|
|||||||
// sentence/word callbacks are not required for call UI, but harmless
|
// sentence/word callbacks are not required for call UI, but harmless
|
||||||
);
|
);
|
||||||
|
|
||||||
_serverAudioPlayer.onPlayerComplete.listen((_) {
|
_serverAudioStateSub = _serverAudioPlayer.playerStateStream.listen((state) {
|
||||||
|
if (state.processingState == ProcessingState.completed) {
|
||||||
_handleServerAudioComplete();
|
_handleServerAudioComplete();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
unawaited(_tts.preloadServerDefaults());
|
unawaited(_tts.preloadServerDefaults());
|
||||||
@@ -721,9 +725,10 @@ class VoiceCallService {
|
|||||||
await _prepareForSpeechPlayback();
|
await _prepareForSpeechPlayback();
|
||||||
_isSpeaking = true;
|
_isSpeaking = true;
|
||||||
_updateState(VoiceCallState.speaking);
|
_updateState(VoiceCallState.speaking);
|
||||||
await _serverAudioPlayer.play(
|
await _serverAudioPlayer.setAudioSource(
|
||||||
BytesSource(chunk.bytes, mimeType: chunk.mimeType),
|
BytesAudioSource(chunk.bytes, chunk.mimeType),
|
||||||
);
|
);
|
||||||
|
await _serverAudioPlayer.play();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_isSpeaking = false;
|
_isSpeaking = false;
|
||||||
_handleTtsError(e.toString());
|
_handleTtsError(e.toString());
|
||||||
@@ -1003,6 +1008,7 @@ class VoiceCallService {
|
|||||||
|
|
||||||
_voiceInput.dispose();
|
_voiceInput.dispose();
|
||||||
await _tts.dispose();
|
await _tts.dispose();
|
||||||
|
await _serverAudioStateSub?.cancel();
|
||||||
await _serverAudioPlayer.dispose();
|
await _serverAudioPlayer.dispose();
|
||||||
|
|
||||||
// Cancel notification
|
// Cancel notification
|
||||||
|
|||||||
225
lib/features/notes/services/audio_recording_service.dart
Normal file
225
lib/features/notes/services/audio_recording_service.dart
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
|
/// Minimum valid audio file size (anything smaller is likely just a header)
|
||||||
|
const int _minValidAudioSize = 1000; // 1KB minimum
|
||||||
|
|
||||||
|
/// Exception thrown when audio recording fails.
|
||||||
|
class AudioRecordingException implements Exception {
|
||||||
|
final String message;
|
||||||
|
AudioRecordingException(this.message);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => message;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Service for recording raw audio files without real-time transcription.
|
||||||
|
///
|
||||||
|
/// This is used in the notes feature where users want to preserve their original
|
||||||
|
/// audio recordings for later transcription using server-side Whisper, rather
|
||||||
|
/// than using Apple's real-time speech transcription which:
|
||||||
|
/// - Sends data to Apple's servers (privacy concern for self-hosted setups)
|
||||||
|
/// - Auto-stops after silence periods
|
||||||
|
/// - Loses the original audio after transcription
|
||||||
|
class AudioRecordingService {
|
||||||
|
final AudioRecorder _recorder = AudioRecorder();
|
||||||
|
bool _isRecording = false;
|
||||||
|
String? _currentFilePath;
|
||||||
|
DateTime? _startTime;
|
||||||
|
|
||||||
|
final _durationController = StreamController<Duration>.broadcast();
|
||||||
|
Stream<Duration> get durationStream => _durationController.stream;
|
||||||
|
|
||||||
|
Timer? _durationTimer;
|
||||||
|
|
||||||
|
bool get isRecording => _isRecording;
|
||||||
|
|
||||||
|
Duration get currentDuration => _startTime != null
|
||||||
|
? DateTime.now().difference(_startTime!)
|
||||||
|
: Duration.zero;
|
||||||
|
|
||||||
|
/// Starts recording audio to a file.
|
||||||
|
///
|
||||||
|
/// Returns the file path where audio will be saved.
|
||||||
|
/// Throws an exception if microphone permission is denied.
|
||||||
|
Future<String> startRecording() async {
|
||||||
|
if (_isRecording) {
|
||||||
|
throw StateError('Already recording');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check/request microphone permission
|
||||||
|
final hasPermission = await _recorder.hasPermission();
|
||||||
|
if (!hasPermission) {
|
||||||
|
throw Exception('Microphone permission denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate unique file path
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
|
||||||
|
// Use AAC for iOS (native support) and better compatibility
|
||||||
|
// Use m4a extension which is widely supported
|
||||||
|
_currentFilePath = '${tempDir.path}/note_recording_$timestamp.m4a';
|
||||||
|
|
||||||
|
// Configure recording for high quality audio
|
||||||
|
// Using AAC encoder for good compression and cross-platform compatibility
|
||||||
|
await _recorder.start(
|
||||||
|
const RecordConfig(
|
||||||
|
encoder: AudioEncoder.aacLc,
|
||||||
|
sampleRate: 44100,
|
||||||
|
bitRate: 128000,
|
||||||
|
numChannels: 1,
|
||||||
|
// Don't alter the recording - preserve original audio
|
||||||
|
echoCancel: false,
|
||||||
|
autoGain: false,
|
||||||
|
noiseSuppress: false,
|
||||||
|
),
|
||||||
|
path: _currentFilePath!,
|
||||||
|
);
|
||||||
|
|
||||||
|
_isRecording = true;
|
||||||
|
_startTime = DateTime.now();
|
||||||
|
|
||||||
|
// Start duration timer for UI updates
|
||||||
|
_durationTimer = Timer.periodic(const Duration(milliseconds: 100), (_) {
|
||||||
|
// Use try-catch to handle race condition where dispose() closes
|
||||||
|
// the controller between the check and the add
|
||||||
|
try {
|
||||||
|
_durationController.add(currentDuration);
|
||||||
|
} catch (_) {
|
||||||
|
// Controller was closed, timer will be cancelled by dispose()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
debugPrint('AudioRecordingService: Started recording to $_currentFilePath');
|
||||||
|
return _currentFilePath!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stops recording and returns the recorded file.
|
||||||
|
///
|
||||||
|
/// Returns null if recording was not active, the file doesn't exist,
|
||||||
|
/// or the recording failed (file too small).
|
||||||
|
/// Throws an exception if the recording captured no audio data.
|
||||||
|
Future<File?> stopRecording() async {
|
||||||
|
if (!_isRecording || _currentFilePath == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
_durationTimer?.cancel();
|
||||||
|
_durationTimer = null;
|
||||||
|
|
||||||
|
final path = await _recorder.stop();
|
||||||
|
_isRecording = false;
|
||||||
|
_startTime = null;
|
||||||
|
|
||||||
|
if (path == null) {
|
||||||
|
debugPrint('AudioRecordingService: Stop returned null path');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final file = File(path);
|
||||||
|
if (!await file.exists()) {
|
||||||
|
debugPrint('AudioRecordingService: File does not exist at $path');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final fileSize = await file.length();
|
||||||
|
debugPrint(
|
||||||
|
'AudioRecordingService: Recording stopped, file size: $fileSize bytes',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Check if the file is too small (likely just header, no audio data)
|
||||||
|
if (fileSize < _minValidAudioSize) {
|
||||||
|
debugPrint(
|
||||||
|
'AudioRecordingService: Recording failed - file too small '
|
||||||
|
'($fileSize bytes < $_minValidAudioSize minimum). '
|
||||||
|
'This usually means the microphone is not working or not available.',
|
||||||
|
);
|
||||||
|
// Clean up the invalid file
|
||||||
|
try {
|
||||||
|
await file.delete();
|
||||||
|
} catch (_) {}
|
||||||
|
_currentFilePath = null;
|
||||||
|
throw AudioRecordingException(
|
||||||
|
'Recording captured no audio. '
|
||||||
|
'Please check microphone permissions and try again.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
_currentFilePath = null;
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Cancels recording and deletes any recorded data.
|
||||||
|
Future<void> cancelRecording() async {
|
||||||
|
_durationTimer?.cancel();
|
||||||
|
_durationTimer = null;
|
||||||
|
|
||||||
|
if (_isRecording) {
|
||||||
|
await _recorder.stop();
|
||||||
|
_isRecording = false;
|
||||||
|
_startTime = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_currentFilePath != null) {
|
||||||
|
try {
|
||||||
|
final file = File(_currentFilePath!);
|
||||||
|
if (await file.exists()) {
|
||||||
|
await file.delete();
|
||||||
|
debugPrint('AudioRecordingService: Deleted cancelled recording');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AudioRecordingService: Failed to delete file: $e');
|
||||||
|
}
|
||||||
|
_currentFilePath = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream of amplitude values for visualization.
|
||||||
|
///
|
||||||
|
/// Returns amplitude data every 100ms while recording.
|
||||||
|
Stream<Amplitude> get amplitudeStream => _recorder.onAmplitudeChanged(
|
||||||
|
const Duration(milliseconds: 100),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// Disposes of resources used by the service.
|
||||||
|
///
|
||||||
|
/// This should be called when the service is no longer needed to properly
|
||||||
|
/// release native audio resources. If a recording is in progress, it will
|
||||||
|
/// be cancelled and any temp files cleaned up.
|
||||||
|
Future<void> dispose() async {
|
||||||
|
// Cancel any in-progress recording first to clean up temp files.
|
||||||
|
// Wrapped in try-catch to ensure timer/controller cleanup always happens.
|
||||||
|
if (_isRecording) {
|
||||||
|
try {
|
||||||
|
await cancelRecording();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AudioRecordingService: Error cancelling recording in dispose: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel timer BEFORE closing controller to avoid relying on exception
|
||||||
|
// handling for control flow. The try-catch in the timer callback is a
|
||||||
|
// safety net for any remaining race condition.
|
||||||
|
_durationTimer?.cancel();
|
||||||
|
_durationTimer = null;
|
||||||
|
|
||||||
|
if (!_durationController.isClosed) {
|
||||||
|
await _durationController.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Await recorder disposal to ensure native resources are released.
|
||||||
|
// Wrapped in try-catch since recorder may be in inconsistent state if
|
||||||
|
// cancelRecording() failed above.
|
||||||
|
try {
|
||||||
|
await _recorder.dispose();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AudioRecordingService: Error disposing recorder: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:io' show Platform;
|
import 'dart:io' show File, Platform;
|
||||||
import 'dart:ui' show ImageFilter;
|
import 'dart:ui' show ImageFilter;
|
||||||
|
|
||||||
import 'package:flutter/cupertino.dart';
|
import 'package:flutter/cupertino.dart';
|
||||||
@@ -21,6 +21,9 @@ import '../../../shared/widgets/middle_ellipsis_text.dart';
|
|||||||
import '../../../shared/widgets/themed_dialogs.dart';
|
import '../../../shared/widgets/themed_dialogs.dart';
|
||||||
import '../../chat/services/voice_input_service.dart';
|
import '../../chat/services/voice_input_service.dart';
|
||||||
import '../providers/notes_providers.dart';
|
import '../providers/notes_providers.dart';
|
||||||
|
import '../widgets/audio_player_dialog.dart';
|
||||||
|
import '../widgets/audio_recording_overlay.dart';
|
||||||
|
import '../widgets/note_file_attachment.dart';
|
||||||
|
|
||||||
/// Page for editing a note with OpenWebUI-style layout.
|
/// Page for editing a note with OpenWebUI-style layout.
|
||||||
class NoteEditorPage extends ConsumerStatefulWidget {
|
class NoteEditorPage extends ConsumerStatefulWidget {
|
||||||
@@ -46,6 +49,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
|||||||
bool _isGeneratingTitle = false;
|
bool _isGeneratingTitle = false;
|
||||||
bool _isEnhancing = false;
|
bool _isEnhancing = false;
|
||||||
bool _isRecording = false;
|
bool _isRecording = false;
|
||||||
|
bool _isUploadingAudio = false;
|
||||||
Note? _note;
|
Note? _note;
|
||||||
|
|
||||||
// Voice input
|
// Voice input
|
||||||
@@ -450,6 +454,246 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Shows a bottom sheet to choose between dictation and audio recording.
|
||||||
|
void _showRecordingOptions() {
|
||||||
|
final conduitTheme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: conduitTheme.surfaceContainer,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(
|
||||||
|
top: Radius.circular(AppBorderRadius.modal),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
builder: (context) => SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: Spacing.md),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Handle bar
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 4,
|
||||||
|
margin: const EdgeInsets.only(bottom: Spacing.md),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: conduitTheme.textSecondary.withValues(alpha: 0.3),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.round),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Dictation option
|
||||||
|
ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: conduitTheme.buttonPrimary.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.keyboard
|
||||||
|
: Icons.keyboard_voice_rounded,
|
||||||
|
color: conduitTheme.buttonPrimary,
|
||||||
|
size: IconSize.md,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
l10n.dictation,
|
||||||
|
style: TextStyle(
|
||||||
|
color: conduitTheme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
l10n.dictationDescription,
|
||||||
|
style: TextStyle(
|
||||||
|
color: conduitTheme.textSecondary,
|
||||||
|
fontSize: AppTypography.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_toggleDictation();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.xs),
|
||||||
|
// Audio recording option
|
||||||
|
ListTile(
|
||||||
|
leading: Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.red.withValues(alpha: 0.1),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.mic_fill
|
||||||
|
: Icons.mic_rounded,
|
||||||
|
color: Colors.red,
|
||||||
|
size: IconSize.md,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
l10n.recordAudio,
|
||||||
|
style: TextStyle(
|
||||||
|
color: conduitTheme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
l10n.recordAudioDescription,
|
||||||
|
style: TextStyle(
|
||||||
|
color: conduitTheme.textSecondary,
|
||||||
|
fontSize: AppTypography.bodySmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(context);
|
||||||
|
_showAudioRecordingOverlay();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shows the full-screen audio recording overlay.
|
||||||
|
void _showAudioRecordingOverlay() {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
PageRouteBuilder<void>(
|
||||||
|
opaque: false,
|
||||||
|
barrierDismissible: false,
|
||||||
|
pageBuilder: (context, animation, secondaryAnimation) {
|
||||||
|
return FadeTransition(
|
||||||
|
opacity: animation,
|
||||||
|
child: AudioRecordingOverlay(
|
||||||
|
onCancel: () => Navigator.pop(context),
|
||||||
|
onConfirm: (file) async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await _uploadAudioFile(file);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
transitionDuration: const Duration(milliseconds: 200),
|
||||||
|
reverseTransitionDuration: const Duration(milliseconds: 150),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uploads an audio file to the server and attaches it to the note.
|
||||||
|
Future<void> _uploadAudioFile(File audioFile) async {
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
if (api == null || _note == null) {
|
||||||
|
_showError(l10n.failedToUploadAudio);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() => _isUploadingAudio = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get file info
|
||||||
|
final fileSize = await audioFile.length();
|
||||||
|
final fileName = 'recording_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||||
|
|
||||||
|
// Upload file to Open WebUI with proper content type
|
||||||
|
final fileId = await api.uploadFile(
|
||||||
|
audioFile.path,
|
||||||
|
fileName,
|
||||||
|
contentType: 'audio/mp4',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Get current note files
|
||||||
|
final currentFiles = _note!.data.files ?? [];
|
||||||
|
|
||||||
|
// Generate a local item ID (for OpenWebUI compatibility)
|
||||||
|
final itemId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||||
|
|
||||||
|
// Add the new file in OpenWebUI's expected format
|
||||||
|
// Must match the structure in NoteEditor.svelte uploadFileHandler
|
||||||
|
final updatedFiles = [
|
||||||
|
...currentFiles,
|
||||||
|
{
|
||||||
|
'type': 'file',
|
||||||
|
'file': '',
|
||||||
|
'id': fileId,
|
||||||
|
'url': fileId,
|
||||||
|
'name': fileName,
|
||||||
|
'collection_name': '',
|
||||||
|
'status': 'uploaded',
|
||||||
|
'size': fileSize,
|
||||||
|
'error': '',
|
||||||
|
'itemId': itemId,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
debugPrint('NoteEditorPage: Saving files: $updatedFiles');
|
||||||
|
|
||||||
|
// Update note with the file attachment
|
||||||
|
final data = <String, dynamic>{
|
||||||
|
'content': <String, dynamic>{
|
||||||
|
'json': null,
|
||||||
|
'html': _markdownToHtml(_contentController.text),
|
||||||
|
'md': _contentController.text,
|
||||||
|
},
|
||||||
|
'files': updatedFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
debugPrint('NoteEditorPage: Updating note with data: $data');
|
||||||
|
|
||||||
|
final json = await api.updateNote(
|
||||||
|
widget.noteId,
|
||||||
|
title: _titleController.text.isEmpty ? l10n.untitled : _titleController.text,
|
||||||
|
data: data,
|
||||||
|
);
|
||||||
|
|
||||||
|
debugPrint('NoteEditorPage: Update response: $json');
|
||||||
|
debugPrint('NoteEditorPage: Response files: ${json['data']?['files']}');
|
||||||
|
|
||||||
|
final updatedNote = Note.fromJson(json);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
// Update provider state inside mounted check to avoid accessing
|
||||||
|
// invalid ref after widget disposal
|
||||||
|
ref.read(notesListProvider.notifier).updateNote(updatedNote);
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_note = updatedNote;
|
||||||
|
_isUploadingAudio = false;
|
||||||
|
_hasChanges = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.audioRecordingSaved),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp file
|
||||||
|
try {
|
||||||
|
await audioFile.delete();
|
||||||
|
} catch (_) {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isUploadingAudio = false);
|
||||||
|
_showError('Failed to upload audio: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
void _copyToClipboard() {
|
void _copyToClipboard() {
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
final content = _contentController.text;
|
final content = _contentController.text;
|
||||||
@@ -979,6 +1223,9 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
|||||||
// App bar height: kToolbarHeight + metadata bar (~40)
|
// App bar height: kToolbarHeight + metadata bar (~40)
|
||||||
final appBarHeight = kToolbarHeight + 40;
|
final appBarHeight = kToolbarHeight + 40;
|
||||||
|
|
||||||
|
// Get attached files
|
||||||
|
final files = _note?.data.files ?? [];
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => _contentFocusNode.requestFocus(),
|
onTap: () => _contentFocusNode.requestFocus(),
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.opaque,
|
||||||
@@ -990,7 +1237,20 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
|||||||
Spacing.inputPadding,
|
Spacing.inputPadding,
|
||||||
120, // Extra padding for floating buttons
|
120, // Extra padding for floating buttons
|
||||||
),
|
),
|
||||||
child: TextField(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// File attachments section (if any)
|
||||||
|
if (files.isNotEmpty) ...[
|
||||||
|
NoteFilesSection(
|
||||||
|
files: files,
|
||||||
|
onPlayFile: _playAudioFile,
|
||||||
|
onDeleteFile: _removeFile,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.lg),
|
||||||
|
],
|
||||||
|
// Content editor
|
||||||
|
TextField(
|
||||||
controller: _contentController,
|
controller: _contentController,
|
||||||
focusNode: _contentFocusNode,
|
focusNode: _contentFocusNode,
|
||||||
style: AppTypography.bodyLargeStyle.copyWith(
|
style: AppTypography.bodyLargeStyle.copyWith(
|
||||||
@@ -1015,10 +1275,112 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
|||||||
textCapitalization: TextCapitalization.sentences,
|
textCapitalization: TextCapitalization.sentences,
|
||||||
keyboardType: TextInputType.multiline,
|
keyboardType: TextInputType.multiline,
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Play an audio file attachment.
|
||||||
|
Future<void> _playAudioFile(Map<String, dynamic> file) async {
|
||||||
|
final fileId = file['id']?.toString();
|
||||||
|
if (fileId == null) return;
|
||||||
|
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
if (api == null) return;
|
||||||
|
|
||||||
|
final fileName = file['name']?.toString() ?? 'Audio Recording';
|
||||||
|
|
||||||
|
await AudioPlayerDialog.show(
|
||||||
|
context,
|
||||||
|
fileId: fileId,
|
||||||
|
api: api,
|
||||||
|
fileName: fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove a file attachment from the note.
|
||||||
|
Future<void> _removeFile(Map<String, dynamic> file) async {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: Text(l10n.removeFile),
|
||||||
|
content: Text(l10n.removeFileConfirm),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: Text(l10n.cancel),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: context.conduitTheme.error,
|
||||||
|
),
|
||||||
|
child: Text(l10n.delete),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (confirmed != true || _note == null) return;
|
||||||
|
|
||||||
|
final api = ref.read(apiServiceProvider);
|
||||||
|
if (api == null) return;
|
||||||
|
|
||||||
|
setState(() => _isSaving = true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final fileId = file['id']?.toString();
|
||||||
|
final currentFiles = _note!.data.files ?? [];
|
||||||
|
final updatedFiles = currentFiles
|
||||||
|
.where((f) => f['id']?.toString() != fileId)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
final data = <String, dynamic>{
|
||||||
|
'content': <String, dynamic>{
|
||||||
|
'json': null,
|
||||||
|
'html': _markdownToHtml(_contentController.text),
|
||||||
|
'md': _contentController.text,
|
||||||
|
},
|
||||||
|
'files': updatedFiles,
|
||||||
|
};
|
||||||
|
|
||||||
|
final json = await api.updateNote(
|
||||||
|
widget.noteId,
|
||||||
|
title: _titleController.text.isEmpty
|
||||||
|
? l10n.untitled
|
||||||
|
: _titleController.text,
|
||||||
|
data: data,
|
||||||
|
);
|
||||||
|
|
||||||
|
final updatedNote = Note.fromJson(json);
|
||||||
|
ref.read(notesListProvider.notifier).updateNote(updatedNote);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_note = updatedNote;
|
||||||
|
_isSaving = false;
|
||||||
|
_hasChanges = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.fileRemoved),
|
||||||
|
duration: const Duration(seconds: 2),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isSaving = false);
|
||||||
|
_showError(e.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildFloatingActionsRow(BuildContext context) {
|
Widget _buildFloatingActionsRow(BuildContext context) {
|
||||||
final theme = context.conduitTheme;
|
final theme = context.conduitTheme;
|
||||||
final l10n = AppLocalizations.of(context)!;
|
final l10n = AppLocalizations.of(context)!;
|
||||||
@@ -1026,7 +1388,7 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
|||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
// Dictation button
|
// Voice/Recording button - shows menu if not recording, stops if recording
|
||||||
_buildFloatingButton(
|
_buildFloatingButton(
|
||||||
context,
|
context,
|
||||||
icon: _isRecording
|
icon: _isRecording
|
||||||
@@ -1035,9 +1397,11 @@ class _NoteEditorPageState extends ConsumerState<NoteEditorPage> {
|
|||||||
: Icons.stop_rounded)
|
: Icons.stop_rounded)
|
||||||
: (Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic_rounded),
|
: (Platform.isIOS ? CupertinoIcons.mic_fill : Icons.mic_rounded),
|
||||||
color: _isRecording ? theme.error : null,
|
color: _isRecording ? theme.error : null,
|
||||||
isLoading: false,
|
isLoading: _isUploadingAudio,
|
||||||
tooltip: _isRecording ? l10n.stopRecording : l10n.startDictation,
|
tooltip: _isRecording ? l10n.stopRecording : l10n.voiceOptions,
|
||||||
onPressed: _toggleDictation,
|
onPressed: _isUploadingAudio
|
||||||
|
? null
|
||||||
|
: (_isRecording ? _toggleDictation : _showRecordingOptions),
|
||||||
),
|
),
|
||||||
|
|
||||||
// AI button
|
// AI button
|
||||||
|
|||||||
396
lib/features/notes/widgets/audio_player_dialog.dart
Normal file
396
lib/features/notes/widgets/audio_player_dialog.dart
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io' show File, Platform;
|
||||||
|
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import '../../../core/services/api_service.dart';
|
||||||
|
import '../../../l10n/app_localizations.dart';
|
||||||
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
|
|
||||||
|
/// A dialog for playing audio files.
|
||||||
|
class AudioPlayerDialog extends StatefulWidget {
|
||||||
|
/// The file ID for downloading.
|
||||||
|
final String fileId;
|
||||||
|
|
||||||
|
/// The API service for authenticated requests.
|
||||||
|
final ApiService api;
|
||||||
|
|
||||||
|
/// The file name to display.
|
||||||
|
final String fileName;
|
||||||
|
|
||||||
|
const AudioPlayerDialog({
|
||||||
|
super.key,
|
||||||
|
required this.fileId,
|
||||||
|
required this.api,
|
||||||
|
required this.fileName,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// Shows the audio player dialog.
|
||||||
|
static Future<void> show(
|
||||||
|
BuildContext context, {
|
||||||
|
required String fileId,
|
||||||
|
required ApiService api,
|
||||||
|
required String fileName,
|
||||||
|
}) {
|
||||||
|
return showDialog(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AudioPlayerDialog(
|
||||||
|
fileId: fileId,
|
||||||
|
api: api,
|
||||||
|
fileName: fileName,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AudioPlayerDialog> createState() => _AudioPlayerDialogState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioPlayerDialogState extends State<AudioPlayerDialog> {
|
||||||
|
final AudioPlayer _player = AudioPlayer();
|
||||||
|
|
||||||
|
bool _isPlaying = false;
|
||||||
|
bool _isLoading = true;
|
||||||
|
bool _hasError = false;
|
||||||
|
Duration _position = Duration.zero;
|
||||||
|
Duration _duration = Duration.zero;
|
||||||
|
File? _tempFile;
|
||||||
|
|
||||||
|
StreamSubscription<PlayerState>? _stateSub;
|
||||||
|
StreamSubscription<Duration>? _positionSub;
|
||||||
|
StreamSubscription<Duration?>? _durationSub;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_setupPlayer();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _setupPlayer() async {
|
||||||
|
try {
|
||||||
|
// Get file info first to determine the correct extension
|
||||||
|
final fileInfo = await widget.api.getFileInfo(widget.fileId);
|
||||||
|
final filename = fileInfo['filename'] as String? ?? 'audio.m4a';
|
||||||
|
final contentType = (fileInfo['meta'] as Map<String, dynamic>?)?['content_type'] as String?;
|
||||||
|
|
||||||
|
debugPrint('AudioPlayerDialog: filename=$filename, contentType=$contentType');
|
||||||
|
debugPrint('AudioPlayerDialog: fileInfo=$fileInfo');
|
||||||
|
|
||||||
|
// Extract extension from filename
|
||||||
|
final extension = filename.contains('.')
|
||||||
|
? filename.substring(filename.lastIndexOf('.'))
|
||||||
|
: '.m4a';
|
||||||
|
|
||||||
|
// Download the file (requires authentication)
|
||||||
|
// Use timestamp suffix to prevent conflicts if same file opened multiple times
|
||||||
|
final tempDir = await getTemporaryDirectory();
|
||||||
|
final timestamp = DateTime.now().millisecondsSinceEpoch;
|
||||||
|
final tempPath = '${tempDir.path}/audio_${widget.fileId}_$timestamp$extension';
|
||||||
|
_tempFile = File(tempPath);
|
||||||
|
|
||||||
|
// Fetch file content through API (authenticated)
|
||||||
|
final response = await widget.api.dio.get(
|
||||||
|
'/api/v1/files/${widget.fileId}/content',
|
||||||
|
options: Options(responseType: ResponseType.bytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
final responseData = response.data;
|
||||||
|
if (responseData is! List<int>) {
|
||||||
|
throw Exception('Unexpected response type: ${responseData.runtimeType}');
|
||||||
|
}
|
||||||
|
final bytes = responseData;
|
||||||
|
debugPrint('AudioPlayerDialog: Downloaded ${bytes.length} bytes');
|
||||||
|
debugPrint('AudioPlayerDialog: First 20 bytes: ${bytes.take(20).toList()}');
|
||||||
|
debugPrint('AudioPlayerDialog: Response content-type: ${response.headers.value('content-type')}');
|
||||||
|
|
||||||
|
await _tempFile!.writeAsBytes(bytes);
|
||||||
|
debugPrint('AudioPlayerDialog: Saved to $tempPath');
|
||||||
|
|
||||||
|
// Setup player state listeners
|
||||||
|
_stateSub = _player.playerStateStream.listen((state) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_isPlaying = state.playing;
|
||||||
|
if (state.processingState == ProcessingState.completed) {
|
||||||
|
_isPlaying = false;
|
||||||
|
_position = _duration;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
_positionSub = _player.positionStream.listen((pos) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _position = pos);
|
||||||
|
});
|
||||||
|
|
||||||
|
_durationSub = _player.durationStream.listen((dur) {
|
||||||
|
if (!mounted) return;
|
||||||
|
if (dur != null) {
|
||||||
|
setState(() {
|
||||||
|
_duration = dur;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load and play the file
|
||||||
|
await _player.setFilePath(_tempFile!.path);
|
||||||
|
|
||||||
|
if (mounted) {
|
||||||
|
setState(() => _isLoading = false);
|
||||||
|
}
|
||||||
|
|
||||||
|
await _player.play();
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('AudioPlayerDialog: Error loading audio: $e');
|
||||||
|
// Clean up temp file on error to avoid orphaned files
|
||||||
|
_tempFile?.delete().then((_) {
|
||||||
|
debugPrint('AudioPlayerDialog: Cleaned up temp file after error');
|
||||||
|
}).catchError((e) {
|
||||||
|
debugPrint('AudioPlayerDialog: Failed to clean up temp file after error: $e');
|
||||||
|
});
|
||||||
|
_tempFile = null;
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_hasError = true;
|
||||||
|
_isLoading = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _togglePlayPause() async {
|
||||||
|
if (_isPlaying) {
|
||||||
|
await _player.pause();
|
||||||
|
} else {
|
||||||
|
// If at end, restart from beginning
|
||||||
|
if (_position >= _duration && _duration > Duration.zero) {
|
||||||
|
await _player.seek(Duration.zero);
|
||||||
|
}
|
||||||
|
await _player.play();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _seekTo(double value) async {
|
||||||
|
final position = Duration(milliseconds: (value * _duration.inMilliseconds).round());
|
||||||
|
await _player.seek(position);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final minutes = duration.inMinutes.toString().padLeft(2, '0');
|
||||||
|
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
|
||||||
|
return '$minutes:$seconds';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_stateSub?.cancel();
|
||||||
|
_positionSub?.cancel();
|
||||||
|
_durationSub?.cancel();
|
||||||
|
// AudioPlayer.dispose() is async but Flutter's dispose() is sync.
|
||||||
|
// Fire-and-forget is acceptable here as just_audio handles cleanup internally.
|
||||||
|
unawaited(_player.dispose());
|
||||||
|
// Clean up temp file (fire and forget, log errors for debugging)
|
||||||
|
_tempFile?.delete().then((_) {
|
||||||
|
debugPrint('AudioPlayerDialog: Cleaned up temp file');
|
||||||
|
}).catchError((e) {
|
||||||
|
debugPrint('AudioPlayerDialog: Failed to clean up temp file: $e');
|
||||||
|
});
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
final progress = _duration.inMilliseconds > 0
|
||||||
|
? (_position.inMilliseconds / _duration.inMilliseconds).clamp(0.0, 1.0)
|
||||||
|
: 0.0;
|
||||||
|
|
||||||
|
return Dialog(
|
||||||
|
backgroundColor: theme.surfaceContainer,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.lg),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(Spacing.lg),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.orange.withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.waveform
|
||||||
|
: Icons.audio_file_rounded,
|
||||||
|
color: Colors.orange,
|
||||||
|
size: IconSize.lg,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.md),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
widget.fileName,
|
||||||
|
style: AppTypography.bodyMediumStyle.copyWith(
|
||||||
|
color: theme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
l10n.audioAttachment,
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Platform.isIOS ? CupertinoIcons.xmark : Icons.close,
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.xl),
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (_hasError)
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.exclamationmark_circle
|
||||||
|
: Icons.error_outline,
|
||||||
|
color: theme.error,
|
||||||
|
size: 48,
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
Text(
|
||||||
|
l10n.failedToLoadAudio,
|
||||||
|
style: AppTypography.bodyMediumStyle.copyWith(
|
||||||
|
color: theme.error,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
// Loading state
|
||||||
|
else if (_isLoading)
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 3,
|
||||||
|
valueColor: AlwaysStoppedAnimation(theme.buttonPrimary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
Text(
|
||||||
|
l10n.loadingAudio,
|
||||||
|
style: AppTypography.bodyMediumStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
// Player controls
|
||||||
|
else ...[
|
||||||
|
// Progress slider
|
||||||
|
SliderTheme(
|
||||||
|
data: SliderThemeData(
|
||||||
|
trackHeight: 4,
|
||||||
|
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 6),
|
||||||
|
activeTrackColor: Colors.orange,
|
||||||
|
inactiveTrackColor: theme.surfaceContainerHighest,
|
||||||
|
thumbColor: Colors.orange,
|
||||||
|
overlayColor: Colors.orange.withValues(alpha: 0.2),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: progress,
|
||||||
|
onChanged: _seekTo,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Time display
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: Spacing.sm),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_formatDuration(_position),
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatDuration(_duration),
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
|
||||||
|
// Play/Pause button
|
||||||
|
GestureDetector(
|
||||||
|
onTap: _togglePlayPause,
|
||||||
|
child: Container(
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.orange,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.orange.withValues(alpha: 0.3),
|
||||||
|
blurRadius: 12,
|
||||||
|
spreadRadius: 2,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_isPlaying
|
||||||
|
? (Platform.isIOS
|
||||||
|
? CupertinoIcons.pause_fill
|
||||||
|
: Icons.pause_rounded)
|
||||||
|
: (Platform.isIOS
|
||||||
|
? CupertinoIcons.play_fill
|
||||||
|
: Icons.play_arrow_rounded),
|
||||||
|
color: Colors.white,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
388
lib/features/notes/widgets/audio_recording_overlay.dart
Normal file
388
lib/features/notes/widgets/audio_recording_overlay.dart
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
import 'dart:io';
|
||||||
|
import 'dart:ui' show FontFeature, ImageFilter;
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
|
import 'package:record/record.dart';
|
||||||
|
|
||||||
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
|
import '../services/audio_recording_service.dart';
|
||||||
|
|
||||||
|
/// Full-screen overlay for audio recording in notes.
|
||||||
|
///
|
||||||
|
/// Shows recording visualization, duration, and controls to confirm or cancel.
|
||||||
|
/// The recorded audio is returned as a file for upload to the server.
|
||||||
|
class AudioRecordingOverlay extends StatefulWidget {
|
||||||
|
/// Called when the user cancels recording.
|
||||||
|
final VoidCallback onCancel;
|
||||||
|
|
||||||
|
/// Called when the user confirms the recording with the audio file.
|
||||||
|
final void Function(File audioFile) onConfirm;
|
||||||
|
|
||||||
|
const AudioRecordingOverlay({
|
||||||
|
super.key,
|
||||||
|
required this.onCancel,
|
||||||
|
required this.onConfirm,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<AudioRecordingOverlay> createState() => _AudioRecordingOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AudioRecordingOverlayState extends State<AudioRecordingOverlay>
|
||||||
|
with SingleTickerProviderStateMixin {
|
||||||
|
final AudioRecordingService _recordingService = AudioRecordingService();
|
||||||
|
|
||||||
|
bool _isRecording = false;
|
||||||
|
bool _isProcessing = false;
|
||||||
|
bool _hasError = false;
|
||||||
|
Duration _duration = Duration.zero;
|
||||||
|
double _amplitude = 0.0;
|
||||||
|
|
||||||
|
StreamSubscription<Duration>? _durationSub;
|
||||||
|
StreamSubscription<Amplitude>? _amplitudeSub;
|
||||||
|
|
||||||
|
late AnimationController _pulseController;
|
||||||
|
late Animation<double> _pulseAnimation;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
|
||||||
|
// Pulse animation for the recording indicator
|
||||||
|
_pulseController = AnimationController(
|
||||||
|
vsync: this,
|
||||||
|
duration: const Duration(milliseconds: 1000),
|
||||||
|
);
|
||||||
|
_pulseAnimation = Tween<double>(begin: 1.0, end: 1.15).animate(
|
||||||
|
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
|
||||||
|
);
|
||||||
|
_pulseController.repeat(reverse: true);
|
||||||
|
|
||||||
|
_startRecording();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startRecording() async {
|
||||||
|
try {
|
||||||
|
await _recordingService.startRecording();
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isRecording = true);
|
||||||
|
HapticFeedback.heavyImpact();
|
||||||
|
|
||||||
|
// Set up stream listeners only if still mounted.
|
||||||
|
// Each callback also checks mounted to handle rapid disposal.
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_durationSub = _recordingService.durationStream.listen((duration) {
|
||||||
|
if (mounted) setState(() => _duration = duration);
|
||||||
|
});
|
||||||
|
|
||||||
|
_amplitudeSub = _recordingService.amplitudeStream.listen((amp) {
|
||||||
|
if (mounted) {
|
||||||
|
// Normalize amplitude to 0-1 range
|
||||||
|
// amp.current is in dBFS, typically -160 to 0
|
||||||
|
// We normalize from -60 to 0 for a reasonable range
|
||||||
|
final normalized = ((amp.current + 60) / 60).clamp(0.0, 1.0);
|
||||||
|
setState(() => _amplitude = normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _hasError = true);
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.microphonePermissionDenied),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Delay briefly to show the error message
|
||||||
|
await Future.delayed(const Duration(seconds: 1));
|
||||||
|
if (mounted) widget.onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _confirmRecording() async {
|
||||||
|
if (_isProcessing || !_isRecording || !mounted) return;
|
||||||
|
|
||||||
|
setState(() => _isProcessing = true);
|
||||||
|
HapticFeedback.mediumImpact();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final file = await _recordingService.stopRecording();
|
||||||
|
|
||||||
|
if (file != null && mounted) {
|
||||||
|
widget.onConfirm(file);
|
||||||
|
} else if (mounted) {
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(l10n.recordingFailed),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.onCancel();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(e.toString()),
|
||||||
|
backgroundColor: Theme.of(context).colorScheme.error,
|
||||||
|
duration: const Duration(seconds: 4),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
widget.onCancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _cancelRecording() async {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
await _recordingService.cancelRecording();
|
||||||
|
if (mounted) widget.onCancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatDuration(Duration duration) {
|
||||||
|
final minutes = duration.inMinutes.toString().padLeft(2, '0');
|
||||||
|
final seconds = (duration.inSeconds % 60).toString().padLeft(2, '0');
|
||||||
|
return '$minutes:$seconds';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_durationSub?.cancel();
|
||||||
|
_amplitudeSub?.cancel();
|
||||||
|
_pulseController.dispose();
|
||||||
|
// Recording service dispose is async but Flutter's dispose() is sync.
|
||||||
|
// Fire-and-forget is acceptable here as the service handles its own cleanup.
|
||||||
|
unawaited(_recordingService.dispose());
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.black.withValues(alpha: 0.92),
|
||||||
|
child: SafeArea(
|
||||||
|
child: Stack(
|
||||||
|
children: [
|
||||||
|
// Background blur effect
|
||||||
|
Positioned.fill(
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||||
|
child: const SizedBox(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Content
|
||||||
|
Column(
|
||||||
|
children: [
|
||||||
|
// Header with cancel button
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.all(Spacing.md),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
TextButton.icon(
|
||||||
|
onPressed: _cancelRecording,
|
||||||
|
icon: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.xmark
|
||||||
|
: Icons.close_rounded,
|
||||||
|
color: Colors.white70,
|
||||||
|
size: IconSize.md,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
l10n.cancel,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: AppTypography.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Recording visualization
|
||||||
|
Expanded(
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// Animated recording indicator
|
||||||
|
AnimatedBuilder(
|
||||||
|
animation: _pulseAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
final scale = _isRecording
|
||||||
|
? _pulseAnimation.value + (_amplitude * 0.3)
|
||||||
|
: 1.0;
|
||||||
|
return Transform.scale(
|
||||||
|
scale: scale,
|
||||||
|
child: child,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
width: 140,
|
||||||
|
height: 140,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
gradient: RadialGradient(
|
||||||
|
colors: [
|
||||||
|
Colors.red.withValues(alpha: 0.4),
|
||||||
|
Colors.red.withValues(alpha: 0.1),
|
||||||
|
Colors.transparent,
|
||||||
|
],
|
||||||
|
stops: const [0.3, 0.7, 1.0],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: Container(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
color: Colors.red.withValues(alpha: 0.2),
|
||||||
|
border: Border.all(
|
||||||
|
color: Colors.red,
|
||||||
|
width: 3,
|
||||||
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.red.withValues(alpha: 0.4),
|
||||||
|
blurRadius: 20,
|
||||||
|
spreadRadius: 5,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.mic_fill
|
||||||
|
: Icons.mic_rounded,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.red,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.xxl),
|
||||||
|
|
||||||
|
// Duration display
|
||||||
|
Text(
|
||||||
|
_formatDuration(_duration),
|
||||||
|
style: theme.textTheme.displayMedium?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: FontWeight.w300,
|
||||||
|
letterSpacing: 4,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.md),
|
||||||
|
|
||||||
|
// Status text
|
||||||
|
AnimatedSwitcher(
|
||||||
|
duration: const Duration(milliseconds: 200),
|
||||||
|
child: Text(
|
||||||
|
_hasError
|
||||||
|
? l10n.microphonePermissionDenied
|
||||||
|
: (_isRecording
|
||||||
|
? l10n.recordingAudio
|
||||||
|
: l10n.preparingRecording),
|
||||||
|
key: ValueKey(_isRecording),
|
||||||
|
style: theme.textTheme.bodyLarge?.copyWith(
|
||||||
|
color: Colors.white60,
|
||||||
|
letterSpacing: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(height: Spacing.sm),
|
||||||
|
|
||||||
|
// Hint text
|
||||||
|
if (_isRecording && !_hasError)
|
||||||
|
Text(
|
||||||
|
l10n.recordingHint,
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: Colors.white38,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Confirm button
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
Spacing.xl,
|
||||||
|
Spacing.md,
|
||||||
|
Spacing.xl,
|
||||||
|
Spacing.xxl,
|
||||||
|
),
|
||||||
|
child: SizedBox(
|
||||||
|
width: double.infinity,
|
||||||
|
height: 56,
|
||||||
|
child: ElevatedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
_isProcessing || !_isRecording || _hasError
|
||||||
|
? null
|
||||||
|
: _confirmRecording,
|
||||||
|
icon: _isProcessing
|
||||||
|
? SizedBox(
|
||||||
|
width: IconSize.md,
|
||||||
|
height: IconSize.md,
|
||||||
|
child: const CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.stop_fill
|
||||||
|
: Icons.stop_rounded,
|
||||||
|
size: IconSize.lg,
|
||||||
|
),
|
||||||
|
label: Text(
|
||||||
|
_isProcessing
|
||||||
|
? l10n.processingRecording
|
||||||
|
: l10n.stopAndSaveRecording,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: AppTypography.bodyLarge,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: Colors.red,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
disabledBackgroundColor: Colors.red.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
|
disabledForegroundColor: Colors.white54,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(
|
||||||
|
AppBorderRadius.button,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
elevation: 4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
312
lib/features/notes/widgets/note_file_attachment.dart
Normal file
312
lib/features/notes/widgets/note_file_attachment.dart
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import 'dart:io' show Platform;
|
||||||
|
|
||||||
|
import 'package:flutter/cupertino.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:conduit/l10n/app_localizations.dart';
|
||||||
|
|
||||||
|
import '../../../shared/theme/theme_extensions.dart';
|
||||||
|
|
||||||
|
/// A widget that displays a file attachment in a note.
|
||||||
|
///
|
||||||
|
/// Supports different file types with appropriate icons and actions.
|
||||||
|
class NoteFileAttachment extends StatelessWidget {
|
||||||
|
/// The file data from the note.
|
||||||
|
final Map<String, dynamic> file;
|
||||||
|
|
||||||
|
/// Called when the file is tapped.
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
|
/// Called when the delete button is pressed.
|
||||||
|
final VoidCallback? onDelete;
|
||||||
|
|
||||||
|
/// Whether the file is currently loading.
|
||||||
|
final bool isLoading;
|
||||||
|
|
||||||
|
/// Whether to show the delete button.
|
||||||
|
final bool showDelete;
|
||||||
|
|
||||||
|
const NoteFileAttachment({
|
||||||
|
super.key,
|
||||||
|
required this.file,
|
||||||
|
this.onTap,
|
||||||
|
this.onDelete,
|
||||||
|
this.isLoading = false,
|
||||||
|
this.showDelete = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
String get _fileName => file['name']?.toString() ?? 'Unknown file';
|
||||||
|
String get _fileType => file['type']?.toString() ?? 'file';
|
||||||
|
int? get _fileSize => file['size'] as int?;
|
||||||
|
|
||||||
|
bool get _isAudio =>
|
||||||
|
_fileType == 'audio' ||
|
||||||
|
_fileName.endsWith('.m4a') ||
|
||||||
|
_fileName.endsWith('.mp3') ||
|
||||||
|
_fileName.endsWith('.wav') ||
|
||||||
|
_fileName.endsWith('.aac');
|
||||||
|
|
||||||
|
bool get _isImage => _fileType == 'image';
|
||||||
|
|
||||||
|
IconData get _icon {
|
||||||
|
if (_isAudio) {
|
||||||
|
return Platform.isIOS
|
||||||
|
? CupertinoIcons.waveform
|
||||||
|
: Icons.audio_file_rounded;
|
||||||
|
}
|
||||||
|
if (_isImage) {
|
||||||
|
return Platform.isIOS ? CupertinoIcons.photo : Icons.image_rounded;
|
||||||
|
}
|
||||||
|
return Platform.isIOS
|
||||||
|
? CupertinoIcons.doc_fill
|
||||||
|
: Icons.insert_drive_file_rounded;
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _iconColor(ConduitThemeExtension theme) {
|
||||||
|
if (_isAudio) return Colors.orange;
|
||||||
|
if (_isImage) return Colors.blue;
|
||||||
|
return theme.textSecondary;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatFileSize(int? bytes) {
|
||||||
|
if (bytes == null) return '';
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.sm,
|
||||||
|
vertical: Spacing.sm,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.surfaceContainer.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.md),
|
||||||
|
border: Border.all(
|
||||||
|
color: theme.cardBorder.withValues(alpha: 0.3),
|
||||||
|
width: BorderWidth.thin,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// File icon
|
||||||
|
Container(
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _iconColor(theme).withValues(alpha: 0.15),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.sm),
|
||||||
|
),
|
||||||
|
child: isLoading
|
||||||
|
? Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: IconSize.sm,
|
||||||
|
height: IconSize.sm,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
valueColor: AlwaysStoppedAnimation(
|
||||||
|
_iconColor(theme),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Icon(
|
||||||
|
_icon,
|
||||||
|
color: _iconColor(theme),
|
||||||
|
size: IconSize.md,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(width: Spacing.sm),
|
||||||
|
|
||||||
|
// File info
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_fileName,
|
||||||
|
style: AppTypography.bodySmallStyle.copyWith(
|
||||||
|
color: theme.textPrimary,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_isAudio
|
||||||
|
? l10n.audioFileType
|
||||||
|
: _isImage
|
||||||
|
? l10n.imageFileType
|
||||||
|
: l10n.file,
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_fileSize != null) ...[
|
||||||
|
Text(
|
||||||
|
' · ',
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
_formatFileSize(_fileSize),
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Play button for audio
|
||||||
|
if (_isAudio && !isLoading)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.play_circle_fill
|
||||||
|
: Icons.play_circle_filled_rounded,
|
||||||
|
color: _iconColor(theme),
|
||||||
|
size: IconSize.lg,
|
||||||
|
),
|
||||||
|
onPressed: onTap,
|
||||||
|
tooltip: l10n.playAudio,
|
||||||
|
),
|
||||||
|
|
||||||
|
// Delete button
|
||||||
|
if (showDelete && !isLoading)
|
||||||
|
IconButton(
|
||||||
|
icon: Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.xmark_circle_fill
|
||||||
|
: Icons.cancel_rounded,
|
||||||
|
color: theme.textSecondary.withValues(alpha: 0.5),
|
||||||
|
size: IconSize.md,
|
||||||
|
),
|
||||||
|
onPressed: () {
|
||||||
|
HapticFeedback.lightImpact();
|
||||||
|
onDelete?.call();
|
||||||
|
},
|
||||||
|
tooltip: l10n.removeFile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A section that displays all file attachments for a note.
|
||||||
|
class NoteFilesSection extends StatelessWidget {
|
||||||
|
/// The list of files attached to the note.
|
||||||
|
final List<Map<String, dynamic>> files;
|
||||||
|
|
||||||
|
/// Called when a file should be played (for audio).
|
||||||
|
final void Function(Map<String, dynamic> file)? onPlayFile;
|
||||||
|
|
||||||
|
/// Called when a file should be deleted.
|
||||||
|
final void Function(Map<String, dynamic> file)? onDeleteFile;
|
||||||
|
|
||||||
|
/// Whether files can be deleted.
|
||||||
|
final bool canDelete;
|
||||||
|
|
||||||
|
const NoteFilesSection({
|
||||||
|
super.key,
|
||||||
|
required this.files,
|
||||||
|
this.onPlayFile,
|
||||||
|
this.onDeleteFile,
|
||||||
|
this.canDelete = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (files.isEmpty) return const SizedBox.shrink();
|
||||||
|
|
||||||
|
final theme = context.conduitTheme;
|
||||||
|
final l10n = AppLocalizations.of(context)!;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Section header
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(
|
||||||
|
left: Spacing.xs,
|
||||||
|
bottom: Spacing.xs,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Platform.isIOS
|
||||||
|
? CupertinoIcons.paperclip
|
||||||
|
: Icons.attach_file_rounded,
|
||||||
|
size: IconSize.sm,
|
||||||
|
color: theme.textSecondary,
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Text(
|
||||||
|
l10n.attachments,
|
||||||
|
style: AppTypography.labelStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: Spacing.xs),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: Spacing.xs,
|
||||||
|
vertical: 2,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||||
|
borderRadius: BorderRadius.circular(AppBorderRadius.xs),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
'${files.length}',
|
||||||
|
style: AppTypography.captionStyle.copyWith(
|
||||||
|
color: theme.textSecondary,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Files list
|
||||||
|
...files.map(
|
||||||
|
(file) => Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: Spacing.xs),
|
||||||
|
child: NoteFileAttachment(
|
||||||
|
file: file,
|
||||||
|
showDelete: canDelete,
|
||||||
|
onTap: () => onPlayFile?.call(file),
|
||||||
|
onDelete: () => onDeleteFile?.call(file),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "Weiter",
|
"continueButton": "Weiter",
|
||||||
"proxyAuthRequired": "Dieser Server erfordert Proxy-Authentifizierung",
|
"proxyAuthRequired": "Dieser Server erfordert Proxy-Authentifizierung",
|
||||||
"proxyAuthRequiredDescription": "Ihr Server scheint hinter einem Authentifizierungs-Proxy (wie oauth2-proxy) zu sein. Sie müssen sich zuerst über den Proxy anmelden.",
|
"proxyAuthRequiredDescription": "Ihr Server scheint hinter einem Authentifizierungs-Proxy (wie oauth2-proxy) zu sein. Sie müssen sich zuerst über den Proxy anmelden.",
|
||||||
"authenticateThroughProxy": "Authentifizieren"
|
"authenticateThroughProxy": "Authentifizieren",
|
||||||
|
"voiceOptions": "Sprachoptionen",
|
||||||
|
"dictation": "Diktat",
|
||||||
|
"dictationDescription": "Sprechen und in Echtzeit in Text umwandeln",
|
||||||
|
"recordAudio": "Audio aufnehmen",
|
||||||
|
"recordAudioDescription": "Audio zur späteren Transkription speichern",
|
||||||
|
"recordingAudio": "Aufnahme...",
|
||||||
|
"preparingRecording": "Vorbereitung...",
|
||||||
|
"recordingHint": "Tippe auf Stopp wenn fertig",
|
||||||
|
"stopAndSaveRecording": "Stopp & Speichern",
|
||||||
|
"processingRecording": "Verarbeitung...",
|
||||||
|
"audioRecordingSaved": "Audioaufnahme gespeichert",
|
||||||
|
"microphonePermissionDenied": "Mikrofonzugriff verweigert",
|
||||||
|
"recordingFailed": "Aufnahme fehlgeschlagen",
|
||||||
|
"removeFileConfirm": "Diese Datei aus der Notiz entfernen?",
|
||||||
|
"fileRemoved": "Datei entfernt",
|
||||||
|
"playAudio": "Audio abspielen",
|
||||||
|
"removeFile": "Datei entfernen",
|
||||||
|
"audioFileType": "Audio",
|
||||||
|
"imageFileType": "Bild",
|
||||||
|
"failedToUploadAudio": "Audio hochladen fehlgeschlagen",
|
||||||
|
"audioAttachment": "Audio-Anhang",
|
||||||
|
"loadingAudio": "Audio wird geladen...",
|
||||||
|
"failedToLoadAudio": "Audio konnte nicht geladen werden"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1763,6 +1763,62 @@
|
|||||||
"@failedToStartDictation": {
|
"@failedToStartDictation": {
|
||||||
"description": "Error when starting dictation fails."
|
"description": "Error when starting dictation fails."
|
||||||
},
|
},
|
||||||
|
"voiceOptions": "Voice options",
|
||||||
|
"@voiceOptions": {
|
||||||
|
"description": "Tooltip for voice/recording options button."
|
||||||
|
},
|
||||||
|
"dictation": "Dictation",
|
||||||
|
"@dictation": {
|
||||||
|
"description": "Option for real-time speech to text."
|
||||||
|
},
|
||||||
|
"dictationDescription": "Speak and convert to text in real-time",
|
||||||
|
"@dictationDescription": {
|
||||||
|
"description": "Description for dictation option."
|
||||||
|
},
|
||||||
|
"recordAudio": "Record Audio",
|
||||||
|
"@recordAudio": {
|
||||||
|
"description": "Option for recording audio files."
|
||||||
|
},
|
||||||
|
"recordAudioDescription": "Save audio for later transcription",
|
||||||
|
"@recordAudioDescription": {
|
||||||
|
"description": "Description for audio recording option."
|
||||||
|
},
|
||||||
|
"recordingAudio": "Recording...",
|
||||||
|
"@recordingAudio": {
|
||||||
|
"description": "Status text while recording audio."
|
||||||
|
},
|
||||||
|
"preparingRecording": "Preparing...",
|
||||||
|
"@preparingRecording": {
|
||||||
|
"description": "Status text while preparing to record."
|
||||||
|
},
|
||||||
|
"recordingHint": "Tap stop when finished",
|
||||||
|
"@recordingHint": {
|
||||||
|
"description": "Hint shown during audio recording."
|
||||||
|
},
|
||||||
|
"stopAndSaveRecording": "Stop & Save",
|
||||||
|
"@stopAndSaveRecording": {
|
||||||
|
"description": "Button to stop and save an audio recording."
|
||||||
|
},
|
||||||
|
"processingRecording": "Processing...",
|
||||||
|
"@processingRecording": {
|
||||||
|
"description": "Status while processing a recording."
|
||||||
|
},
|
||||||
|
"audioRecordingSaved": "Audio recording saved",
|
||||||
|
"@audioRecordingSaved": {
|
||||||
|
"description": "Success message after saving audio recording."
|
||||||
|
},
|
||||||
|
"failedToUploadAudio": "Failed to upload audio",
|
||||||
|
"@failedToUploadAudio": {
|
||||||
|
"description": "Error when audio upload fails."
|
||||||
|
},
|
||||||
|
"microphonePermissionDenied": "Microphone permission denied",
|
||||||
|
"@microphonePermissionDenied": {
|
||||||
|
"description": "Error when microphone access is denied."
|
||||||
|
},
|
||||||
|
"recordingFailed": "Recording failed",
|
||||||
|
"@recordingFailed": {
|
||||||
|
"description": "Error when audio recording fails."
|
||||||
|
},
|
||||||
"noteNotFound": "Note not found",
|
"noteNotFound": "Note not found",
|
||||||
"@noteNotFound": {
|
"@noteNotFound": {
|
||||||
"description": "Error message when a note cannot be found."
|
"description": "Error message when a note cannot be found."
|
||||||
@@ -1956,5 +2012,41 @@
|
|||||||
"or": "or",
|
"or": "or",
|
||||||
"@or": {
|
"@or": {
|
||||||
"description": "Separator text between authentication options."
|
"description": "Separator text between authentication options."
|
||||||
|
},
|
||||||
|
"audioAttachment": "Audio Attachment",
|
||||||
|
"@audioAttachment": {
|
||||||
|
"description": "Label for audio file attachments in notes."
|
||||||
|
},
|
||||||
|
"loadingAudio": "Loading audio...",
|
||||||
|
"@loadingAudio": {
|
||||||
|
"description": "Loading message while audio file loads."
|
||||||
|
},
|
||||||
|
"failedToLoadAudio": "Failed to load audio",
|
||||||
|
"@failedToLoadAudio": {
|
||||||
|
"description": "Error message when audio file fails to load."
|
||||||
|
},
|
||||||
|
"removeFileConfirm": "Remove this file from the note?",
|
||||||
|
"@removeFileConfirm": {
|
||||||
|
"description": "Confirmation message when removing a file attachment."
|
||||||
|
},
|
||||||
|
"fileRemoved": "File removed",
|
||||||
|
"@fileRemoved": {
|
||||||
|
"description": "Success message after removing a file attachment."
|
||||||
|
},
|
||||||
|
"playAudio": "Play audio",
|
||||||
|
"@playAudio": {
|
||||||
|
"description": "Tooltip for audio play button."
|
||||||
|
},
|
||||||
|
"removeFile": "Remove file",
|
||||||
|
"@removeFile": {
|
||||||
|
"description": "Tooltip for remove file button."
|
||||||
|
},
|
||||||
|
"audioFileType": "Audio",
|
||||||
|
"@audioFileType": {
|
||||||
|
"description": "Label for audio file type."
|
||||||
|
},
|
||||||
|
"imageFileType": "Image",
|
||||||
|
"@imageFileType": {
|
||||||
|
"description": "Label for image file type."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "Continuar",
|
"continueButton": "Continuar",
|
||||||
"proxyAuthRequired": "Este servidor requiere autenticación de proxy",
|
"proxyAuthRequired": "Este servidor requiere autenticación de proxy",
|
||||||
"proxyAuthRequiredDescription": "Su servidor parece estar detrás de un proxy de autenticación (como oauth2-proxy). Deberá iniciar sesión a través del proxy primero.",
|
"proxyAuthRequiredDescription": "Su servidor parece estar detrás de un proxy de autenticación (como oauth2-proxy). Deberá iniciar sesión a través del proxy primero.",
|
||||||
"authenticateThroughProxy": "Autenticar"
|
"authenticateThroughProxy": "Autenticar",
|
||||||
|
"voiceOptions": "Opciones de voz",
|
||||||
|
"dictation": "Dictado",
|
||||||
|
"dictationDescription": "Habla y convierte a texto en tiempo real",
|
||||||
|
"recordAudio": "Grabar audio",
|
||||||
|
"recordAudioDescription": "Guardar audio para transcripción posterior",
|
||||||
|
"recordingAudio": "Grabando...",
|
||||||
|
"preparingRecording": "Preparando...",
|
||||||
|
"recordingHint": "Toca detener cuando termines",
|
||||||
|
"stopAndSaveRecording": "Detener y guardar",
|
||||||
|
"processingRecording": "Procesando...",
|
||||||
|
"audioRecordingSaved": "Grabación de audio guardada",
|
||||||
|
"microphonePermissionDenied": "Permiso de micrófono denegado",
|
||||||
|
"recordingFailed": "Grabación fallida",
|
||||||
|
"removeFileConfirm": "¿Eliminar este archivo de la nota?",
|
||||||
|
"fileRemoved": "Archivo eliminado",
|
||||||
|
"playAudio": "Reproducir audio",
|
||||||
|
"removeFile": "Eliminar archivo",
|
||||||
|
"audioFileType": "Audio",
|
||||||
|
"imageFileType": "Imagen",
|
||||||
|
"failedToUploadAudio": "Error al subir el audio",
|
||||||
|
"audioAttachment": "Archivo de audio",
|
||||||
|
"loadingAudio": "Cargando audio...",
|
||||||
|
"failedToLoadAudio": "Error al cargar el audio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "Continuer",
|
"continueButton": "Continuer",
|
||||||
"proxyAuthRequired": "Ce serveur nécessite une authentification proxy",
|
"proxyAuthRequired": "Ce serveur nécessite une authentification proxy",
|
||||||
"proxyAuthRequiredDescription": "Votre serveur semble être derrière un proxy d'authentification (comme oauth2-proxy). Vous devrez d'abord vous connecter via le proxy.",
|
"proxyAuthRequiredDescription": "Votre serveur semble être derrière un proxy d'authentification (comme oauth2-proxy). Vous devrez d'abord vous connecter via le proxy.",
|
||||||
"authenticateThroughProxy": "S'authentifier"
|
"authenticateThroughProxy": "S'authentifier",
|
||||||
|
"voiceOptions": "Options vocales",
|
||||||
|
"dictation": "Dictée",
|
||||||
|
"dictationDescription": "Parlez et convertissez en texte en temps réel",
|
||||||
|
"recordAudio": "Enregistrer l'audio",
|
||||||
|
"recordAudioDescription": "Enregistrer l'audio pour transcription ultérieure",
|
||||||
|
"recordingAudio": "Enregistrement...",
|
||||||
|
"preparingRecording": "Préparation...",
|
||||||
|
"recordingHint": "Appuyez sur arrêt quand terminé",
|
||||||
|
"stopAndSaveRecording": "Arrêter et sauvegarder",
|
||||||
|
"processingRecording": "Traitement...",
|
||||||
|
"audioRecordingSaved": "Enregistrement audio sauvegardé",
|
||||||
|
"microphonePermissionDenied": "Accès au microphone refusé",
|
||||||
|
"recordingFailed": "Enregistrement échoué",
|
||||||
|
"removeFileConfirm": "Retirer ce fichier de la note ?",
|
||||||
|
"fileRemoved": "Fichier retiré",
|
||||||
|
"playAudio": "Lire l'audio",
|
||||||
|
"removeFile": "Retirer le fichier",
|
||||||
|
"audioFileType": "Audio",
|
||||||
|
"imageFileType": "Image",
|
||||||
|
"failedToUploadAudio": "Échec du téléchargement audio",
|
||||||
|
"audioAttachment": "Pièce jointe audio",
|
||||||
|
"loadingAudio": "Chargement audio...",
|
||||||
|
"failedToLoadAudio": "Impossible de charger l'audio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "Continua",
|
"continueButton": "Continua",
|
||||||
"proxyAuthRequired": "Questo server richiede l'autenticazione proxy",
|
"proxyAuthRequired": "Questo server richiede l'autenticazione proxy",
|
||||||
"proxyAuthRequiredDescription": "Il tuo server sembra essere dietro un proxy di autenticazione (come oauth2-proxy). Dovrai prima accedere tramite il proxy.",
|
"proxyAuthRequiredDescription": "Il tuo server sembra essere dietro un proxy di autenticazione (come oauth2-proxy). Dovrai prima accedere tramite il proxy.",
|
||||||
"authenticateThroughProxy": "Autentica"
|
"authenticateThroughProxy": "Autentica",
|
||||||
|
"voiceOptions": "Opzioni vocali",
|
||||||
|
"dictation": "Dettatura",
|
||||||
|
"dictationDescription": "Parla e converti in testo in tempo reale",
|
||||||
|
"recordAudio": "Registra audio",
|
||||||
|
"recordAudioDescription": "Salva l'audio per la trascrizione successiva",
|
||||||
|
"recordingAudio": "Registrazione...",
|
||||||
|
"preparingRecording": "Preparazione...",
|
||||||
|
"recordingHint": "Tocca stop quando hai finito",
|
||||||
|
"stopAndSaveRecording": "Ferma e salva",
|
||||||
|
"processingRecording": "Elaborazione...",
|
||||||
|
"audioRecordingSaved": "Registrazione audio salvata",
|
||||||
|
"microphonePermissionDenied": "Accesso al microfono negato",
|
||||||
|
"recordingFailed": "Registrazione fallita",
|
||||||
|
"removeFileConfirm": "Rimuovere questo file dalla nota?",
|
||||||
|
"fileRemoved": "File rimosso",
|
||||||
|
"playAudio": "Riproduci audio",
|
||||||
|
"removeFile": "Rimuovi file",
|
||||||
|
"audioFileType": "Audio",
|
||||||
|
"imageFileType": "Immagine",
|
||||||
|
"failedToUploadAudio": "Caricamento audio fallito",
|
||||||
|
"audioAttachment": "Allegato audio",
|
||||||
|
"loadingAudio": "Caricamento audio...",
|
||||||
|
"failedToLoadAudio": "Impossibile caricare l'audio"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -674,5 +674,28 @@
|
|||||||
"continueButton": "계속",
|
"continueButton": "계속",
|
||||||
"proxyAuthRequired": "이 서버는 프록시 인증이 필요합니다",
|
"proxyAuthRequired": "이 서버는 프록시 인증이 필요합니다",
|
||||||
"proxyAuthRequiredDescription": "서버가 인증 프록시(예: oauth2-proxy) 뒤에 있는 것 같습니다. 먼저 프록시를 통해 로그인해야 합니다.",
|
"proxyAuthRequiredDescription": "서버가 인증 프록시(예: oauth2-proxy) 뒤에 있는 것 같습니다. 먼저 프록시를 통해 로그인해야 합니다.",
|
||||||
"authenticateThroughProxy": "인증"
|
"authenticateThroughProxy": "인증",
|
||||||
|
"voiceOptions": "음성 옵션",
|
||||||
|
"dictation": "받아쓰기",
|
||||||
|
"dictationDescription": "말하면 실시간으로 텍스트로 변환",
|
||||||
|
"recordAudio": "오디오 녹음",
|
||||||
|
"recordAudioDescription": "나중에 텍스트 변환을 위해 오디오 저장",
|
||||||
|
"recordingAudio": "녹음 중...",
|
||||||
|
"preparingRecording": "준비 중...",
|
||||||
|
"recordingHint": "완료되면 정지를 누르세요",
|
||||||
|
"stopAndSaveRecording": "정지 및 저장",
|
||||||
|
"processingRecording": "처리 중...",
|
||||||
|
"audioRecordingSaved": "오디오 녹음 저장됨",
|
||||||
|
"microphonePermissionDenied": "마이크 권한이 거부되었습니다",
|
||||||
|
"recordingFailed": "녹음 실패",
|
||||||
|
"removeFileConfirm": "이 파일을 노트에서 제거하시겠습니까?",
|
||||||
|
"fileRemoved": "파일 제거됨",
|
||||||
|
"playAudio": "오디오 재생",
|
||||||
|
"removeFile": "파일 제거",
|
||||||
|
"audioFileType": "오디오",
|
||||||
|
"imageFileType": "이미지",
|
||||||
|
"failedToUploadAudio": "오디오 업로드 실패",
|
||||||
|
"audioAttachment": "오디오 첨부 파일",
|
||||||
|
"loadingAudio": "오디오 로드 중...",
|
||||||
|
"failedToLoadAudio": "오디오를 로드할 수 없습니다"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "Doorgaan",
|
"continueButton": "Doorgaan",
|
||||||
"proxyAuthRequired": "Deze server vereist proxy-authenticatie",
|
"proxyAuthRequired": "Deze server vereist proxy-authenticatie",
|
||||||
"proxyAuthRequiredDescription": "Uw server lijkt achter een authenticatieproxy (zoals oauth2-proxy) te staan. U moet eerst inloggen via de proxy.",
|
"proxyAuthRequiredDescription": "Uw server lijkt achter een authenticatieproxy (zoals oauth2-proxy) te staan. U moet eerst inloggen via de proxy.",
|
||||||
"authenticateThroughProxy": "Authenticeren"
|
"authenticateThroughProxy": "Authenticeren",
|
||||||
|
"voiceOptions": "Spraakopties",
|
||||||
|
"dictation": "Dicteren",
|
||||||
|
"dictationDescription": "Spreek en converteer naar tekst in realtime",
|
||||||
|
"recordAudio": "Audio opnemen",
|
||||||
|
"recordAudioDescription": "Audio opslaan voor latere transcriptie",
|
||||||
|
"recordingAudio": "Opnemen...",
|
||||||
|
"preparingRecording": "Voorbereiden...",
|
||||||
|
"recordingHint": "Tik op stop wanneer klaar",
|
||||||
|
"stopAndSaveRecording": "Stop en opslaan",
|
||||||
|
"processingRecording": "Verwerken...",
|
||||||
|
"audioRecordingSaved": "Audio-opname opgeslagen",
|
||||||
|
"microphonePermissionDenied": "Microfoontoegang geweigerd",
|
||||||
|
"recordingFailed": "Opname mislukt",
|
||||||
|
"removeFileConfirm": "Dit bestand uit de notitie verwijderen?",
|
||||||
|
"fileRemoved": "Bestand verwijderd",
|
||||||
|
"playAudio": "Audio afspelen",
|
||||||
|
"removeFile": "Bestand verwijderen",
|
||||||
|
"audioFileType": "Audio",
|
||||||
|
"imageFileType": "Afbeelding",
|
||||||
|
"failedToUploadAudio": "Audio uploaden mislukt",
|
||||||
|
"audioAttachment": "Audiobijlage",
|
||||||
|
"loadingAudio": "Audio laden...",
|
||||||
|
"failedToLoadAudio": "Audio laden mislukt"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "Продолжить",
|
"continueButton": "Продолжить",
|
||||||
"proxyAuthRequired": "Этот сервер требует аутентификацию через прокси",
|
"proxyAuthRequired": "Этот сервер требует аутентификацию через прокси",
|
||||||
"proxyAuthRequiredDescription": "Ваш сервер, похоже, находится за прокси аутентификации (например, oauth2-proxy). Сначала необходимо войти через прокси.",
|
"proxyAuthRequiredDescription": "Ваш сервер, похоже, находится за прокси аутентификации (например, oauth2-proxy). Сначала необходимо войти через прокси.",
|
||||||
"authenticateThroughProxy": "Аутентифицировать"
|
"authenticateThroughProxy": "Аутентифицировать",
|
||||||
|
"voiceOptions": "Голосовые опции",
|
||||||
|
"dictation": "Диктовка",
|
||||||
|
"dictationDescription": "Говорите и конвертируйте в текст в реальном времени",
|
||||||
|
"recordAudio": "Записать аудио",
|
||||||
|
"recordAudioDescription": "Сохранить аудио для последующей транскрипции",
|
||||||
|
"recordingAudio": "Запись...",
|
||||||
|
"preparingRecording": "Подготовка...",
|
||||||
|
"recordingHint": "Нажмите стоп когда закончите",
|
||||||
|
"stopAndSaveRecording": "Остановить и сохранить",
|
||||||
|
"processingRecording": "Обработка...",
|
||||||
|
"audioRecordingSaved": "Аудиозапись сохранена",
|
||||||
|
"microphonePermissionDenied": "Доступ к микрофону запрещён",
|
||||||
|
"recordingFailed": "Ошибка записи",
|
||||||
|
"removeFileConfirm": "Удалить этот файл из заметки?",
|
||||||
|
"fileRemoved": "Файл удалён",
|
||||||
|
"playAudio": "Воспроизвести аудио",
|
||||||
|
"removeFile": "Удалить файл",
|
||||||
|
"audioFileType": "Аудио",
|
||||||
|
"imageFileType": "Изображение",
|
||||||
|
"failedToUploadAudio": "Не удалось загрузить аудио",
|
||||||
|
"audioAttachment": "Аудио вложение",
|
||||||
|
"loadingAudio": "Загрузка аудио...",
|
||||||
|
"failedToLoadAudio": "Не удалось загрузить аудио"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "继续",
|
"continueButton": "继续",
|
||||||
"proxyAuthRequired": "此服务器需要代理认证",
|
"proxyAuthRequired": "此服务器需要代理认证",
|
||||||
"proxyAuthRequiredDescription": "您的服务器似乎位于认证代理(如 oauth2-proxy)后面。您需要先通过代理登录。",
|
"proxyAuthRequiredDescription": "您的服务器似乎位于认证代理(如 oauth2-proxy)后面。您需要先通过代理登录。",
|
||||||
"authenticateThroughProxy": "认证"
|
"authenticateThroughProxy": "认证",
|
||||||
|
"voiceOptions": "语音选项",
|
||||||
|
"dictation": "听写",
|
||||||
|
"dictationDescription": "说话并实时转换为文字",
|
||||||
|
"recordAudio": "录制音频",
|
||||||
|
"recordAudioDescription": "保存音频以便稍后转录",
|
||||||
|
"recordingAudio": "录音中...",
|
||||||
|
"preparingRecording": "准备中...",
|
||||||
|
"recordingHint": "完成后点击停止",
|
||||||
|
"stopAndSaveRecording": "停止并保存",
|
||||||
|
"processingRecording": "处理中...",
|
||||||
|
"audioRecordingSaved": "音频录制已保存",
|
||||||
|
"microphonePermissionDenied": "麦克风权限被拒绝",
|
||||||
|
"recordingFailed": "录音失败",
|
||||||
|
"removeFileConfirm": "从笔记中移除此文件?",
|
||||||
|
"fileRemoved": "文件已移除",
|
||||||
|
"playAudio": "播放音频",
|
||||||
|
"removeFile": "移除文件",
|
||||||
|
"audioFileType": "音频",
|
||||||
|
"imageFileType": "图片",
|
||||||
|
"failedToUploadAudio": "音频上传失败",
|
||||||
|
"audioAttachment": "音频附件",
|
||||||
|
"loadingAudio": "正在加载音频...",
|
||||||
|
"failedToLoadAudio": "无法加载音频"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -896,5 +896,28 @@
|
|||||||
"continueButton": "繼續",
|
"continueButton": "繼續",
|
||||||
"proxyAuthRequired": "此伺服器需要代理認證",
|
"proxyAuthRequired": "此伺服器需要代理認證",
|
||||||
"proxyAuthRequiredDescription": "您的伺服器似乎位於認證代理(如 oauth2-proxy)後面。您需要先透過代理登入。",
|
"proxyAuthRequiredDescription": "您的伺服器似乎位於認證代理(如 oauth2-proxy)後面。您需要先透過代理登入。",
|
||||||
"authenticateThroughProxy": "認證"
|
"authenticateThroughProxy": "認證",
|
||||||
|
"voiceOptions": "語音選項",
|
||||||
|
"dictation": "聽寫",
|
||||||
|
"dictationDescription": "說話並即時轉換為文字",
|
||||||
|
"recordAudio": "錄製音訊",
|
||||||
|
"recordAudioDescription": "儲存音訊以便稍後轉錄",
|
||||||
|
"recordingAudio": "錄音中...",
|
||||||
|
"preparingRecording": "準備中...",
|
||||||
|
"recordingHint": "完成後點擊停止",
|
||||||
|
"stopAndSaveRecording": "停止並儲存",
|
||||||
|
"processingRecording": "處理中...",
|
||||||
|
"audioRecordingSaved": "音訊錄製已儲存",
|
||||||
|
"microphonePermissionDenied": "麥克風權限被拒絕",
|
||||||
|
"recordingFailed": "錄音失敗",
|
||||||
|
"removeFileConfirm": "從筆記中移除此檔案?",
|
||||||
|
"fileRemoved": "檔案已移除",
|
||||||
|
"playAudio": "播放音訊",
|
||||||
|
"removeFile": "移除檔案",
|
||||||
|
"audioFileType": "音訊",
|
||||||
|
"imageFileType": "圖片",
|
||||||
|
"failedToUploadAudio": "音訊上傳失敗",
|
||||||
|
"audioAttachment": "音訊附件",
|
||||||
|
"loadingAudio": "正在載入音訊...",
|
||||||
|
"failedToLoadAudio": "無法載入音訊"
|
||||||
}
|
}
|
||||||
|
|||||||
56
lib/shared/utils/bytes_audio_source.dart
Normal file
56
lib/shared/utils/bytes_audio_source.dart
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
import 'dart:typed_data';
|
||||||
|
|
||||||
|
import 'package:just_audio/just_audio.dart';
|
||||||
|
|
||||||
|
/// A [StreamAudioSource] that plays audio from raw bytes in memory.
|
||||||
|
///
|
||||||
|
/// This is useful for playing audio data that has been loaded from a network
|
||||||
|
/// response or other byte stream, such as server-side TTS audio.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// final audioPlayer = AudioPlayer();
|
||||||
|
/// final audioBytes = Uint8List.fromList([...]);
|
||||||
|
/// await audioPlayer.setAudioSource(
|
||||||
|
/// BytesAudioSource(audioBytes, 'audio/mpeg'),
|
||||||
|
/// );
|
||||||
|
/// await audioPlayer.play();
|
||||||
|
/// ```
|
||||||
|
class BytesAudioSource extends StreamAudioSource {
|
||||||
|
/// Creates a [BytesAudioSource] from the given bytes and MIME type.
|
||||||
|
///
|
||||||
|
/// The [mimeType] should be a valid audio MIME type such as:
|
||||||
|
/// - `audio/mpeg` for MP3
|
||||||
|
/// - `audio/wav` for WAV
|
||||||
|
/// - `audio/mp4` or `audio/aac` for AAC/M4A
|
||||||
|
/// - `audio/ogg` for Ogg Vorbis
|
||||||
|
BytesAudioSource(this._bytes, this._mimeType);
|
||||||
|
|
||||||
|
final Uint8List _bytes;
|
||||||
|
final String _mimeType;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<StreamAudioResponse> request([int? start, int? end]) async {
|
||||||
|
start ??= 0;
|
||||||
|
end ??= _bytes.length;
|
||||||
|
|
||||||
|
// Assert valid bounds in debug mode to catch bugs early, but don't
|
||||||
|
// crash in release. just_audio should always pass valid ranges.
|
||||||
|
assert(
|
||||||
|
start >= 0 && start <= _bytes.length,
|
||||||
|
'BytesAudioSource: start ($start) out of bounds [0, ${_bytes.length}]',
|
||||||
|
);
|
||||||
|
assert(
|
||||||
|
end >= start && end <= _bytes.length,
|
||||||
|
'BytesAudioSource: end ($end) out of bounds [$start, ${_bytes.length}]',
|
||||||
|
);
|
||||||
|
|
||||||
|
return StreamAudioResponse(
|
||||||
|
sourceLength: _bytes.length,
|
||||||
|
contentLength: end - start,
|
||||||
|
offset: start,
|
||||||
|
stream: Stream.value(_bytes.sublist(start, end)),
|
||||||
|
contentType: _mimeType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
80
pubspec.lock
80
pubspec.lock
@@ -65,62 +65,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.13.0"
|
version: "2.13.0"
|
||||||
audioplayers:
|
audio_session:
|
||||||
dependency: "direct main"
|
|
||||||
description:
|
|
||||||
name: audioplayers
|
|
||||||
sha256: "5441fa0ceb8807a5ad701199806510e56afde2b4913d9d17c2f19f2902cf0ae4"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.5.1"
|
|
||||||
audioplayers_android:
|
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: audioplayers_android
|
name: audio_session
|
||||||
sha256: "60a6728277228413a85755bd3ffd6fab98f6555608923813ce383b190a360605"
|
sha256: "8f96a7fecbb718cb093070f868b4cdcb8a9b1053dce342ff8ab2fde10eb9afb7"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.2.1"
|
version: "0.2.2"
|
||||||
audioplayers_darwin:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: audioplayers_darwin
|
|
||||||
sha256: "0811d6924904ca13f9ef90d19081e4a87f7297ddc19fc3d31f60af1aaafee333"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "6.3.0"
|
|
||||||
audioplayers_linux:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: audioplayers_linux
|
|
||||||
sha256: f75bce1ce864170ef5e6a2c6a61cd3339e1a17ce11e99a25bae4474ea491d001
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.2.1"
|
|
||||||
audioplayers_platform_interface:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: audioplayers_platform_interface
|
|
||||||
sha256: "0e2f6a919ab56d0fec272e801abc07b26ae7f31980f912f24af4748763e5a656"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "7.1.1"
|
|
||||||
audioplayers_web:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: audioplayers_web
|
|
||||||
sha256: "1c0f17cec68455556775f1e50ca85c40c05c714a99c5eb1d2d57cc17ba5522d7"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "5.1.1"
|
|
||||||
audioplayers_windows:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: audioplayers_windows
|
|
||||||
sha256: "4048797865105b26d47628e6abb49231ea5de84884160229251f37dfcbe52fd7"
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "4.2.1"
|
|
||||||
boolean_selector:
|
boolean_selector:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -997,6 +949,30 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.11.2"
|
version: "6.11.2"
|
||||||
|
just_audio:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: just_audio
|
||||||
|
sha256: "9694e4734f515f2a052493d1d7e0d6de219ee0427c7c29492e246ff32a219908"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.10.5"
|
||||||
|
just_audio_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: just_audio_platform_interface
|
||||||
|
sha256: "2532c8d6702528824445921c5ff10548b518b13f808c2e34c2fd54793b999a6a"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "4.6.0"
|
||||||
|
just_audio_web:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: just_audio_web
|
||||||
|
sha256: "6ba8a2a7e87d57d32f0f7b42856ade3d6a9fbe0f1a11fabae0a4f00bb73f0663"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.4.16"
|
||||||
leak_tracker:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@@ -48,7 +48,6 @@ dependencies:
|
|||||||
speech_to_text: ^7.3.0
|
speech_to_text: ^7.3.0
|
||||||
record: ^6.1.2
|
record: ^6.1.2
|
||||||
flutter_tts: ^4.2.3
|
flutter_tts: ^4.2.3
|
||||||
audioplayers: ^6.5.1
|
|
||||||
image_picker: ^1.2.0
|
image_picker: ^1.2.0
|
||||||
file_picker: ^10.3.7
|
file_picker: ^10.3.7
|
||||||
flutter_image_compress: ^2.1.0
|
flutter_image_compress: ^2.1.0
|
||||||
@@ -83,6 +82,7 @@ dependencies:
|
|||||||
pasteboard: ^0.4.0
|
pasteboard: ^0.4.0
|
||||||
super_context_menu: ^0.9.1
|
super_context_menu: ^0.9.1
|
||||||
super_drag_and_drop: ^0.9.1
|
super_drag_and_drop: ^0.9.1
|
||||||
|
just_audio: ^0.10.5
|
||||||
|
|
||||||
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
# Clipboard functionality is available through flutter/services (part of Flutter SDK)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user