movius-chats 1.3.13 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/commonjs/index.js +5 -3
- package/lib/commonjs/index.js.map +1 -1
- package/lib/module/index.js +5 -3
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/assets/Icons/ChevronUpIcon.d.ts +5 -0
- package/lib/typescript/assets/Icons/LockIcon.d.ts +5 -0
- package/lib/typescript/assets/Icons/TrashIcon.d.ts +5 -0
- package/lib/typescript/components/AudioPlayer/types.d.ts +1 -0
- package/lib/typescript/components/ChatInput/types.d.ts +2 -2
- package/lib/typescript/components/VoiceRecorder/LongPressRecording.d.ts +13 -0
- package/lib/typescript/components/VoiceRecorder/NormalRecording.d.ts +20 -0
- package/lib/typescript/components/VoiceRecorder/WaveformAnimation.d.ts +10 -0
- package/lib/typescript/hooks/useVoiceRecorder.d.ts +19 -0
- package/lib/typescript/types/index.d.ts +74 -1
- package/package.json +12 -2
- package/scripts/patchSound.js +48 -23
- package/src/assets/Icons/ChevronUpIcon.tsx +20 -0
- package/src/assets/Icons/LockIcon.tsx +28 -0
- package/src/assets/Icons/TrashIcon.tsx +26 -0
- package/src/components/AudioPlayer/AudioPlayer.tsx +147 -163
- package/src/components/AudioPlayer/types.ts +1 -0
- package/src/components/ChatBubble/MediaGrid.tsx +4 -1
- package/src/components/ChatBubble/MessageContent.tsx +1 -0
- package/src/components/ChatInput/ChatInput.tsx +296 -62
- package/src/components/ChatInput/FilePreview.tsx +3 -0
- package/src/components/ChatInput/types.ts +2 -2
- package/src/components/MediaViewer/MediaViewer.tsx +45 -10
- package/src/components/VoiceRecorder/LongPressRecording.tsx +195 -0
- package/src/components/VoiceRecorder/NormalRecording.tsx +156 -0
- package/src/components/VoiceRecorder/WaveformAnimation.tsx +56 -0
- package/src/hooks/useVoiceRecorder.ts +206 -0
- package/src/types/index.ts +80 -1
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import React, { useEffect } from 'react';
|
|
2
|
+
import { Text, View } from 'react-native';
|
|
3
|
+
import Animated, {
|
|
4
|
+
Easing,
|
|
5
|
+
useAnimatedStyle,
|
|
6
|
+
useSharedValue,
|
|
7
|
+
withRepeat,
|
|
8
|
+
withSequence,
|
|
9
|
+
withTiming,
|
|
10
|
+
} from 'react-native-reanimated';
|
|
11
|
+
import { ChevronUpIcon } from '../../assets/Icons/ChevronUpIcon';
|
|
12
|
+
import { LockIcon } from '../../assets/Icons/LockIcon';
|
|
13
|
+
import { MicrophoneIcon } from '../../assets/Icons/MicrophoneIcon';
|
|
14
|
+
import { formatDuration } from '../../utils/datefunc';
|
|
15
|
+
import { RecordingUIProps, VoiceRecorderStyleOverrides } from '../../types';
|
|
16
|
+
|
|
17
|
+
interface LongPressRecordingProps {
|
|
18
|
+
duration: number;
|
|
19
|
+
/** Current horizontal drag offset (negative = sliding left to cancel) */
|
|
20
|
+
slideX: number;
|
|
21
|
+
containerHeight?: number;
|
|
22
|
+
fontFamily?: string;
|
|
23
|
+
voiceRecorderStyles?: VoiceRecorderStyleOverrides;
|
|
24
|
+
recordingUIProps?: RecordingUIProps;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export const LongPressRecording: React.FC<LongPressRecordingProps> = ({
|
|
28
|
+
duration,
|
|
29
|
+
slideX,
|
|
30
|
+
containerHeight = 50,
|
|
31
|
+
fontFamily,
|
|
32
|
+
voiceRecorderStyles,
|
|
33
|
+
recordingUIProps,
|
|
34
|
+
}) => {
|
|
35
|
+
const micPulseColor = recordingUIProps?.micPulseColor ?? '#ef4444';
|
|
36
|
+
const cancelTextColor = recordingUIProps?.cancelTextColor ?? '#6b7280';
|
|
37
|
+
|
|
38
|
+
// ── Mic breathing ─────────────────────────────────────────────────────────
|
|
39
|
+
const micScale = useSharedValue(1);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
micScale.value = withRepeat(
|
|
42
|
+
withSequence(
|
|
43
|
+
withTiming(1.28, { duration: 700, easing: Easing.inOut(Easing.ease) }),
|
|
44
|
+
withTiming(1, { duration: 700, easing: Easing.inOut(Easing.ease) })
|
|
45
|
+
),
|
|
46
|
+
-1,
|
|
47
|
+
false
|
|
48
|
+
);
|
|
49
|
+
}, []);
|
|
50
|
+
|
|
51
|
+
// ── "Slide to cancel" text oscillation ───────────────────────────────────
|
|
52
|
+
const slideTextX = useSharedValue(0);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
slideTextX.value = withRepeat(
|
|
55
|
+
withSequence(
|
|
56
|
+
withTiming(-8, { duration: 600, easing: Easing.inOut(Easing.ease) }),
|
|
57
|
+
withTiming(0, { duration: 600, easing: Easing.inOut(Easing.ease) })
|
|
58
|
+
),
|
|
59
|
+
-1,
|
|
60
|
+
false
|
|
61
|
+
);
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
// ── Chevron bounce ────────────────────────────────────────────────────────
|
|
65
|
+
const chevronY = useSharedValue(0);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
chevronY.value = withRepeat(
|
|
68
|
+
withSequence(
|
|
69
|
+
withTiming(-5, { duration: 450, easing: Easing.inOut(Easing.ease) }),
|
|
70
|
+
withTiming(0, { duration: 450, easing: Easing.inOut(Easing.ease) })
|
|
71
|
+
),
|
|
72
|
+
-1,
|
|
73
|
+
false
|
|
74
|
+
);
|
|
75
|
+
}, []);
|
|
76
|
+
|
|
77
|
+
// ── Lock open/close ───────────────────────────────────────────────────────
|
|
78
|
+
const lockScale = useSharedValue(0.8);
|
|
79
|
+
useEffect(() => {
|
|
80
|
+
lockScale.value = withRepeat(
|
|
81
|
+
withSequence(
|
|
82
|
+
withTiming(1, { duration: 550, easing: Easing.out(Easing.ease) }),
|
|
83
|
+
withTiming(0.8, { duration: 550, easing: Easing.in(Easing.ease) })
|
|
84
|
+
),
|
|
85
|
+
-1,
|
|
86
|
+
false
|
|
87
|
+
);
|
|
88
|
+
}, []);
|
|
89
|
+
|
|
90
|
+
const micStyle = useAnimatedStyle(() => ({
|
|
91
|
+
transform: [{ scale: micScale.value }],
|
|
92
|
+
}));
|
|
93
|
+
|
|
94
|
+
// Fade + shift the "slide to cancel" text as user drags left
|
|
95
|
+
const cancelProgress = Math.min(1, Math.abs(Math.min(0, slideX)) / 70);
|
|
96
|
+
|
|
97
|
+
const slideTextStyle = useAnimatedStyle(() => ({
|
|
98
|
+
transform: [{ translateX: slideTextX.value }],
|
|
99
|
+
opacity: 1 - cancelProgress,
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
const chevronStyle = useAnimatedStyle(() => ({
|
|
103
|
+
transform: [{ translateY: chevronY.value }],
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const lockStyle = useAnimatedStyle(() => ({
|
|
107
|
+
transform: [{ scale: lockScale.value }],
|
|
108
|
+
}));
|
|
109
|
+
|
|
110
|
+
const micSize = containerHeight * 0.5;
|
|
111
|
+
|
|
112
|
+
return (
|
|
113
|
+
<View
|
|
114
|
+
style={[
|
|
115
|
+
{
|
|
116
|
+
flexDirection: 'row',
|
|
117
|
+
alignItems: 'center',
|
|
118
|
+
height: containerHeight,
|
|
119
|
+
paddingHorizontal: 8,
|
|
120
|
+
},
|
|
121
|
+
voiceRecorderStyles?.container,
|
|
122
|
+
]}
|
|
123
|
+
>
|
|
124
|
+
{/* ── Animated mic (breathing) ── */}
|
|
125
|
+
<Animated.View
|
|
126
|
+
style={[
|
|
127
|
+
micStyle,
|
|
128
|
+
{
|
|
129
|
+
width: containerHeight,
|
|
130
|
+
height: containerHeight,
|
|
131
|
+
borderRadius: containerHeight / 2,
|
|
132
|
+
backgroundColor: `${micPulseColor}22`,
|
|
133
|
+
justifyContent: 'center',
|
|
134
|
+
alignItems: 'center',
|
|
135
|
+
},
|
|
136
|
+
voiceRecorderStyles?.micButton,
|
|
137
|
+
]}
|
|
138
|
+
>
|
|
139
|
+
<MicrophoneIcon
|
|
140
|
+
style={{ width: micSize, height: micSize }}
|
|
141
|
+
color={micPulseColor}
|
|
142
|
+
/>
|
|
143
|
+
</Animated.View>
|
|
144
|
+
|
|
145
|
+
{/* ── Timer ── */}
|
|
146
|
+
<Text
|
|
147
|
+
style={[
|
|
148
|
+
{
|
|
149
|
+
fontSize: 15,
|
|
150
|
+
fontWeight: '600',
|
|
151
|
+
color: '#374151',
|
|
152
|
+
marginLeft: 8,
|
|
153
|
+
fontFamily,
|
|
154
|
+
},
|
|
155
|
+
voiceRecorderStyles?.timer,
|
|
156
|
+
]}
|
|
157
|
+
>
|
|
158
|
+
{formatDuration(duration)}
|
|
159
|
+
</Text>
|
|
160
|
+
|
|
161
|
+
{/* ── "Slide to cancel" text ── */}
|
|
162
|
+
<Animated.View
|
|
163
|
+
style={[slideTextStyle, { flex: 1, alignItems: 'center' }]}
|
|
164
|
+
>
|
|
165
|
+
<Text
|
|
166
|
+
style={[
|
|
167
|
+
{
|
|
168
|
+
fontSize: 14,
|
|
169
|
+
color: cancelTextColor,
|
|
170
|
+
fontFamily,
|
|
171
|
+
},
|
|
172
|
+
voiceRecorderStyles?.slideText,
|
|
173
|
+
]}
|
|
174
|
+
>
|
|
175
|
+
{'< Slide to cancel'}
|
|
176
|
+
</Text>
|
|
177
|
+
</Animated.View>
|
|
178
|
+
|
|
179
|
+
{/* ── Lock + Chevron column ── */}
|
|
180
|
+
<View
|
|
181
|
+
style={[
|
|
182
|
+
{ alignItems: 'center', marginRight: 4 },
|
|
183
|
+
voiceRecorderStyles?.lockContainer,
|
|
184
|
+
]}
|
|
185
|
+
>
|
|
186
|
+
<Animated.View style={lockStyle}>
|
|
187
|
+
<LockIcon style={{ width: 18, height: 18 }} color="#6b7280" />
|
|
188
|
+
</Animated.View>
|
|
189
|
+
<Animated.View style={chevronStyle}>
|
|
190
|
+
<ChevronUpIcon style={{ width: 18, height: 18 }} color="#6b7280" />
|
|
191
|
+
</Animated.View>
|
|
192
|
+
</View>
|
|
193
|
+
</View>
|
|
194
|
+
);
|
|
195
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Pressable, Text, View } from 'react-native';
|
|
3
|
+
import { PaperPlaneIcon } from '../../assets/Icons/PaperPlaneIcon';
|
|
4
|
+
import { PauseIcon } from '../../assets/Icons/PauseIcon';
|
|
5
|
+
import { PlayIcon } from '../../assets/Icons/PlayIcon';
|
|
6
|
+
import { TrashIcon } from '../../assets/Icons/TrashIcon';
|
|
7
|
+
import { formatDuration } from '../../utils/datefunc';
|
|
8
|
+
import {
|
|
9
|
+
RecordingUIProps,
|
|
10
|
+
VoiceRecorderStyleOverrides,
|
|
11
|
+
} from '../../types';
|
|
12
|
+
import { WaveformAnimation } from './WaveformAnimation';
|
|
13
|
+
|
|
14
|
+
interface NormalRecordingProps {
|
|
15
|
+
isRecording: boolean;
|
|
16
|
+
isPaused: boolean;
|
|
17
|
+
duration: number;
|
|
18
|
+
onCancel: () => void;
|
|
19
|
+
onSend: () => void;
|
|
20
|
+
onPause: () => void;
|
|
21
|
+
onResume: () => void;
|
|
22
|
+
containerHeight?: number;
|
|
23
|
+
fontFamily?: string;
|
|
24
|
+
sendButtonColor?: string;
|
|
25
|
+
sendIconColor?: string;
|
|
26
|
+
enablePauseResume?: boolean;
|
|
27
|
+
voiceRecorderStyles?: VoiceRecorderStyleOverrides;
|
|
28
|
+
recordingUIProps?: RecordingUIProps;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const NormalRecording: React.FC<NormalRecordingProps> = ({
|
|
32
|
+
isRecording,
|
|
33
|
+
isPaused,
|
|
34
|
+
duration,
|
|
35
|
+
onCancel,
|
|
36
|
+
onSend,
|
|
37
|
+
onPause,
|
|
38
|
+
onResume,
|
|
39
|
+
containerHeight = 50,
|
|
40
|
+
fontFamily,
|
|
41
|
+
sendButtonColor = '#16a34a',
|
|
42
|
+
sendIconColor = '#ffffff',
|
|
43
|
+
enablePauseResume = true,
|
|
44
|
+
voiceRecorderStyles,
|
|
45
|
+
recordingUIProps,
|
|
46
|
+
}) => {
|
|
47
|
+
const waveColor = recordingUIProps?.waveformColor ?? 'rgba(0,0,0,0.45)';
|
|
48
|
+
const cancelColor = recordingUIProps?.cancelTextColor ?? '#ef4444';
|
|
49
|
+
const timerColor = '#374151';
|
|
50
|
+
const bg = recordingUIProps?.recordingBackground ?? 'transparent';
|
|
51
|
+
const timerTextStyle = recordingUIProps?.timerTextStyle;
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<View
|
|
55
|
+
style={[
|
|
56
|
+
{
|
|
57
|
+
flexDirection: 'row',
|
|
58
|
+
alignItems: 'center',
|
|
59
|
+
height: containerHeight,
|
|
60
|
+
paddingHorizontal: 4,
|
|
61
|
+
backgroundColor: bg,
|
|
62
|
+
gap: 8,
|
|
63
|
+
},
|
|
64
|
+
voiceRecorderStyles?.container,
|
|
65
|
+
]}
|
|
66
|
+
>
|
|
67
|
+
{/* ── Cancel / Trash ── */}
|
|
68
|
+
<Pressable
|
|
69
|
+
onPress={onCancel}
|
|
70
|
+
style={[
|
|
71
|
+
{
|
|
72
|
+
width: containerHeight,
|
|
73
|
+
height: containerHeight,
|
|
74
|
+
borderRadius: containerHeight / 2,
|
|
75
|
+
justifyContent: 'center',
|
|
76
|
+
alignItems: 'center',
|
|
77
|
+
backgroundColor: `${cancelColor}18`,
|
|
78
|
+
},
|
|
79
|
+
voiceRecorderStyles?.trashButton,
|
|
80
|
+
]}
|
|
81
|
+
hitSlop={6}
|
|
82
|
+
>
|
|
83
|
+
<TrashIcon
|
|
84
|
+
style={{ width: containerHeight * 0.44, height: containerHeight * 0.44 }}
|
|
85
|
+
color={cancelColor}
|
|
86
|
+
/>
|
|
87
|
+
</Pressable>
|
|
88
|
+
|
|
89
|
+
{/* ── Timer ── */}
|
|
90
|
+
<Text
|
|
91
|
+
style={[
|
|
92
|
+
{
|
|
93
|
+
fontSize: 15,
|
|
94
|
+
fontWeight: '600',
|
|
95
|
+
color: timerColor,
|
|
96
|
+
minWidth: 40,
|
|
97
|
+
fontFamily,
|
|
98
|
+
},
|
|
99
|
+
voiceRecorderStyles?.timer,
|
|
100
|
+
timerTextStyle,
|
|
101
|
+
]}
|
|
102
|
+
>
|
|
103
|
+
{formatDuration(duration)}
|
|
104
|
+
</Text>
|
|
105
|
+
|
|
106
|
+
{/* ── Waveform ── */}
|
|
107
|
+
<WaveformAnimation
|
|
108
|
+
isActive={isRecording && !isPaused}
|
|
109
|
+
color={waveColor}
|
|
110
|
+
height={Math.round(containerHeight * 0.52)}
|
|
111
|
+
style={[{ flex: 1 }, voiceRecorderStyles?.waveform]}
|
|
112
|
+
/>
|
|
113
|
+
|
|
114
|
+
{/* ── Pause / Resume ── */}
|
|
115
|
+
{enablePauseResume && (
|
|
116
|
+
<Pressable
|
|
117
|
+
onPress={isPaused ? onResume : onPause}
|
|
118
|
+
style={{
|
|
119
|
+
width: 36,
|
|
120
|
+
height: 36,
|
|
121
|
+
borderRadius: 18,
|
|
122
|
+
backgroundColor: 'rgba(0,0,0,0.08)',
|
|
123
|
+
justifyContent: 'center',
|
|
124
|
+
alignItems: 'center',
|
|
125
|
+
}}
|
|
126
|
+
hitSlop={6}
|
|
127
|
+
>
|
|
128
|
+
{isPaused ? (
|
|
129
|
+
<PlayIcon style={{ width: 18, height: 18 }} color="#374151" />
|
|
130
|
+
) : (
|
|
131
|
+
<PauseIcon style={{ width: 18, height: 18 }} color="#374151" />
|
|
132
|
+
)}
|
|
133
|
+
</Pressable>
|
|
134
|
+
)}
|
|
135
|
+
|
|
136
|
+
{/* ── Send ── */}
|
|
137
|
+
<Pressable
|
|
138
|
+
onPress={onSend}
|
|
139
|
+
style={{
|
|
140
|
+
width: containerHeight,
|
|
141
|
+
height: containerHeight,
|
|
142
|
+
borderRadius: containerHeight / 2,
|
|
143
|
+
backgroundColor: sendButtonColor,
|
|
144
|
+
justifyContent: 'center',
|
|
145
|
+
alignItems: 'center',
|
|
146
|
+
}}
|
|
147
|
+
hitSlop={4}
|
|
148
|
+
>
|
|
149
|
+
<PaperPlaneIcon
|
|
150
|
+
style={{ width: containerHeight * 0.44, height: containerHeight * 0.44 }}
|
|
151
|
+
color={sendIconColor}
|
|
152
|
+
/>
|
|
153
|
+
</Pressable>
|
|
154
|
+
</View>
|
|
155
|
+
);
|
|
156
|
+
};
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { StyleProp, View, ViewStyle } from 'react-native';
|
|
3
|
+
|
|
4
|
+
const BAR_COUNT = 22;
|
|
5
|
+
|
|
6
|
+
function randomBars(): number[] {
|
|
7
|
+
return Array.from({ length: BAR_COUNT }, () => 0.15 + Math.random() * 0.85);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface WaveformAnimationProps {
|
|
11
|
+
isActive: boolean;
|
|
12
|
+
color?: string;
|
|
13
|
+
height?: number;
|
|
14
|
+
style?: StyleProp<ViewStyle>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const WaveformAnimation: React.FC<WaveformAnimationProps> = ({
|
|
18
|
+
isActive,
|
|
19
|
+
color = 'rgba(0,0,0,0.45)',
|
|
20
|
+
height = 26,
|
|
21
|
+
style,
|
|
22
|
+
}) => {
|
|
23
|
+
const [bars, setBars] = useState<number[]>(() =>
|
|
24
|
+
Array.from({ length: BAR_COUNT }, () => 0.3)
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
useEffect(() => {
|
|
28
|
+
if (!isActive) {
|
|
29
|
+
setBars(Array.from({ length: BAR_COUNT }, () => 0.3));
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
const id = setInterval(() => setBars(randomBars()), 110);
|
|
33
|
+
return () => clearInterval(id);
|
|
34
|
+
}, [isActive]);
|
|
35
|
+
|
|
36
|
+
return (
|
|
37
|
+
<View
|
|
38
|
+
style={[
|
|
39
|
+
{ flexDirection: 'row', alignItems: 'center', height, gap: 2 },
|
|
40
|
+
style,
|
|
41
|
+
]}
|
|
42
|
+
>
|
|
43
|
+
{bars.map((amp, i) => (
|
|
44
|
+
<View
|
|
45
|
+
key={i}
|
|
46
|
+
style={{
|
|
47
|
+
flex: 1,
|
|
48
|
+
height: Math.max(3, Math.round(amp * height)),
|
|
49
|
+
borderRadius: 2,
|
|
50
|
+
backgroundColor: color,
|
|
51
|
+
}}
|
|
52
|
+
/>
|
|
53
|
+
))}
|
|
54
|
+
</View>
|
|
55
|
+
);
|
|
56
|
+
};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { RecordingResult } from '../types';
|
|
3
|
+
|
|
4
|
+
export type RecordingStatus = 'idle' | 'recording' | 'paused';
|
|
5
|
+
|
|
6
|
+
interface UseVoiceRecorderOptions {
|
|
7
|
+
maxDuration?: number;
|
|
8
|
+
onRecordStart?: () => void;
|
|
9
|
+
onRecordEnd?: (result: RecordingResult) => void;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function useVoiceRecorder({
|
|
13
|
+
maxDuration = 300,
|
|
14
|
+
onRecordStart,
|
|
15
|
+
onRecordEnd,
|
|
16
|
+
}: UseVoiceRecorderOptions = {}) {
|
|
17
|
+
const [status, setStatus] = useState<RecordingStatus>('idle');
|
|
18
|
+
const [duration, setDuration] = useState(0);
|
|
19
|
+
|
|
20
|
+
const recordingRef = useRef<any>(null);
|
|
21
|
+
const timerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
|
22
|
+
const durationRef = useRef(0);
|
|
23
|
+
|
|
24
|
+
// Keep callback refs stable so the PanResponder (created once) always calls the latest version
|
|
25
|
+
const onRecordEndRef = useRef(onRecordEnd);
|
|
26
|
+
onRecordEndRef.current = onRecordEnd;
|
|
27
|
+
const onRecordStartRef = useRef(onRecordStart);
|
|
28
|
+
onRecordStartRef.current = onRecordStart;
|
|
29
|
+
const maxDurationRef = useRef(maxDuration);
|
|
30
|
+
maxDurationRef.current = maxDuration;
|
|
31
|
+
|
|
32
|
+
const stopTimer = useCallback(() => {
|
|
33
|
+
if (timerRef.current) {
|
|
34
|
+
clearInterval(timerRef.current);
|
|
35
|
+
timerRef.current = null;
|
|
36
|
+
}
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const startTimer = useCallback(
|
|
40
|
+
(onMaxDuration: () => void) => {
|
|
41
|
+
stopTimer();
|
|
42
|
+
timerRef.current = setInterval(() => {
|
|
43
|
+
durationRef.current += 1;
|
|
44
|
+
setDuration(durationRef.current);
|
|
45
|
+
if (durationRef.current >= maxDurationRef.current) {
|
|
46
|
+
onMaxDuration();
|
|
47
|
+
}
|
|
48
|
+
}, 1000);
|
|
49
|
+
},
|
|
50
|
+
[stopTimer]
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
const startRecording = useCallback(async () => {
|
|
54
|
+
let Audio: any;
|
|
55
|
+
try {
|
|
56
|
+
Audio = require('expo-av').Audio;
|
|
57
|
+
} catch {
|
|
58
|
+
console.error(
|
|
59
|
+
'[movius-chats] Voice recording requires expo-av. ' +
|
|
60
|
+
'Install it with: bun add expo-av'
|
|
61
|
+
);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
try {
|
|
66
|
+
const { status: permStatus } = await Audio.requestPermissionsAsync();
|
|
67
|
+
if (permStatus !== 'granted') {
|
|
68
|
+
console.warn('[movius-chats] Microphone permission denied.');
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
await Audio.setAudioModeAsync({
|
|
73
|
+
allowsRecordingIOS: true,
|
|
74
|
+
playsInSilentModeIOS: true,
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const recording = new Audio.Recording();
|
|
78
|
+
await recording.prepareToRecordAsync(
|
|
79
|
+
Audio.RecordingOptionsPresets.HIGH_QUALITY
|
|
80
|
+
);
|
|
81
|
+
await recording.startAsync();
|
|
82
|
+
|
|
83
|
+
recordingRef.current = recording;
|
|
84
|
+
durationRef.current = 0;
|
|
85
|
+
setDuration(0);
|
|
86
|
+
setStatus('recording');
|
|
87
|
+
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
89
|
+
startTimer(() => stopRecording());
|
|
90
|
+
|
|
91
|
+
onRecordStartRef.current?.();
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn('[movius-chats] Failed to start recording:', e);
|
|
94
|
+
}
|
|
95
|
+
}, [startTimer]); // stopRecording defined below — used via ref
|
|
96
|
+
|
|
97
|
+
const pauseRecording = useCallback(async () => {
|
|
98
|
+
if (!recordingRef.current) return;
|
|
99
|
+
try {
|
|
100
|
+
await recordingRef.current.pauseAsync();
|
|
101
|
+
setStatus('paused');
|
|
102
|
+
stopTimer();
|
|
103
|
+
} catch (e) {
|
|
104
|
+
console.warn('[movius-chats] Failed to pause:', e);
|
|
105
|
+
}
|
|
106
|
+
}, [stopTimer]);
|
|
107
|
+
|
|
108
|
+
const resumeRecording = useCallback(async () => {
|
|
109
|
+
if (!recordingRef.current) return;
|
|
110
|
+
try {
|
|
111
|
+
await recordingRef.current.startAsync();
|
|
112
|
+
setStatus('recording');
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
114
|
+
startTimer(() => stopRecording());
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.warn('[movius-chats] Failed to resume:', e);
|
|
117
|
+
}
|
|
118
|
+
}, [startTimer]);
|
|
119
|
+
|
|
120
|
+
// Exposed as a stable ref so the timer callback (closed at creation) can call it
|
|
121
|
+
const stopRecordingImpl = useCallback(async (): Promise<RecordingResult | null> => {
|
|
122
|
+
const rec = recordingRef.current;
|
|
123
|
+
if (!rec) return null;
|
|
124
|
+
|
|
125
|
+
stopTimer();
|
|
126
|
+
recordingRef.current = null;
|
|
127
|
+
const capturedDuration = durationRef.current;
|
|
128
|
+
durationRef.current = 0;
|
|
129
|
+
setStatus('idle');
|
|
130
|
+
setDuration(0);
|
|
131
|
+
|
|
132
|
+
try {
|
|
133
|
+
await rec.stopAndUnloadAsync();
|
|
134
|
+
const uri = rec.getURI() as string | null;
|
|
135
|
+
if (!uri) return null;
|
|
136
|
+
|
|
137
|
+
let durMs = capturedDuration * 1000;
|
|
138
|
+
try {
|
|
139
|
+
const st = await rec.getStatusAsync();
|
|
140
|
+
if (st?.durationMillis) durMs = st.durationMillis;
|
|
141
|
+
} catch {}
|
|
142
|
+
|
|
143
|
+
const result: RecordingResult = {
|
|
144
|
+
uri,
|
|
145
|
+
duration: Math.max(1, Math.round(durMs / 1000)),
|
|
146
|
+
mimeType: 'audio/m4a',
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
onRecordEndRef.current?.(result);
|
|
150
|
+
return result;
|
|
151
|
+
} catch (e) {
|
|
152
|
+
console.warn('[movius-chats] Failed to stop recording:', e);
|
|
153
|
+
return null;
|
|
154
|
+
}
|
|
155
|
+
}, [stopTimer]);
|
|
156
|
+
|
|
157
|
+
// Stable ref so the timer closure can call the latest implementation
|
|
158
|
+
const stopRecordingRef = useRef(stopRecordingImpl);
|
|
159
|
+
stopRecordingRef.current = stopRecordingImpl;
|
|
160
|
+
|
|
161
|
+
const stopRecording = useCallback(
|
|
162
|
+
() => stopRecordingRef.current(),
|
|
163
|
+
[]
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
const cancelRecording = useCallback(async () => {
|
|
167
|
+
const rec = recordingRef.current;
|
|
168
|
+
stopTimer();
|
|
169
|
+
recordingRef.current = null;
|
|
170
|
+
durationRef.current = 0;
|
|
171
|
+
setStatus('idle');
|
|
172
|
+
setDuration(0);
|
|
173
|
+
|
|
174
|
+
if (rec) {
|
|
175
|
+
try {
|
|
176
|
+
await rec.stopAndUnloadAsync();
|
|
177
|
+
const uri = rec.getURI() as string | null;
|
|
178
|
+
if (uri) {
|
|
179
|
+
try {
|
|
180
|
+
const { deleteAsync } = require('expo-file-system');
|
|
181
|
+
await deleteAsync(uri, { idempotent: true });
|
|
182
|
+
} catch {}
|
|
183
|
+
}
|
|
184
|
+
} catch {}
|
|
185
|
+
}
|
|
186
|
+
}, [stopTimer]);
|
|
187
|
+
|
|
188
|
+
useEffect(() => {
|
|
189
|
+
return () => {
|
|
190
|
+
stopTimer();
|
|
191
|
+
recordingRef.current?.stopAndUnloadAsync().catch(() => {});
|
|
192
|
+
};
|
|
193
|
+
}, [stopTimer]);
|
|
194
|
+
|
|
195
|
+
return {
|
|
196
|
+
status,
|
|
197
|
+
duration,
|
|
198
|
+
isRecording: status === 'recording',
|
|
199
|
+
isPaused: status === 'paused',
|
|
200
|
+
startRecording,
|
|
201
|
+
pauseRecording,
|
|
202
|
+
resumeRecording,
|
|
203
|
+
stopRecording,
|
|
204
|
+
cancelRecording,
|
|
205
|
+
};
|
|
206
|
+
}
|