Matthias Nott
2026-03-02 aca79f31767ae6f03f47a284f3d0e80850c5fb02
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>
  );
}