@@ -34,15 +34,29 @@ export default class EqualizerPlugin extends DlfMediaPlugin {
3434 } ;
3535
3636 /** @private */
37- this . context = new AudioContext ( ) ;
37+ /** @private @type {AudioContext | null } */
38+ // Don't create AudioContext eagerly: some browsers block creation before
39+ // a user gesture (autoplay policies). Create / resume it on demand in resumeAudioContext().
40+ this . context = null ;
3841
39- /** @private */
40- this . markAsResumed = ( /** @type {any } */ _value ) => { } ;
42+ /** @private @type {(value?: any) => void } */
43+ this . markAsResumed = ( /* value */ ) => { } ;
44+
45+ /** @private @type {HTMLElement | null } */
46+ this . resumeHintEl_ = null ;
4147
4248 /** @private */
4349 this . resumedPromise = new Promise ( ( resolve ) => { this . markAsResumed = resolve ; } ) ;
4450 }
4551
52+ /** @private */
53+ removeResumeHint_ ( ) {
54+ if ( this . resumeHintEl_ && this . resumeHintEl_ . parentNode ) {
55+ this . resumeHintEl_ . parentNode . removeChild ( this . resumeHintEl_ ) ;
56+ }
57+ this . resumeHintEl_ = null ;
58+ }
59+
4660 get view ( ) {
4761 return this . eqView_ ;
4862 }
@@ -56,23 +70,48 @@ export default class EqualizerPlugin extends DlfMediaPlugin {
5670 console . error ( "Warning: The equalizer will probably fail without HTTPS" ) ;
5771 }
5872
59- // Resume audio context
73+ // Resume audio context (ensures this.context is available)
6074 await this . resumeAudioContext ( ) ;
6175
76+ if ( this . context === null ) {
77+ // As a last resort, create one. This should not normally happen because
78+ // resumeAudioContext creates it, but this guard avoids null deref.
79+ const AC = ( typeof window . AudioContext !== 'undefined' )
80+ ? window . AudioContext
81+ : /** @type {any } */ ( globalThis ) . webkitAudioContext ;
82+ if ( typeof AC === 'undefined' ) {
83+ console . error ( 'AudioContext is not supported in this environment' ) ;
84+ return ;
85+ }
86+ this . context = new AC ( ) ;
87+ }
88+
89+ if ( this . context === null ) {
90+ console . error ( 'AudioContext creation failed' ) ;
91+ return ;
92+ }
93+ /** @type {AudioContext } */
94+ const ctx = this . context ;
95+
6296 // Load MultiIirProcessor
6397 const blob = new Blob ( [ `
6498 ${ registerMultiIirProcessor . toString ( ) }
6599 ${ registerMultiIirProcessor . name } ();
66100 ` ] , { type : 'application/javascript; charset=utf-8' } ) ;
67101 // TODO: Object URLs didn't work in Chrome?
68102 const dataUrl = await blobToDataURL ( blob ) ;
69- await this . context . audioWorklet . addModule ( dataUrl ) ;
103+ try {
104+ await ctx . audioWorklet . addModule ( dataUrl ) ;
105+ } catch ( err ) {
106+ console . error ( 'Failed to load equalizer audio worklet:' , err ) ;
107+ // Proceed without the worklet — equalizer may still function with fallback code paths.
108+ }
70109
71110 // Connect equalizer
72111 player . media . crossOrigin = 'anonymous' ;
73- const source = this . context . createMediaElementSource ( player . media ) ;
112+ const source = ctx . createMediaElementSource ( player . media ) ;
74113 const eq = new Equalizer ( source ) ;
75- eq . connect ( this . context . destination ) ;
114+ eq . connect ( ctx . destination ) ;
76115
77116 // Setup view
78117 this . eqView_ = new EqualizerView ( this . env , eq ) ;
@@ -132,24 +171,66 @@ export default class EqualizerPlugin extends DlfMediaPlugin {
132171 * @private
133172 */
134173 async resumeAudioContext ( ) {
135- if ( this . context . state === 'running' ) {
174+ // Do NOT create or resume the AudioContext here synchronously.
175+ // Instead show a resume UI and create/resume the context inside the user gesture handlers (pointerdown/keydown).
176+ // This avoids browsers blocking the action because it's not triggered by a user gesture.
177+
178+ // If we already have a context and it's running, immediately resolve.
179+ if ( this . context !== null && this . context . state === 'running' ) {
136180 this . markAsResumed ( ) ;
137- } else {
138- this . append ( e ( 'div' , { className : "dlf-equalizer-resume" } , [
181+ await this . resumedPromise ;
182+ return ;
183+ }
184+
185+ // Show resume hint once - accessible button
186+ if ( ! this . resumeHintEl_ ) {
187+ const btn = e ( 'button' , { className : 'dlf-equalizer-resume' , type : 'button' , ariaLabel : this . env . t ( 'control.sound_tools.equalizer.resume_context' ) } , [
139188 this . env . t ( 'control.sound_tools.equalizer.resume_context' ) ,
140- ] ) ) ;
141- this . context . resume ( ) . then ( ( ) => {
142- this . markAsResumed ( ) ;
143- } ) ;
189+ ] ) ;
190+ btn . addEventListener ( 'click' , ( ) => createAndResume ( ) ) ;
191+ this . resumeHintEl_ = btn ;
192+ this . append ( btn ) ;
144193 }
145- window . addEventListener ( 'pointerdown' , async ( ) => {
146- await this . context . resume ( ) ;
194+
195+ const createAndResume = async ( ) => {
196+ // Create AudioContext lazily if missing.
197+ if ( this . context === null ) {
198+ const AC = ( typeof window . AudioContext !== 'undefined' )
199+ ? window . AudioContext
200+ : /** @type {any } */ ( globalThis ) . webkitAudioContext ;
201+ if ( typeof AC === 'undefined' ) {
202+ // Audio not supported -> resolve anyway
203+ this . markAsResumed ( ) ;
204+ return ;
205+ }
206+ this . context = new AC ( ) ;
207+ }
208+
209+ // Narrow to local non-null variable for the typechecker.
210+ const ctx = this . context ;
211+ if ( ctx ) {
212+ try {
213+ // Resume the context (allowed because we're inside a user gesture).
214+ await ctx . resume ( ) ;
215+ } catch ( e ) {
216+ // ignore
217+ }
218+ }
219+ // remove resume hint if present
220+ this . removeResumeHint_ ( ) ;
147221 this . markAsResumed ( ) ;
148- } , { once : true , capture : true } ) ;
149- window . addEventListener ( 'keydown' , async ( ) => {
150- await this . context . resume ( ) ;
222+ } ;
223+
224+ // Attach once-only handlers that will create/resume the context on first user interaction.
225+ window . addEventListener ( 'pointerdown' , createAndResume , { once : true , capture : true } ) ;
226+ window . addEventListener ( 'keydown' , createAndResume , { once : true , capture : true } ) ;
227+
228+ // Also attempt to resume if the browser reports a running context later (edge case), but don't create it here.
229+ if ( this . context !== null && this . context . state === 'running' ) {
230+ this . removeResumeHint_ ( ) ;
151231 this . markAsResumed ( ) ;
152- } , { once : true , capture : true } ) ;
232+ }
233+
153234 await this . resumedPromise ;
154235 }
155236
0 commit comments