Skip to content

Commit ae2e53a

Browse files
committed
feat(playback): fix concurrency
Even though we handled the connection/disconnection of nodes properly, the AudioContext marks an HTMLMediaElement as already attached to a node, so our best bet is to instantiate/dispose the AudioContext on demand (removing the console warning that appeared when booting the client along the way) Also create a PromiseQueue class for enqueuing promises easily Signed-off-by: Fernando Fernández <[email protected]>
1 parent 58404e1 commit ae2e53a

File tree

6 files changed

+76
-55
lines changed

6 files changed

+76
-55
lines changed

frontend/src/App.vue

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,12 +12,13 @@
1212
</VApp>
1313
<Snackbar />
1414
<ConfirmDialog />
15-
<PlayerElement />
15+
<PlayerElement v-if="remote.auth.currentUser.value" />
1616
</JApp>
1717
</template>
1818

1919
<script setup lang="ts">
2020
import { computed } from 'vue';
21+
import { remote } from './plugins/remote';
2122
import { themeSettings } from '#/store/settings/theme';
2223
import { useLoading } from '#/composables/use-loading';
2324

frontend/src/components/Playback/MusicVisualizer.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,12 @@ function destroy(): void {
2020
}
2121
}
2222
23-
watch(visualizerElement, () => {
23+
watch([visualizerElement, mediaWebAudio.sourceNode], () => {
2424
destroy();
2525
26-
if (visualizerElement.value) {
26+
if (visualizerElement.value && mediaWebAudio.sourceNode.value) {
2727
visualizerInstance = new AudioMotionAnalyzer(visualizerElement.value, {
28-
source: mediaWebAudio.sourceNode,
28+
source: mediaWebAudio.sourceNode.value,
2929
connectSpeakers: false,
3030
mode: 2,
3131
gradient: 'prism',

frontend/src/components/Playback/PlayerElement.vue

Lines changed: 30 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -7,16 +7,16 @@
77
<div class="uno-relative">
88
<Component
99
:is="mediaElementType"
10-
v-show="mediaElementType === 'video' && videoContainerRef"
10+
v-show="playbackManager.isVideo.value && videoContainerRef"
1111
ref="mediaElementRef"
1212
:poster="String(posterUrl)"
1313
autoplay
1414
crossorigin
1515
playsinline
1616
:loop="playbackManager.isRepeatingOnce.value"
17-
class="uno-h-full uno-max-h-100vh"
1817
:class="{
1918
'uno-object-fill uno-w-screen': playerElement.state.value.isStretched,
19+
'uno-h-full uno-max-h-100vh': playbackManager.isVideo.value
2020
}"
2121
@loadeddata="onLoadedData">
2222
<track
@@ -37,9 +37,10 @@
3737
<script setup lang="ts">
3838
import Hls, { ErrorTypes, Events, type ErrorData } from 'hls.js';
3939
import HlsWorkerUrl from 'hls.js/dist/hls.worker.js?url';
40-
import { computed, nextTick, watch } from 'vue';
40+
import { computed, nextTick, onScopeDispose, watch } from 'vue';
4141
import { useTranslation } from 'i18next-vue';
4242
import { isNil } from '@jellyfin-vue/shared/validation';
43+
import { PromiseQueue } from '@jellyfin-vue/shared/promises';
4344
import { useSnackbar } from '#/composables/use-snackbar';
4445
import {
4546
mediaElementRef,
@@ -51,7 +52,7 @@ import { getImageInfo } from '#/utils/images';
5152
import { subtitleSettings } from '#/store/settings/subtitle';
5253
5354
const { t } = useTranslation();
54-
let busyWebAudio = false;
55+
const webAudioQueue = new PromiseQueue();
5556
const hls = Hls.isSupported()
5657
? new Hls({
5758
testBandwidth: false,
@@ -90,55 +91,29 @@ function detachHls(): void {
9091
* Suspends WebAudio when no playback is in place
9192
*/
9293
async function detachWebAudio(): Promise<void> {
93-
if (mediaWebAudio.context.state === 'running' && !busyWebAudio) {
94-
busyWebAudio = true;
95-
96-
try {
97-
if (mediaWebAudio.gainNode) {
98-
mediaWebAudio.gainNode.gain.setValueAtTime(mediaWebAudio.gainNode.gain.value, mediaWebAudio.context.currentTime);
99-
mediaWebAudio.gainNode.gain.exponentialRampToValueAtTime(0.0001, mediaWebAudio.context.currentTime + 1.5);
100-
await nextTick();
101-
await new Promise(resolve => globalThis.setTimeout(resolve));
102-
mediaWebAudio.gainNode.disconnect();
103-
mediaWebAudio.gainNode = undefined;
104-
}
105-
106-
if (mediaWebAudio.sourceNode) {
107-
mediaWebAudio.sourceNode.disconnect();
108-
mediaWebAudio.sourceNode = undefined;
109-
}
94+
const { context, sourceNode } = mediaWebAudio;
11095
111-
await mediaWebAudio.context.suspend();
112-
} catch {} finally {
113-
busyWebAudio = false;
96+
if (context.value) {
97+
if (sourceNode.value) {
98+
sourceNode.value.disconnect();
99+
sourceNode.value = undefined;
114100
}
101+
102+
await context.value.close();
103+
context.value = undefined;
115104
}
116105
}
117106
118107
/**
119108
* Resumes WebAudio when playback is in place
120109
*/
121110
async function attachWebAudio(el: HTMLMediaElement): Promise<void> {
122-
if (mediaWebAudio.context.state === 'suspended' && !busyWebAudio) {
123-
busyWebAudio = true;
111+
const { context, sourceNode } = mediaWebAudio;
124112
125-
try {
126-
await mediaWebAudio.context.resume();
127-
128-
mediaWebAudio.sourceNode = mediaWebAudio.context.createMediaElementSource(el);
129-
mediaWebAudio.sourceNode.connect(mediaWebAudio.context.destination);
130-
131-
/**
132-
* The gain node is to avoid cracks when stopping playback or switching really fast between tracks
133-
*/
134-
mediaWebAudio.gainNode = mediaWebAudio.context.createGain();
135-
mediaWebAudio.gainNode.connect(mediaWebAudio.context.destination);
136-
mediaWebAudio.gainNode.gain.setValueAtTime(mediaWebAudio.gainNode.gain.value, mediaWebAudio.context.currentTime);
137-
mediaWebAudio.gainNode.gain.exponentialRampToValueAtTime(1, mediaWebAudio.context.currentTime + 1.5);
138-
} catch {} finally {
139-
busyWebAudio = false;
140-
}
141-
}
113+
context.value = new AudioContext();
114+
sourceNode.value = context.value.createMediaElementSource(el);
115+
await context.value.resume();
116+
sourceNode.value.connect(context.value.destination);
142117
}
143118
144119
/**
@@ -188,17 +163,19 @@ function onHlsEror(_event: typeof Hls.Events.ERROR, data: ErrorData): void {
188163
}
189164
}
190165
191-
watch(mediaElementRef, async () => {
166+
watch(mediaElementRef, () => {
192167
detachHls();
193-
await detachWebAudio();
168+
void webAudioQueue.add(() => detachWebAudio());
194169
195170
if (mediaElementRef.value) {
196-
if (mediaElementType.value === 'video' && hls) {
171+
if (playbackManager.isVideo.value && hls) {
197172
hls.attachMedia(mediaElementRef.value);
198173
hls.on(Events.ERROR, onHlsEror);
199174
}
200175
201-
await attachWebAudio(mediaElementRef.value);
176+
if (playbackManager.isAudio.value) {
177+
void webAudioQueue.add(() => attachWebAudio(mediaElementRef.value!));
178+
}
202179
}
203180
});
204181
@@ -238,4 +215,10 @@ watch(playbackManager.currentSourceUrl,
238215
}
239216
}
240217
);
218+
219+
onScopeDispose(() => {
220+
detachHls();
221+
hls?.destroy();
222+
void detachWebAudio();
223+
});
241224
</script>

frontend/src/plugins/remote/auth.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ class RemotePluginAuth extends BaseState<AuthState> {
4343
public readonly servers = computed(() => this._state.value.servers);
4444
public readonly currentServer = computed(() => this._state.value.servers[this._state.value.currentServerIndex]);
4545
public readonly currentUser = computed(() => this._state.value.users[this._state.value.currentUserIndex]);
46-
public readonly currentUserId = computed(() => this.currentUser.value.Id);
46+
public readonly currentUserId = computed(() => this.currentUser.value?.Id);
4747
public readonly currentUserToken = computed(() => this._getUserAccessToken(this.currentUser.value));
4848
public readonly addedServers = computed(() => this._state.value.servers.length);
4949

frontend/src/store/index.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -50,9 +50,8 @@ export const mediaControls = useMediaControls(mediaElementRef);
5050
* WebAudio instance of the local media player
5151
*/
5252
export const mediaWebAudio = {
53-
context: new AudioContext(),
54-
sourceNode: undefined as undefined | MediaElementAudioSourceNode,
55-
gainNode: undefined as undefined | GainNode
53+
context: shallowRef<AudioContext>(),
54+
sourceNode: shallowRef<MediaElementAudioSourceNode>()
5655
};
5756

5857
/**
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export class PromiseQueue {
2+
private concurrency: number;
3+
private queue: (() => void)[] = [];
4+
private activeCount = 0;
5+
6+
public constructor(concurrency = 1) {
7+
this.concurrency = concurrency;
8+
}
9+
10+
public add<T>(task: () => Promise<T>): Promise<T> {
11+
return new Promise<T>((resolve, reject) => {
12+
const run = () => {
13+
this.activeCount++;
14+
/* eslint-disable promise/prefer-await-to-then, promise/catch-or-return */
15+
task()
16+
.then(resolve)
17+
.catch(reject)
18+
.finally(() => {
19+
this.activeCount--;
20+
21+
// Dispara la siguiente tarea en la cola, si existe
22+
const next = this.queue.shift();
23+
24+
if (next) {
25+
next();
26+
}
27+
});
28+
/* eslint-enable promise/prefer-await-to-then, promise/catch-or-return */
29+
};
30+
31+
if (this.activeCount < this.concurrency) {
32+
run();
33+
} else {
34+
this.queue.push(run);
35+
}
36+
});
37+
}
38+
}

0 commit comments

Comments
 (0)