Skip to content

Commit d692d7a

Browse files
authored
[BUGFIX] create/resume AudioContext only in response to user gestures to avoid autoplay blocks (kitodo#1709)
1 parent 1473ee6 commit d692d7a

File tree

3 files changed

+136
-49
lines changed

3 files changed

+136
-49
lines changed

Build/Webpack/DevServer/equalizer.html

Lines changed: 34 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -62,36 +62,42 @@ <h1>Equalizer Test</h1>
6262

6363
<script>
6464
const equalizer = document.querySelector('dlf-equalizer');
65-
equalizer.parsePresets([
66-
{
67-
key: "riaa",
68-
mode: "riaa",
69-
group: "riaa",
70-
label: "RIAA",
71-
params: {
72-
trebleCut: 75,
73-
baseBoost: 318,
74-
baseBoostRolloff: 3180,
65+
if (equalizer) {
66+
equalizer.parsePresets([
67+
{
68+
key: "riaa",
69+
mode: "riaa",
70+
group: "riaa",
71+
label: "RIAA",
72+
params: {
73+
trebleCut: 75,
74+
baseBoost: 318,
75+
baseBoostRolloff: 3180,
76+
},
7577
},
76-
},
77-
{
78-
key: "riaa-iec",
79-
mode: "riaa",
80-
group: "riaa",
81-
label: "Enhanced RIAA / IEC",
82-
params: {
83-
trebleCut: 75,
84-
baseBoost: 318,
85-
baseBoostRolloff: 3180,
86-
deepBaseRolloff: 7950,
78+
{
79+
key: "riaa-iec",
80+
mode: "riaa",
81+
group: "riaa",
82+
label: "Enhanced RIAA / IEC",
83+
params: {
84+
trebleCut: 75,
85+
baseBoost: 318,
86+
baseBoostRolloff: 3180,
87+
deepBaseRolloff: 7950,
88+
},
8789
},
88-
},
89-
]);
90-
equalizer.selectPreset("riaa");
91-
const gainOffsetSlider = document.getElementById('gain-offset-slider');
92-
window.setInterval(() => {
93-
equalizer.view.fftSnapshot(gainOffsetSlider.valueAsNumber);
94-
}, 500);
90+
]);
91+
equalizer.selectPreset("riaa");
92+
const gainOffsetSlider = document.getElementById('gain-offset-slider');
93+
window.setInterval(() => {
94+
if (equalizer.view) {
95+
equalizer.view.fftSnapshot(gainOffsetSlider.valueAsNumber);
96+
}
97+
}, 500);
98+
} else {
99+
console.warn('Equalizer component not found in the DOM.');
100+
}
95101
</script>
96102
</body>
97103
</html>

Resources/Private/JavaScript/SlubMediaPlayer/components/equalizer/EqualizerPlugin.js

Lines changed: 101 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Resources/Public/JavaScript/DlfMediaPlayer/DlfMediaPlayer.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)