Skip to content

Commit a6b25d1

Browse files
fix: use audio context and buffers for audio fragments
1 parent 1a365fc commit a6b25d1

File tree

1 file changed

+184
-83
lines changed

1 file changed

+184
-83
lines changed

src/components/audio-player.tsx

Lines changed: 184 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -8,153 +8,230 @@ import {
88
SliderThumb,
99
SliderTrack,
1010
Text,
11+
Spinner,
1112
} from "@chakra-ui/react";
12-
import { useEffect, useRef, useState } from "react";
13-
import { FullQuestion } from "../utils/types";
13+
import { useEffect, useRef, useState, useCallback } from "react";
14+
import { fetch } from "@tauri-apps/plugin-http";
1415
import { logger } from "@sentry/react";
16+
import { FullQuestion } from "../utils/types";
1517

1618
interface AudioPlayerProps {
1719
fullQuestion: FullQuestion;
1820
}
1921

2022
export function AudioPlayer({ fullQuestion }: AudioPlayerProps) {
21-
const audioRef = useRef<HTMLAudioElement | null>(null);
23+
const audioCtxRef = useRef<AudioContext | null>(null);
24+
const sourceRef = useRef<AudioBufferSourceNode | null>(null);
25+
const bufferRef = useRef<AudioBuffer | null>(null);
26+
27+
const startTimeRef = useRef<number>(0);
28+
const pausedAtRef = useRef<number>(0);
29+
const animationFrameRef = useRef<number | null>(null);
30+
31+
const fragmentRef = useRef({ start: 0, end: 0, duration: 0 });
2232

2333
const [isPlaying, setIsPlaying] = useState(false);
2434
const [progress, setProgress] = useState(0);
2535
const [duration, setDuration] = useState(0);
36+
const [isLoading, setIsLoading] = useState(false);
2637

2738
useEffect(() => {
28-
if (!fullQuestion.audio) {
29-
return;
30-
}
39+
const audioUrl = fullQuestion.audio?.url;
40+
if (!audioUrl) return;
41+
42+
const loadAudio = async () => {
43+
setIsLoading(true);
44+
try {
45+
if (!audioCtxRef.current) {
46+
audioCtxRef.current = new (window.AudioContext ||
47+
(window as any).webkitAudioContext)();
48+
}
49+
50+
const fragment = parseMediaFragment(audioUrl);
3151

32-
const audio = new Audio(fullQuestion.audio.url);
52+
const response = await fetch(audioUrl);
53+
const arrayBuffer = await response.arrayBuffer();
3354

34-
// Attach handlers BEFORE loading
35-
audio.onloadedmetadata = onLoadedMetadata;
36-
audio.ontimeupdate = onTimeUpdate;
37-
audio.onpause = onPause;
38-
audio.onplay = onPlay;
55+
const audioBuffer =
56+
await audioCtxRef.current.decodeAudioData(arrayBuffer);
57+
bufferRef.current = audioBuffer;
3958

40-
// Add canplay event for better compatibility
41-
audio.oncanplay = () => {
42-
if (audio.duration && !isNaN(audio.duration)) {
43-
setDuration(audio.duration);
59+
const totalBufferDuration = audioBuffer.duration;
60+
const start = fragment.start;
61+
const end = fragment.end || totalBufferDuration;
62+
const validDuration = end - start;
63+
64+
fragmentRef.current = { start, end, duration: validDuration };
65+
66+
setDuration(validDuration);
67+
setProgress(0);
68+
pausedAtRef.current = 0;
69+
} catch (e) {
70+
if (e instanceof Error) {
71+
logger.warn(e.message);
72+
}
73+
} finally {
74+
setIsLoading(false);
4475
}
4576
};
4677

47-
// Preload metadata
48-
audio.preload = "metadata";
49-
audio.load();
50-
51-
audioRef.current = audio;
52-
setIsPlaying(false);
53-
setProgress(0);
78+
loadAudio();
5479

5580
return () => {
56-
if (!audioRef.current) {
57-
return;
81+
stopAudio();
82+
if (audioCtxRef.current) {
83+
audioCtxRef.current.close();
84+
audioCtxRef.current = null;
85+
}
86+
if (animationFrameRef.current) {
87+
cancelAnimationFrame(animationFrameRef.current);
5888
}
59-
audioRef.current.pause();
60-
audioRef.current = null;
6189
};
62-
}, [fullQuestion]);
90+
}, [fullQuestion.audio]);
6391

64-
function onLoadedMetadata() {
65-
if (!audioRef.current) {
66-
return;
92+
const stopAudio = () => {
93+
if (sourceRef.current) {
94+
try {
95+
sourceRef.current.stop();
96+
sourceRef.current.disconnect();
97+
} catch (e) {
98+
// Ignore errors if already stopped
99+
}
100+
sourceRef.current = null;
67101
}
68-
const d = audioRef.current.duration;
69-
if (typeof d !== "number") {
70-
return;
102+
if (animationFrameRef.current) {
103+
cancelAnimationFrame(animationFrameRef.current);
71104
}
72-
setDuration(d);
73-
}
105+
};
106+
107+
const play = useCallback(() => {
108+
if (!audioCtxRef.current || !bufferRef.current) return;
74109

75-
function onTimeUpdate() {
76-
if (!audioRef.current) {
77-
return;
110+
// ensure context is running (browsers suspend it sometimes)
111+
if (audioCtxRef.current.state === "suspended") {
112+
audioCtxRef.current.resume();
78113
}
79-
const currentTime = audioRef.current.currentTime;
80-
if (isNaN(currentTime)) {
81-
return;
114+
115+
const { start: fragmentStart, duration: fragmentDuration } =
116+
fragmentRef.current;
117+
118+
if (pausedAtRef.current >= fragmentDuration) {
119+
pausedAtRef.current = 0;
82120
}
83-
setProgress(currentTime);
84-
}
85121

86-
function onPause() {
87-
setIsPlaying(false);
88-
}
122+
const source = audioCtxRef.current.createBufferSource();
123+
source.buffer = bufferRef.current;
124+
source.connect(audioCtxRef.current.destination);
125+
sourceRef.current = source;
126+
127+
const offset = fragmentStart + pausedAtRef.current;
128+
const durationToPlay = fragmentDuration - pausedAtRef.current;
89129

90-
function onPlay() {
130+
source.start(0, offset, durationToPlay);
131+
132+
startTimeRef.current = audioCtxRef.current.currentTime;
91133
setIsPlaying(true);
92-
}
93134

94-
const togglePlay = async () => {
135+
const updateProgress = () => {
136+
if (!audioCtxRef.current) return;
137+
138+
const elapsed = audioCtxRef.current.currentTime - startTimeRef.current;
139+
const currentProgress = pausedAtRef.current + elapsed;
140+
141+
if (currentProgress >= fragmentDuration) {
142+
handlePause();
143+
setProgress(fragmentDuration);
144+
pausedAtRef.current = fragmentDuration;
145+
} else {
146+
setProgress(currentProgress);
147+
animationFrameRef.current = requestAnimationFrame(updateProgress);
148+
}
149+
};
150+
151+
animationFrameRef.current = requestAnimationFrame(updateProgress);
152+
153+
// Handle natural end (though the duration param in start() usually handles this)
154+
source.onended = () => {
155+
// this triggers on stop() too -> rely on the animation loop for UI logic
156+
};
157+
}, []);
158+
159+
const handlePause = () => {
160+
if (!audioCtxRef.current) return;
161+
162+
const elapsed = audioCtxRef.current.currentTime - startTimeRef.current;
163+
pausedAtRef.current += elapsed;
164+
165+
stopAudio();
166+
setIsPlaying(false);
167+
};
168+
169+
const togglePlay = () => {
95170
if (isPlaying) {
96-
audioRef.current?.pause();
171+
handlePause();
97172
} else {
98-
try {
99-
await audioRef.current?.play();
100-
} catch (e) {
101-
if (e instanceof Error && e.name !== "AbortError") {
102-
logger.warn(e.message);
103-
}
104-
}
173+
play();
105174
}
106175
};
107176

108-
function handleSeek(value: number) {
109-
if (!audioRef.current || isNaN(value)) {
110-
return;
111-
}
177+
const handleSeek = (val: number) => {
178+
if (isNaN(val)) return;
112179

113-
// https://developer.mozilla.org/en-US/docs/Web/API/HTMLMediaElement/readyState
114-
const HAVE_CURRENT_DATA = 2;
115-
if (audioRef.current.readyState < HAVE_CURRENT_DATA) {
116-
return;
117-
}
180+
setProgress(val);
118181

119-
audioRef.current.currentTime = value;
120-
setProgress(value);
121-
}
182+
pausedAtRef.current = val;
183+
184+
if (isPlaying) {
185+
stopAudio();
186+
play();
187+
}
188+
};
122189

123190
function formatTime(time: unknown): string {
124-
if (typeof time !== "number") {
125-
// Somehow, `audioRef.current.currentTime` can be unset
191+
if (typeof time !== "number" || isNaN(time)) {
126192
return "0:00";
127193
}
128194
const minutes = Math.floor(time / 60);
129195
const seconds = Math.floor(time % 60);
130196
return `${minutes}:${seconds < 10 ? "0" : ""}${seconds}`;
131197
}
132198

133-
if (!audioRef.current || !fullQuestion.audio) {
134-
return null;
135-
}
199+
if (!fullQuestion.audio) return null;
136200

137201
return (
138-
<Box role="region" aria-label="Audio player">
139-
<Flex direction="column">
140-
<Flex alignItems="center">
202+
<Box role="region" aria-label="Audio player" width="100%">
203+
<Flex direction="column" gap={2}>
204+
<Flex alignItems="center" gap={4}>
141205
<Button
142206
onClick={togglePlay}
207+
isLoading={isLoading}
208+
isDisabled={isLoading}
143209
aria-label={isPlaying ? "Pause audio" : "Play audio"}
210+
colorScheme="blue"
211+
size="sm"
144212
>
145-
{isPlaying ? "| |" : <TriangleUpIcon style={{ rotate: "90deg" }} />}
213+
{isLoading ? (
214+
<Spinner size="xs" />
215+
) : isPlaying ? (
216+
"||"
217+
) : (
218+
<TriangleUpIcon style={{ transform: "rotate(90deg)" }} />
219+
)}
146220
</Button>
147-
<Text ml={2} aria-live="polite">
148-
{formatTime(audioRef.current.currentTime)} / {formatTime(duration)}
221+
222+
<Text fontSize="sm" fontFamily="monospace">
223+
{formatTime(progress)} / {formatTime(duration)}
149224
</Text>
150225
</Flex>
151226

152227
<Slider
153228
aria-label="Audio progress and seek"
154229
min={0}
155-
max={duration}
230+
max={duration > 0 ? duration : 100}
156231
value={progress}
157-
onChange={(val) => handleSeek(val)}
232+
onChange={handleSeek}
233+
isDisabled={isLoading}
234+
focusThumbOnChange={false}
158235
>
159236
<SliderTrack>
160237
<SliderFilledTrack />
@@ -165,3 +242,27 @@ export function AudioPlayer({ fullQuestion }: AudioPlayerProps) {
165242
</Box>
166243
);
167244
}
245+
246+
/*
247+
* Extracts optional start and end from #t=10,20
248+
*/
249+
function parseMediaFragment(urlStr: string) {
250+
try {
251+
const urlObj = new URL(urlStr);
252+
const fragmentParams = new URLSearchParams(urlObj.hash.substring(1));
253+
const temporal = fragmentParams.get("t");
254+
255+
if (!temporal) return { start: 0, end: null };
256+
257+
const parts = temporal.split(",");
258+
const start = parseFloat(parts[0]);
259+
const end = parts[1] ? parseFloat(parts[1]) : null;
260+
261+
return {
262+
start: !isNaN(start) ? start : 0,
263+
end: end && !isNaN(end) ? end : null,
264+
};
265+
} catch (e) {
266+
return { start: 0, end: null };
267+
}
268+
}

0 commit comments

Comments
 (0)