feat: add voice note recording overlay with transcription feedback and worktree support

This commit is contained in:
Mohamed Boudra
2025-10-26 13:48:14 +01:00
parent dcfdccf07f
commit c4bb10d3c9
13 changed files with 895 additions and 194 deletions
+28 -41
View File
@@ -1,4 +1,4 @@
import { useEffect } from "react";
import { useEffect, useState, useRef } from "react";
import { View } from "react-native";
import ReanimatedAnimated, {
useSharedValue,
@@ -24,54 +24,52 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
// Base dimensions
const LINE_SPACING = 8;
const LINE_WIDTH = 8;
const MAX_HEIGHT = orientation === "horizontal" ? 30 : 50;
const MIN_HEIGHT = orientation === "horizontal" ? 12 : 20;
// Shared values for each line's height
// Create shared values for 3 dots unconditionally
const line1Height = useSharedValue(MIN_HEIGHT);
const line2Height = useSharedValue(MIN_HEIGHT);
const line3Height = useSharedValue(MIN_HEIGHT);
// Idle pulse animations (when no volume)
const line1Pulse = useSharedValue(1);
const line2Pulse = useSharedValue(1);
const line3Pulse = useSharedValue(1);
// Start idle animations with different phases
// Start idle animations with different phases for all dots
useEffect(() => {
if (isMuted) {
// When muted, set pulse to 1 (no animation)
// When muted, set all pulses to 1 (no animation)
line1Pulse.value = 1;
line2Pulse.value = 1;
line3Pulse.value = 1;
return;
}
// Line 1 - fastest pulse
// Animate each dot with different phases and durations
line1Pulse.value = withRepeat(
withSequence(
withTiming(1.2, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 0 }),
withTiming(1.15, { duration: 800, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
// Line 2 - medium pulse with offset
line2Pulse.value = withRepeat(
withSequence(
withTiming(1, { duration: 400 }),
withTiming(1.15, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 200 }),
withTiming(1.20, { duration: 1000, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 1000, easing: Easing.inOut(Easing.ease) })
),
-1,
false
);
// Line 3 - slowest pulse with different offset
line3Pulse.value = withRepeat(
withSequence(
withTiming(1, { duration: 600 }),
withTiming(1, { duration: 400 }),
withTiming(1.25, { duration: 1200, easing: Easing.inOut(Easing.ease) }),
withTiming(1, { duration: 1200, easing: Easing.inOut(Easing.ease) })
),
@@ -80,10 +78,10 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
);
}, [isMuted]);
// Update heights based on volume with different responsiveness per line
// Update heights based on volume with different responsiveness for all dots
useEffect(() => {
if (isMuted) {
// When muted, keep lines at minimum height without animation
// When muted, keep all lines at minimum height without animation
line1Height.value = MIN_HEIGHT;
line2Height.value = MIN_HEIGHT;
line3Height.value = MIN_HEIGHT;
@@ -92,25 +90,23 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
if (volume > 0.01) {
// Active volume - animate heights based on volume
// Line 1 - most responsive, follows volume closely
const target1 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.2);
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 1.05);
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
line1Height.value = withSpring(target1, {
damping: 10,
stiffness: 200,
});
// Line 2 - medium responsiveness
const target2 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.9);
line2Height.value = withSpring(target2, {
damping: 12,
stiffness: 150,
damping: 12.5,
stiffness: 175,
});
// Line 3 - smoothest, lags behind
const target3 = MIN_HEIGHT + (MAX_HEIGHT * volume * 0.7);
line3Height.value = withSpring(target3, {
damping: 15,
stiffness: 100,
stiffness: 150,
});
} else {
// No volume - return to minimum
@@ -118,10 +114,12 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
damping: 20,
stiffness: 150,
});
line2Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
});
line3Height.value = withSpring(MIN_HEIGHT, {
damping: 20,
stiffness: 150,
@@ -129,7 +127,10 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
}
}, [volume, isMuted]);
// Animated styles for each line
const lineColor = "#FFFFFF";
const containerHeight = orientation === "horizontal" ? 60 : 100;
// Create animated styles unconditionally at top level
const line1Style = useAnimatedStyle(() => {
const isActive = isDetecting || isSpeaking;
const baseOpacity = isMuted ? 0.3 : isActive ? 0.9 : 0.5;
@@ -160,20 +161,12 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
};
});
const lineColor = "#FFFFFF";
const lineWidth = 8;
const containerHeight = orientation === "horizontal" ? 60 : 100;
return (
<View style={[styles.container, { height: containerHeight }]}>
<ReanimatedAnimated.View
style={[
styles.line,
{
width: lineWidth,
backgroundColor: lineColor,
},
{ width: LINE_WIDTH, backgroundColor: lineColor },
line1Style,
]}
/>
@@ -181,10 +174,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
<ReanimatedAnimated.View
style={[
styles.line,
{
width: lineWidth,
backgroundColor: lineColor,
},
{ width: LINE_WIDTH, backgroundColor: lineColor },
line2Style,
]}
/>
@@ -192,10 +182,7 @@ export function VolumeMeter({ volume, isMuted = false, isDetecting = false, isSp
<ReanimatedAnimated.View
style={[
styles.line,
{
width: lineWidth,
backgroundColor: lineColor,
},
{ width: LINE_WIDTH, backgroundColor: lineColor },
line3Style,
]}
/>