Skip to content

Commit aeff91a

Browse files
feat: add voice recording functionality with permission handling (#140)
* feat: add voice recording functionality with permission handling - Implemented RecordService for managing audio recording, including start, stop, and cancel functionalities. - Integrated amplitude visualization for the recording process. - Added UI components for recording voice messages in the chat input. - Enhanced chat message bubble to display audio messages with user context. - Updated localization files to support new voice recording features in multiple languages. - Refactored chat download manager to utilize temporary file paths for audio recordings. - Registered new dependencies for audio recording across platforms (Linux, macOS). - Updated platform-specific files to handle audio input permissions and dependencies. * fix: Stage all the right bits for audio recording (#141) * feat: implement audio visualization and voice recording features * feat: enhance chat join dialog and notification handler with membership checks --------- Co-authored-by: Ken VanDine <ken@vandine.org>
1 parent 41928c6 commit aeff91a

41 files changed

Lines changed: 1008 additions & 280 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@
2020
sudo apt install libcrypto++-dev libsecret-1-dev libjsoncpp-dev libnotify-dev libjsoncpp1 libsecret-1-0 librhash0 libwebkit2gtk-4.0-dev libssl-dev libpciaccess-dev
2121
```
2222

23+
```
24+
sudo apt install pulseaudio-utils ffmpeg
25+
```
26+
2327
#### Run
2428

2529
`flutter run`
@@ -51,4 +55,3 @@ Thank you for your awesome dart sdk!
5155
## Why this name?
5256

5357
The name is inspired by the traveling vehicle from the movie Matrix, which uses the name of https://en.wikipedia.org/wiki/Nebuchadnezzar_II the second king of Babylon!
54-

lib/chat_master/view/chat_master_detail_page.dart

Lines changed: 2 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,7 @@ import 'package:matrix/matrix.dart';
55
import '../../authentication/authentication_service.dart';
66
import '../../authentication/view/chat_login_page.dart';
77
import '../../authentication/view/uia_request_handler.dart';
8-
import '../../chat_room/common/view/chat_no_selected_room_page.dart';
9-
import '../../chat_room/common/view/chat_room_page.dart';
8+
import '../../chat_room/common/view/chat_room_loading_page.dart';
109
import '../../chat_room/input/draft_manager.dart';
1110
import '../../common/chat_manager.dart';
1211
import '../../common/platforms.dart';
@@ -85,11 +84,6 @@ class ChatMasterDetailPage extends StatelessWidget
8584
handler: chatNotificationHandler,
8685
);
8786

88-
final selectedRoom = watchPropertyValue((ChatManager m) => m.selectedRoom);
89-
final isArchivedRoom = watchPropertyValue(
90-
(ChatManager m) => m.selectedRoom?.isArchived == true,
91-
);
92-
9387
return Scaffold(
9488
key: masterScaffoldKey,
9589
drawer: !Platforms.isMacOS
@@ -105,15 +99,7 @@ class ChatMasterDetailPage extends StatelessWidget
10599
],
106100
if (context.showSideBar)
107101
const VerticalDivider(width: 0, thickness: 0),
108-
if (selectedRoom == null)
109-
const Expanded(child: ChatNoSelectedRoomPage())
110-
else
111-
Expanded(
112-
child: ChatRoomPage(
113-
key: ValueKey('${selectedRoom.id} $isArchivedRoom'),
114-
room: selectedRoom,
115-
),
116-
),
102+
const Expanded(child: ChatRoomLoadingPage()),
117103
],
118104
),
119105
bottomNavigationBar: const PlayerView(),

lib/chat_master/view/chat_master_tile_menu.dart

Lines changed: 18 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import '../../chat_room/create_or_edit/edit_room_manager.dart';
99
import '../../chat_room/create_or_edit/edit_room_service.dart';
1010
import '../../chat_room/titlebar/chat_room_notification_button.dart';
1111
import '../../common/chat_manager.dart';
12-
import '../../common/rooms_filter.dart';
1312
import '../../common/view/build_context_x.dart';
1413
import '../../common/view/confirm.dart';
1514
import '../../l10n/l10n.dart';
@@ -90,31 +89,29 @@ class _ChatMasterTileMenuState extends State<ChatMasterTileMenu> {
9089
style: context.textTheme.bodyMedium,
9190
),
9291
),
93-
if (di<ChatManager>().roomsFilter == RoomsFilter.spaces) ...[
92+
MenuItemButton(
93+
onPressed: () => showDialog(
94+
context: context,
95+
builder: (context) => AddToSpaceDialog(room: widget.room),
96+
),
97+
leadingIcon: const Icon(YaruIcons.plus),
98+
child: Text(context.l10n.addToSpace),
99+
),
100+
if (di<ChatManager>().spaces.firstWhereOrNull(
101+
(space) => space.spaceChildren
102+
.map((c) => c.roomId)
103+
.contains(widget.room.id),
104+
) !=
105+
null)
94106
MenuItemButton(
95107
onPressed: () => showDialog(
96108
context: context,
97-
builder: (context) => AddToSpaceDialog(room: widget.room),
109+
builder: (context) =>
110+
RemoveFromSpaceDialog(room: widget.room),
98111
),
99-
leadingIcon: const Icon(YaruIcons.plus),
100-
child: Text(context.l10n.addToSpace),
112+
leadingIcon: const Icon(YaruIcons.minus),
113+
child: Text(context.l10n.removeFromSpace),
101114
),
102-
if (di<ChatManager>().spaces.firstWhereOrNull(
103-
(space) => space.spaceChildren
104-
.map((c) => c.roomId)
105-
.contains(widget.room.id),
106-
) !=
107-
null)
108-
MenuItemButton(
109-
onPressed: () => showDialog(
110-
context: context,
111-
builder: (context) =>
112-
RemoveFromSpaceDialog(room: widget.room),
113-
),
114-
leadingIcon: const Icon(YaruIcons.minus),
115-
child: Text(context.l10n.removeFromSpace),
116-
),
117-
],
118115
],
119116
child: widget.child,
120117
),
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import 'package:flutter/material.dart';
2+
import 'package:flutter_it/flutter_it.dart';
3+
import 'package:matrix/matrix.dart';
4+
5+
import '../../../common/view/build_context_x.dart';
6+
import '../../../events/chat_download_manager.dart';
7+
import '../../../player/data/local_media.dart';
8+
import '../../../player/player_manager.dart';
9+
import '../../input/record_service.dart';
10+
11+
class AudioVisualizer extends StatelessWidget with WatchItMixin {
12+
const AudioVisualizer({
13+
super.key,
14+
this.height = 40,
15+
required this.recording,
16+
this.event,
17+
});
18+
19+
final double height;
20+
final AudioRecording recording;
21+
final Event? event;
22+
23+
@override
24+
Widget build(BuildContext context) {
25+
final playerPosition = watchValue(
26+
(PlayerManager m) =>
27+
m.position.select((p) => p.inMilliseconds.toDouble()),
28+
);
29+
30+
final maxPosition = watchValue(
31+
(PlayerManager m) =>
32+
m.duration.select((p) => p.inMilliseconds.toDouble()),
33+
);
34+
var currentPosition = playerPosition;
35+
36+
if (currentPosition > maxPosition) {
37+
currentPosition = maxPosition;
38+
}
39+
40+
final wavePosition =
41+
(currentPosition / maxPosition) * AudioRecording.waveCount;
42+
43+
final media = watchStream(
44+
(PlayerManager p) => p.currentMediaStream,
45+
initialValue: di<PlayerManager>().currentMedia,
46+
preserveState: false,
47+
allowStreamChange: true,
48+
).data;
49+
50+
final cap = event == null
51+
? null
52+
: watchValue((ChatDownloadManager m) => m.getDownloadCommand(event!));
53+
54+
final isAudioPlaying = media is LocalMedia && media.uri == cap?.file?.path;
55+
56+
final waveform = recording.normalizedWaveform;
57+
return Stack(
58+
children: [
59+
if (waveform != null)
60+
Row(
61+
children: [
62+
for (var i = 0; i < AudioRecording.waveCount; i++)
63+
Expanded(
64+
child: Container(
65+
height: height,
66+
alignment: Alignment.center,
67+
child: Container(
68+
margin: const EdgeInsets.symmetric(horizontal: 1),
69+
decoration: BoxDecoration(
70+
color: isAudioPlaying && i < wavePosition
71+
? context.colorScheme.primary
72+
: context.colorScheme.primary.withAlpha(128),
73+
borderRadius: BorderRadius.circular(64),
74+
),
75+
height: height * (waveform[i] / 1024),
76+
),
77+
),
78+
),
79+
],
80+
),
81+
if (isAudioPlaying && maxPosition > 0 && currentPosition >= 0)
82+
SizedBox(
83+
height: height,
84+
child: SliderTheme(
85+
data: SliderTheme.of(context).copyWith(
86+
thumbShape: const RoundSliderThumbShape(
87+
enabledThumbRadius: 0,
88+
disabledThumbRadius: 0,
89+
),
90+
),
91+
child: Slider(
92+
secondaryActiveColor: Colors.transparent,
93+
overlayColor: WidgetStateProperty.all(Colors.transparent),
94+
thumbColor: Colors.transparent,
95+
activeColor: Colors.transparent,
96+
inactiveColor: Colors.transparent,
97+
max: maxPosition,
98+
value: currentPosition,
99+
onChanged: isAudioPlaying
100+
? (value) => di<PlayerManager>().seek(
101+
Duration(milliseconds: value.round()),
102+
)
103+
: null,
104+
),
105+
),
106+
),
107+
],
108+
);
109+
}
110+
}

lib/chat_room/common/view/chat_join_room_dialog.dart

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,9 @@ import 'package:flutter_it/flutter_it.dart';
33
import 'package:matrix/matrix.dart';
44

55
import '../../../common/chat_manager.dart';
6+
import '../../../common/view/chat_avatar.dart';
67
import '../../../common/view/confirm.dart';
8+
import '../../../common/view/ui_constants.dart';
79
import '../../../l10n/l10n.dart';
810
import '../../create_or_edit/edit_room_manager.dart';
911

@@ -14,7 +16,21 @@ class ChatJoinRoomDialog extends StatelessWidget with WatchItMixin {
1416

1517
@override
1618
Widget build(BuildContext context) => ConfirmationDialog(
17-
title: Text('${context.l10n.invite}: ${room.getLocalizedDisplayname()}'),
19+
content: Column(
20+
mainAxisSize: .min,
21+
spacing: kMediumPadding,
22+
children: [
23+
ChatAvatar(avatarUri: room.avatar, dimension: 40),
24+
Flexible(
25+
child: Text(
26+
room.isDirectChat
27+
? context.l10n.youInvitedBy(room.getLocalizedDisplayname())
28+
: room.getLocalizedDisplayname(),
29+
),
30+
),
31+
],
32+
),
33+
title: Text(context.l10n.joinRoom),
1834
cancelLabel: context.l10n.reject,
1935
onCancel: () {
2036
di<ChatManager>().setSelectedRoom(null);

lib/chat_room/common/view/chat_no_selected_room_page.dart

Lines changed: 48 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,8 @@ import 'package:yaru/yaru.dart';
88

99
import '../../../common/chat_manager.dart';
1010
import '../../../common/view/build_context_x.dart';
11-
import '../../../common/view/common_widgets.dart';
1211
import '../../../common/view/ui_constants.dart';
1312
import '../../../l10n/l10n.dart';
14-
import '../../create_or_edit/create_room_manager.dart';
1513
import '../../create_or_edit/edit_room_manager.dart';
1614
import '../../titlebar/side_bar_button.dart';
1715
import 'chat_join_mixin.dart';
@@ -24,18 +22,6 @@ class ChatNoSelectedRoomPage extends StatelessWidget
2422
Widget build(BuildContext context) {
2523
registerJoinHandlers(context);
2624

27-
final joiningDirectChat = watchValue(
28-
(CreateRoomManager m) => m.createOrGetDirectChatCommand.isRunning,
29-
);
30-
31-
final knockIngOrJoining = watchValue(
32-
(EditRoomManager m) => m.knockOrJoinCommand.isRunning,
33-
);
34-
35-
final joining = watchValue(
36-
(EditRoomManager m) => m.joinRoomCommand.isRunning,
37-
);
38-
3925
final loadingArchive = watchValue(
4026
(ChatManager m) => m.toggleArchiveCommand.isRunning,
4127
);
@@ -80,63 +66,57 @@ class ChatNoSelectedRoomPage extends StatelessWidget
8066
child: Column(
8167
mainAxisSize: .min,
8268
spacing: kBigPadding,
83-
children: joiningDirectChat || knockIngOrJoining || joining
84-
? [const Progress(), Text(context.l10n.joiningRoomPleaseWait)]
85-
: [
69+
children: [
70+
loadingArchive
71+
? LiquidCustomProgressIndicator(
72+
backgroundColor:
73+
context.theme.colorScheme.surfaceContainerHighest,
74+
direction: Axis.vertical,
75+
value: 0.5,
76+
shapePath: _buildArchiveIconPath(),
77+
)
78+
: clearingArchive
79+
? LiquidCustomProgressIndicator(
80+
backgroundColor:
81+
context.theme.colorScheme.surfaceContainerHighest,
82+
direction: Axis.vertical,
83+
value: progress,
84+
shapePath: _buildBoatPath(),
85+
)
86+
: isArchiveActive
87+
? CustomPaint(
88+
size: const Size(90, 90),
89+
painter: _PathPainter(
90+
path: _buildArchiveIconPath(),
91+
color: context.theme.colorScheme.primary,
92+
),
93+
)
94+
: Image.asset(
95+
'assets/nebuchadnezzar.png',
96+
width: 90,
97+
height: 90,
98+
),
99+
SizedBox(
100+
width: 300,
101+
child: Padding(
102+
padding: const EdgeInsets.symmetric(
103+
horizontal: kMediumPadding,
104+
),
105+
child: Text(
86106
loadingArchive
87-
? LiquidCustomProgressIndicator(
88-
backgroundColor: context
89-
.theme
90-
.colorScheme
91-
.surfaceContainerHighest,
92-
direction: Axis.vertical,
93-
value: 0.5,
94-
shapePath: _buildArchiveIconPath(),
95-
)
107+
? context.l10n.loadingArchivePleaseWait
96108
: clearingArchive
97-
? LiquidCustomProgressIndicator(
98-
backgroundColor: context
99-
.theme
100-
.colorScheme
101-
.surfaceContainerHighest,
102-
direction: Axis.vertical,
103-
value: progress,
104-
shapePath: _buildBoatPath(),
105-
)
109+
? context.l10n.clearingArchivePleaseWait
106110
: isArchiveActive
107-
? CustomPaint(
108-
size: const Size(90, 90),
109-
painter: _PathPainter(
110-
path: _buildArchiveIconPath(),
111-
color: context.theme.colorScheme.primary,
112-
),
113-
)
114-
: Image.asset(
115-
'assets/nebuchadnezzar.png',
116-
width: 90,
117-
height: 90,
118-
),
119-
SizedBox(
120-
width: 300,
121-
child: Padding(
122-
padding: const EdgeInsets.symmetric(
123-
horizontal: kMediumPadding,
124-
),
125-
child: Text(
126-
loadingArchive
127-
? context.l10n.loadingArchivePleaseWait
128-
: clearingArchive
129-
? context.l10n.clearingArchivePleaseWait
130-
: isArchiveActive
131-
? isArchiveEmpty
132-
? context.l10n.archiveIsEmpty
133-
: context.l10n.pleaseSelectAChatRoom
134-
: context.l10n.pleaseSelectAChatRoom,
135-
textAlign: TextAlign.center,
136-
),
137-
),
138-
),
139-
],
111+
? isArchiveEmpty
112+
? context.l10n.archiveIsEmpty
113+
: context.l10n.pleaseSelectAChatRoom
114+
: context.l10n.pleaseSelectAChatRoom,
115+
textAlign: TextAlign.center,
116+
),
117+
),
118+
),
119+
],
140120
),
141121
),
142122
),

0 commit comments

Comments
 (0)