@@ -45,10 +45,12 @@ export function TimerModal({
4545 const [ isOvertimeStarted , setIsOvertimeStarted ] = useState ( false ) ;
4646 const [ isMuted , setIsMuted ] = useState ( false ) ;
4747
48- // Audio refs
49- const playingAudioRef = useRef ( null ) ;
50- const breakAudioRef = useRef ( null ) ;
51- const overtimeAudioRef = useRef ( null ) ;
48+ // Audio context and buffer refs for true seamless looping
49+ const audioContextRef = useRef ( null ) ;
50+ const audioBuffersRef = useRef ( { } ) ;
51+ const audioSourcesRef = useRef ( { } ) ;
52+ const gainNodesRef = useRef ( { } ) ;
53+ const isInitializedRef = useRef ( false ) ;
5254
5355 const presets = [
5456 { value : "5" , label : "5 min" , seconds : 5 * 60 } ,
@@ -57,58 +59,202 @@ export function TimerModal({
5759 { value : "50" , label : "50 min" , seconds : 50 * 60 } ,
5860 ] ;
5961
60- // Initialize audio elements
62+ // Initialize Web Audio API with aggressive looping
6163 useEffect ( ( ) => {
62- playingAudioRef . current = new Audio ( "/music/playing.mp3" ) ;
63- breakAudioRef . current = new Audio ( "/music/break.mp3" ) ;
64- overtimeAudioRef . current = new Audio ( "/music/overtime.mp3" ) ;
65-
66- // Set audio properties
67- [
68- playingAudioRef . current ,
69- breakAudioRef . current ,
70- overtimeAudioRef . current ,
71- ] . forEach ( ( audio ) => {
64+ const initAudio = async ( ) => {
65+ try {
66+ // Create audio context
67+ const AudioContext = window . AudioContext || window . webkitAudioContext ;
68+ audioContextRef . current = new AudioContext ( ) ;
69+
70+ const loadAudioBuffer = async ( url , key ) => {
71+ try {
72+ const response = await fetch ( url ) ;
73+ const arrayBuffer = await response . arrayBuffer ( ) ;
74+ const audioBuffer = await audioContextRef . current . decodeAudioData (
75+ arrayBuffer
76+ ) ;
77+
78+ // Trim silence from beginning and end
79+ const trimmedBuffer = trimSilence ( audioBuffer ) ;
80+ audioBuffersRef . current [ key ] = trimmedBuffer ;
81+
82+ // Create gain node for this audio
83+ gainNodesRef . current [ key ] = audioContextRef . current . createGain ( ) ;
84+ gainNodesRef . current [ key ] . gain . value = 0.2 ;
85+ gainNodesRef . current [ key ] . connect (
86+ audioContextRef . current . destination
87+ ) ;
88+
89+ console . log (
90+ `Audio ${ key } loaded: ${ trimmedBuffer . duration . toFixed ( 3 ) } s`
91+ ) ;
92+ } catch ( error ) {
93+ console . error ( `Failed to load audio ${ key } :` , error ) ;
94+ // Fallback to HTML5 audio
95+ createFallbackAudio ( url , key ) ;
96+ }
97+ } ;
98+
99+ // Load all audio files
100+ await Promise . all ( [
101+ loadAudioBuffer ( "/music/playing.mp3" , "playing" ) ,
102+ loadAudioBuffer ( "/music/break.mp3" , "break" ) ,
103+ loadAudioBuffer ( "/music/overtime.mp3" , "overtime" ) ,
104+ ] ) ;
105+
106+ isInitializedRef . current = true ;
107+ console . log ( "Web Audio API initialized successfully" ) ;
108+ } catch ( error ) {
109+ console . error ( "Web Audio API initialization failed:" , error ) ;
110+ initFallbackAudio ( ) ;
111+ }
112+ } ;
113+
114+ // Trim silence from audio buffer
115+ const trimSilence = ( buffer ) => {
116+ const threshold = 0 ; // Silence threshold
117+ const channelData = buffer . getChannelData ( 0 ) ;
118+
119+ // Find start of audio (first non-silent sample)
120+ let start = 0 ;
121+ for ( let i = 0 ; i < channelData . length ; i ++ ) {
122+ if ( Math . abs ( channelData [ i ] ) > threshold ) {
123+ start = i ;
124+ break ;
125+ }
126+ }
127+
128+ // Find end of audio (last non-silent sample)
129+ let end = channelData . length - 1 ;
130+ for ( let i = channelData . length - 1 ; i >= 0 ; i -- ) {
131+ if ( Math . abs ( channelData [ i ] ) > threshold ) {
132+ end = i ;
133+ break ;
134+ }
135+ }
136+
137+ // Create new buffer with trimmed audio
138+ const trimmedLength = end - start + 1 ;
139+ const trimmedBuffer = audioContextRef . current . createBuffer (
140+ buffer . numberOfChannels ,
141+ trimmedLength ,
142+ buffer . sampleRate
143+ ) ;
144+
145+ for ( let channel = 0 ; channel < buffer . numberOfChannels ; channel ++ ) {
146+ const originalData = buffer . getChannelData ( channel ) ;
147+ const trimmedData = trimmedBuffer . getChannelData ( channel ) ;
148+ for ( let i = 0 ; i < trimmedLength ; i ++ ) {
149+ trimmedData [ i ] = originalData [ start + i ] ;
150+ }
151+ }
152+
153+ return trimmedBuffer ;
154+ } ;
155+
156+ // Fallback to HTML5 audio if Web Audio API fails
157+ const createFallbackAudio = ( url , key ) => {
158+ const audio = new Audio ( url ) ;
72159 audio . loop = true ;
73- audio . volume = 0.3 ; // Set a reasonable default volume
74- } ) ;
160+ audio . volume = 0.2 ;
161+ audio . preload = "auto" ;
162+ audioBuffersRef . current [ key ] = { audio, isFallback : true } ;
163+ } ;
164+
165+ const initFallbackAudio = ( ) => {
166+ console . log ( "Using HTML5 Audio fallback" ) ;
167+ createFallbackAudio ( "/music/playing.mp3" , "playing" ) ;
168+ createFallbackAudio ( "/music/break.mp3" , "break" ) ;
169+ createFallbackAudio ( "/music/overtime.mp3" , "overtime" ) ;
170+ isInitializedRef . current = true ;
171+ } ;
172+
173+ initAudio ( ) ;
75174
76- // Cleanup function
77175 return ( ) => {
78- [
79- playingAudioRef . current ,
80- breakAudioRef . current ,
81- overtimeAudioRef . current ,
82- ] . forEach ( ( audio ) => {
83- if ( audio ) {
84- audio . pause ( ) ;
85- audio . currentTime = 0 ;
86- }
87- } ) ;
176+ // Cleanup
177+ stopAllAudio ( ) ;
178+ if (
179+ audioContextRef . current &&
180+ audioContextRef . current . state !== "closed"
181+ ) {
182+ audioContextRef . current . close ( ) ;
183+ }
88184 } ;
89185 } , [ ] ) ;
90186
91- // Audio control function
92- const playAudio = ( audioRef , shouldPlay = ! isMuted ) => {
93- if ( shouldPlay && audioRef . current ) {
94- // Stop all other audio first
95- stopAllAudio ( ) ;
96- audioRef . current . currentTime = 0 ;
97- audioRef . current . play ( ) . catch ( ( error ) => {
98- console . log ( "Audio playback failed:" , error ) ;
99- } ) ;
187+ // Play audio with aggressive seamless looping
188+ const playAudio = ( audioKey , shouldPlay = ! isMuted ) => {
189+ if (
190+ ! shouldPlay ||
191+ ! isInitializedRef . current ||
192+ ! audioBuffersRef . current [ audioKey ]
193+ )
194+ return ;
195+
196+ // Stop all other audio first
197+ stopAllAudio ( ) ;
198+
199+ const buffer = audioBuffersRef . current [ audioKey ] ;
200+
201+ // Handle fallback audio
202+ if ( buffer . isFallback ) {
203+ buffer . audio . currentTime = 0 ;
204+ buffer . audio . play ( ) . catch ( console . error ) ;
205+ return ;
206+ }
207+
208+ // Resume audio context if suspended
209+ if ( audioContextRef . current . state === "suspended" ) {
210+ audioContextRef . current . resume ( ) ;
100211 }
212+
213+ // Create and start buffer source with perfect looping
214+ const startSeamlessLoop = ( ) => {
215+ if ( audioSourcesRef . current [ audioKey ] ) {
216+ audioSourcesRef . current [ audioKey ] . stop ( ) ;
217+ }
218+
219+ const source = audioContextRef . current . createBufferSource ( ) ;
220+ source . buffer = buffer ;
221+ source . loop = true ;
222+ source . loopStart = 0 ;
223+ source . loopEnd = buffer . duration ;
224+
225+ // Connect to gain node
226+ source . connect ( gainNodesRef . current [ audioKey ] ) ;
227+
228+ // Start immediately
229+ source . start ( 0 ) ;
230+ audioSourcesRef . current [ audioKey ] = source ;
231+
232+ console . log ( `Started seamless loop for ${ audioKey } ` ) ;
233+ } ;
234+
235+ startSeamlessLoop ( ) ;
101236 } ;
102237
103238 const stopAllAudio = ( ) => {
104- [
105- playingAudioRef . current ,
106- breakAudioRef . current ,
107- overtimeAudioRef . current ,
108- ] . forEach ( ( audio ) => {
109- if ( audio ) {
110- audio . pause ( ) ;
111- audio . currentTime = 0 ;
239+ Object . keys ( audioSourcesRef . current ) . forEach ( ( key ) => {
240+ const source = audioSourcesRef . current [ key ] ;
241+ if ( source ) {
242+ try {
243+ source . stop ( ) ;
244+ source . disconnect ( ) ;
245+ } catch ( error ) {
246+ // Source might already be stopped
247+ }
248+ delete audioSourcesRef . current [ key ] ;
249+ }
250+ } ) ;
251+
252+ // Stop fallback audio
253+ Object . keys ( audioBuffersRef . current ) . forEach ( ( key ) => {
254+ const buffer = audioBuffersRef . current [ key ] ;
255+ if ( buffer && buffer . isFallback ) {
256+ buffer . audio . pause ( ) ;
257+ buffer . audio . currentTime = 0 ;
112258 }
113259 } ) ;
114260 } ;
@@ -118,22 +264,46 @@ export function TimerModal({
118264 setIsMuted ( newMutedState ) ;
119265
120266 if ( newMutedState ) {
121- // Muting - stop all audio
122267 stopAllAudio ( ) ;
123268 } else {
124- // Unmuting - resume appropriate audio based on current state
269+ // Resume appropriate audio
125270 if ( isRunning ) {
126271 if ( timeLeft === 0 && isOvertimeStarted ) {
127- playAudio ( overtimeAudioRef ) ;
272+ playAudio ( "overtime" ) ;
128273 } else if ( isBreak ) {
129- playAudio ( breakAudioRef ) ;
274+ playAudio ( "break" ) ;
130275 } else if ( timeLeft > 0 ) {
131- playAudio ( playingAudioRef ) ;
276+ playAudio ( "playing" ) ;
132277 }
133278 }
134279 }
135280 } ;
136281
282+ // Handle user interaction requirement for audio
283+ const handleFirstUserInteraction = ( ) => {
284+ if (
285+ audioContextRef . current &&
286+ audioContextRef . current . state === "suspended"
287+ ) {
288+ audioContextRef . current . resume ( ) ;
289+ }
290+ } ;
291+
292+ // Add click listener for first user interaction
293+ useEffect ( ( ) => {
294+ document . addEventListener ( "click" , handleFirstUserInteraction , {
295+ once : true ,
296+ } ) ;
297+ document . addEventListener ( "touchstart" , handleFirstUserInteraction , {
298+ once : true ,
299+ } ) ;
300+
301+ return ( ) => {
302+ document . removeEventListener ( "click" , handleFirstUserInteraction ) ;
303+ document . removeEventListener ( "touchstart" , handleFirstUserInteraction ) ;
304+ } ;
305+ } , [ ] ) ;
306+
137307 // Main countdown timer effect
138308 useEffect ( ( ) => {
139309 if ( isRunning && timeLeft > 0 ) {
@@ -147,10 +317,9 @@ export function TimerModal({
147317 // Overtime counter effect
148318 useEffect ( ( ) => {
149319 if ( isRunning && timeLeft === 0 ) {
150- // Start overtime audio when entering overtime mode
151320 if ( ! isOvertimeStarted ) {
152321 setIsOvertimeStarted ( true ) ;
153- playAudio ( overtimeAudioRef ) ; // Uses default mute check
322+ playAudio ( "overtime" ) ;
154323 }
155324
156325 const overtimeInterval = setInterval ( ( ) => {
@@ -193,19 +362,18 @@ export function TimerModal({
193362 useEffect ( ( ) => {
194363 if ( isRunning && ! isMuted ) {
195364 if ( timeLeft === 0 ) {
196- playAudio ( overtimeAudioRef ) ;
365+ playAudio ( "overtime" ) ;
197366 } else if ( isBreak ) {
198- playAudio ( breakAudioRef ) ;
367+ playAudio ( "break" ) ;
199368 } else {
200- playAudio ( playingAudioRef ) ;
369+ playAudio ( "playing" ) ;
201370 }
202371 } else {
203372 stopAllAudio ( ) ;
204373 if ( ! isRunning ) {
205374 setIsOvertimeStarted ( false ) ;
206375 }
207376 }
208- // The dependency `timeLeft > 0` is a boolean that only changes when the timer hits zero.
209377 } , [ isRunning , isBreak , timeLeft > 0 , isMuted ] ) ;
210378
211379 const formatTime = ( seconds ) => {
0 commit comments