1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
| | import React, { useCallback, useRef, useState } from "react";
| | import { Animated, Pressable, Text, View } from "react-native";
| | import * as Haptics from "expo-haptics";
| | import { startRecording, stopRecording } from "../../services/audio";
| | import { Audio } from "expo-av";
| |
| | interface VoiceButtonProps {
| | onVoiceMessage: (audioUri: string, durationMs: number) => void;
| | }
| |
| | const VOICE_BUTTON_SIZE = 88;
| |
| | export function VoiceButton({ onVoiceMessage }: VoiceButtonProps) {
| | const [isRecording, setIsRecording] = useState(false);
| | const recordingRef = useRef<Audio.Recording | null>(null);
| | const scaleAnim = useRef(new Animated.Value(1)).current;
| | const pulseAnim = useRef(new Animated.Value(1)).current;
| | const pulseLoop = useRef<Animated.CompositeAnimation | null>(null);
| |
| | const startPulse = useCallback(() => {
| | pulseLoop.current = Animated.loop(
| | Animated.sequence([
| | Animated.timing(pulseAnim, {
| | toValue: 1.15,
| | duration: 600,
| | useNativeDriver: true,
| | }),
| | Animated.timing(pulseAnim, {
| | toValue: 1,
| | duration: 600,
| | useNativeDriver: true,
| | }),
| | ])
| | );
| | pulseLoop.current.start();
| | }, [pulseAnim]);
| |
| | const stopPulse = useCallback(() => {
| | pulseLoop.current?.stop();
| | pulseAnim.setValue(1);
| | }, [pulseAnim]);
| |
| | const handlePressIn = useCallback(async () => {
| | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Medium);
| |
| | Animated.spring(scaleAnim, {
| | toValue: 0.92,
| | useNativeDriver: true,
| | }).start();
| |
| | const recording = await startRecording();
| | if (recording) {
| | recordingRef.current = recording;
| | setIsRecording(true);
| | startPulse();
| | }
| | }, [scaleAnim, startPulse]);
| |
| | const handlePressOut = useCallback(async () => {
| | Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
| |
| | Animated.spring(scaleAnim, {
| | toValue: 1,
| | useNativeDriver: true,
| | }).start();
| |
| | stopPulse();
| | setIsRecording(false);
| |
| | if (recordingRef.current) {
| | const result = await stopRecording();
| | recordingRef.current = null;
| |
| | if (result && result.durationMs > 500) {
| | onVoiceMessage(result.uri, result.durationMs);
| | }
| | }
| | }, [scaleAnim, stopPulse, onVoiceMessage]);
| |
| | return (
| | <View className="items-center justify-center py-4">
| | {/* Pulse ring — only visible while recording */}
| | <Animated.View
| | style={{
| | position: "absolute",
| | width: VOICE_BUTTON_SIZE + 24,
| | height: VOICE_BUTTON_SIZE + 24,
| | borderRadius: (VOICE_BUTTON_SIZE + 24) / 2,
| | backgroundColor: isRecording ? "rgba(255, 159, 67, 0.15)" : "transparent",
| | transform: [{ scale: pulseAnim }],
| | }}
| | />
| |
| | {/* Button */}
| | <Animated.View style={{ transform: [{ scale: scaleAnim }] }}>
| | <Pressable
| | onPressIn={handlePressIn}
| | onPressOut={handlePressOut}
| | style={{
| | width: VOICE_BUTTON_SIZE,
| | height: VOICE_BUTTON_SIZE,
| | borderRadius: VOICE_BUTTON_SIZE / 2,
| | backgroundColor: isRecording ? "#FF9F43" : "#4A9EFF",
| | alignItems: "center",
| | justifyContent: "center",
| | shadowColor: isRecording ? "#FF9F43" : "#4A9EFF",
| | shadowOffset: { width: 0, height: 4 },
| | shadowOpacity: 0.4,
| | shadowRadius: 12,
| | elevation: 8,
| | }}
| | >
| | <Text style={{ fontSize: 32 }}>{isRecording ? "🎙" : "🎤"}</Text>
| | </Pressable>
| | </Animated.View>
| |
| | <Text className="text-pai-text-muted text-xs mt-3">
| | {isRecording ? "Release to send" : "Hold to talk"}
| | </Text>
| | </View>
| | );
| | }
|
|