Skip to content

Commit 870616a

Browse files
authored
Merge pull request #31 from token-js/sg/tts
feat: add audio messages
2 parents c3fe019 + 6c1222c commit 870616a

File tree

26 files changed

+840
-310
lines changed

26 files changed

+840
-310
lines changed

Dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ RUN apt-get update && apt-get install -y \
1919
# Copy only the package.json, package-lock.json, and prisma schema to cache dependencies
2020
COPY package.json package-lock.json /app/
2121
COPY prisma/schema.prisma prisma/schema.prisma
22-
COPY assets/filler_sound.wav /app/assets/
2322

2423
# Copy the server directory (assuming it contains requirements.txt)
2524
COPY /server /app/server/

app/(home)/index.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ export default function App() {
6969
} else {
7070
return (
7171
<View style={styles.container}>
72-
<HomeScreen />
72+
<HomeScreen settings={settings}/>
7373
</View>
7474
);
7575
}

assets/filler_sound.wav

-709 KB
Binary file not shown.

components/audio-message/index.tsx

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { useAudioPlayerContext } from "@/components/audio-player-context";
2+
import { Ionicons } from "@expo/vector-icons";
3+
import { Session } from "@supabase/supabase-js";
4+
import { Audio, AVPlaybackStatus } from "expo-av";
5+
import * as FileSystem from "expo-file-system";
6+
import React, { useCallback, useEffect, useRef, useState } from "react";
7+
import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
8+
9+
type AudioMessageProps = {
10+
audioId: string;
11+
autoplay: boolean;
12+
session: Session;
13+
};
14+
15+
export const AudioMessage: React.FC<AudioMessageProps> = ({
16+
audioId,
17+
autoplay,
18+
session,
19+
}) => {
20+
const soundRef = useRef<Audio.Sound | null>(null);
21+
const [isPlaying, setIsPlaying] = useState<boolean>(false);
22+
const [isLoading, setIsLoading] = useState<boolean>(false);
23+
const isCancelledRef = useRef<boolean>(false);
24+
25+
const {
26+
currentlyPlayingId,
27+
setCurrentlyPlayingId,
28+
currentlyLoadingId,
29+
setCurrentlyLoadingId,
30+
} = useAudioPlayerContext();
31+
32+
const loadAndPlayAudio = useCallback(async () => {
33+
setIsLoading(true);
34+
setCurrentlyLoadingId(audioId);
35+
isCancelledRef.current = false;
36+
37+
try {
38+
await Audio.setAudioModeAsync({
39+
playsInSilentModeIOS: true,
40+
});
41+
42+
const localAudioUri = `${FileSystem.documentDirectory}${audioId}.mp3`;
43+
44+
const fileInfo = await FileSystem.getInfoAsync(localAudioUri);
45+
46+
let sound: Audio.Sound;
47+
if (fileInfo.exists) {
48+
// Load the audio from local storage
49+
({ sound } = await Audio.Sound.createAsync({
50+
uri: localAudioUri,
51+
}));
52+
53+
if (isCancelledRef.current) {
54+
sound.unloadAsync();
55+
return;
56+
}
57+
} else {
58+
// Fetch the audio from the network
59+
const headers = {
60+
Authorization: `Bearer ${session.access_token}`,
61+
};
62+
63+
const audioUrl = `https://${process.env.EXPO_PUBLIC_API_URL}/api/fetchAudio?audioId=${audioId}`;
64+
65+
// Download the audio file to local storage
66+
await FileSystem.downloadAsync(audioUrl, localAudioUri, {
67+
headers,
68+
});
69+
70+
// Load the audio from the newly saved file
71+
({ sound } = await Audio.Sound.createAsync({
72+
uri: localAudioUri,
73+
}));
74+
75+
if (isCancelledRef.current) {
76+
sound.unloadAsync();
77+
return;
78+
}
79+
}
80+
81+
setIsLoading(false);
82+
setCurrentlyLoadingId(null);
83+
setIsPlaying(true);
84+
setCurrentlyPlayingId(audioId);
85+
86+
await sound.playAsync();
87+
sound.setOnPlaybackStatusUpdate((status: AVPlaybackStatus) => {
88+
if (status.isLoaded) {
89+
setIsPlaying(status.isPlaying);
90+
if (status.didJustFinish) {
91+
sound.unloadAsync();
92+
soundRef.current = null;
93+
setCurrentlyPlayingId(null);
94+
}
95+
}
96+
});
97+
98+
soundRef.current = sound;
99+
} catch (error) {
100+
console.error("Error loading audio:", error);
101+
setIsLoading(false);
102+
setCurrentlyLoadingId(null);
103+
setIsPlaying(false);
104+
setCurrentlyPlayingId(null);
105+
}
106+
}, [audioId, session.access_token]);
107+
108+
useEffect(() => {
109+
// Pause this audio if another audio starts playing
110+
if (currentlyPlayingId !== audioId && isPlaying) {
111+
if (soundRef.current) {
112+
soundRef.current.pauseAsync();
113+
setIsPlaying(false);
114+
}
115+
}
116+
}, [currentlyPlayingId, audioId, isPlaying]);
117+
118+
useEffect(() => {
119+
// Cancel this audio's loading if another audio starts loading
120+
if (currentlyLoadingId !== audioId && isLoading) {
121+
isCancelledRef.current = true;
122+
setIsLoading(false);
123+
}
124+
}, [currentlyLoadingId, audioId, isLoading]);
125+
126+
useEffect(() => {
127+
if (autoplay && !soundRef.current) {
128+
loadAndPlayAudio();
129+
}
130+
131+
// Cleanup when the component unmounts
132+
return () => {
133+
if (isLoading) {
134+
isCancelledRef.current = true;
135+
setIsLoading(false);
136+
setCurrentlyLoadingId(null);
137+
}
138+
if (soundRef.current) {
139+
soundRef.current.stopAsync();
140+
soundRef.current.unloadAsync();
141+
setIsPlaying(false);
142+
if (currentlyPlayingId === audioId) {
143+
setCurrentlyPlayingId(null); // Reset context
144+
}
145+
}
146+
};
147+
}, [autoplay, loadAndPlayAudio]);
148+
149+
const onPlayPausePress = async () => {
150+
if (isLoading) {
151+
// Cancel loading
152+
isCancelledRef.current = true;
153+
setIsLoading(false);
154+
setCurrentlyLoadingId(null);
155+
} else if (!soundRef.current) {
156+
// First time loading and playing the audio
157+
await loadAndPlayAudio();
158+
} else {
159+
const status = await soundRef.current.getStatusAsync();
160+
161+
if (!status.isLoaded) {
162+
soundRef.current = null;
163+
await loadAndPlayAudio();
164+
} else if (status.isPlaying) {
165+
// If the audio is playing, pause it
166+
await soundRef.current.pauseAsync();
167+
setIsPlaying(false);
168+
if (currentlyPlayingId === audioId) {
169+
setCurrentlyPlayingId(null); // Reset context
170+
}
171+
} else {
172+
setIsPlaying(true);
173+
if (currentlyPlayingId !== audioId) {
174+
setCurrentlyPlayingId(audioId);
175+
}
176+
await soundRef.current.playAsync();
177+
}
178+
}
179+
};
180+
181+
const formatTime = (millis: number | null) => {
182+
if (millis === null) return "0:00";
183+
const totalSeconds = Math.floor(millis / 1000);
184+
const minutes = Math.floor(totalSeconds / 60);
185+
const seconds = totalSeconds % 60;
186+
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
187+
};
188+
189+
return (
190+
<View style={styles.audioContainer}>
191+
<TouchableOpacity onPress={onPlayPausePress} style={styles.playButton}>
192+
{isLoading ? (
193+
<Ionicons name="download" size={24} color="#fff" />
194+
) : (
195+
<Ionicons
196+
name={isPlaying ? "pause" : "play"}
197+
size={24}
198+
color="#fff"
199+
/>
200+
)}
201+
</TouchableOpacity>
202+
</View>
203+
);
204+
};
205+
206+
const styles = StyleSheet.create({
207+
audioContainer: {
208+
flexDirection: "row",
209+
alignItems: "center",
210+
backgroundColor: "#f0f0f0",
211+
borderRadius: 10,
212+
padding: 10,
213+
marginVertical: 5,
214+
maxWidth: "80%",
215+
alignSelf: "flex-start",
216+
},
217+
playButton: {
218+
width: 40,
219+
height: 40,
220+
borderRadius: 20,
221+
backgroundColor: "#007AFF",
222+
justifyContent: "center",
223+
alignItems: "center",
224+
},
225+
timeText: {
226+
marginLeft: 10,
227+
fontSize: 16,
228+
color: "#000",
229+
},
230+
});
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import React, { createContext, useContext, useState } from "react";
2+
3+
type AudioPlayerContextProps = {
4+
currentlyPlayingId: string | null;
5+
setCurrentlyPlayingId: React.Dispatch<React.SetStateAction<string | null>>;
6+
currentlyLoadingId: string | null;
7+
setCurrentlyLoadingId: React.Dispatch<React.SetStateAction<string | null>>;
8+
};
9+
10+
const AudioPlayerContext = createContext<AudioPlayerContextProps | undefined>(
11+
undefined
12+
);
13+
14+
export const AudioPlayerProvider: React.FC<{ children: React.ReactNode }> = ({
15+
children,
16+
}) => {
17+
const [currentlyPlayingId, setCurrentlyPlayingId] = useState<string | null>(
18+
null
19+
);
20+
const [currentlyLoadingId, setCurrentlyLoadingId] = useState<string | null>(
21+
null
22+
);
23+
24+
return (
25+
<AudioPlayerContext.Provider
26+
value={{
27+
currentlyPlayingId,
28+
setCurrentlyPlayingId,
29+
currentlyLoadingId,
30+
setCurrentlyLoadingId,
31+
}}
32+
>
33+
{children}
34+
</AudioPlayerContext.Provider>
35+
);
36+
};
37+
38+
export const useAudioPlayerContext = () => {
39+
const context = useContext(AudioPlayerContext);
40+
if (!context) {
41+
throw new Error(
42+
"useAudioPlayerContext must be used within an AudioPlayerProvider"
43+
);
44+
}
45+
return context;
46+
};

0 commit comments

Comments
 (0)