@@ -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 " ;
1415import { logger } from "@sentry/react" ;
16+ import { FullQuestion } from "../utils/types" ;
1517
1618interface AudioPlayerProps {
1719 fullQuestion : FullQuestion ;
1820}
1921
2022export 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