mirror of
https://github.com/jellyfin/jellyfin-vue.git
synced 2024-11-27 00:00:23 +00:00
fix(PlayerElement): WebAudio cracks
Signed-off-by: Fernando Fernández <ferferga@hotmail.com>
This commit is contained in:
parent
a1f4e221d4
commit
68d127f9bc
@ -27,9 +27,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* This component should call detachHls and detachWebAudio when it's unmounted.
|
||||
* However, there's no onBeforeUnmount/onUnmounted lifecycle hook because in the current
|
||||
* App.vue setip, this component never unmounts.
|
||||
*
|
||||
* If at some point this component is unmounted, the lifecycle hook must be added.
|
||||
*/
|
||||
import Hls, { ErrorTypes, Events, type ErrorData } from 'hls.js';
|
||||
import HlsWorkerUrl from 'hls.js/dist/hls.worker.js?url';
|
||||
import { computed, nextTick, watch } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useSnackbar } from '@/composables/use-snackbar';
|
||||
import {
|
||||
@ -39,9 +46,10 @@ import {
|
||||
import { playbackManager } from '@/store/playback-manager';
|
||||
import { playerElement, videoContainerRef } from '@/store/player-element';
|
||||
import { getImageInfo } from '@/utils/images';
|
||||
import { isNil } from '@/utils/validation';
|
||||
import { isNil, promisifyTimeout } from '@/utils/validation';
|
||||
|
||||
const { t } = useI18n();
|
||||
let attachingWebAudio = false;
|
||||
|
||||
const hls = Hls.isSupported()
|
||||
? new Hls({
|
||||
@ -61,15 +69,57 @@ function detachHls(): void {
|
||||
}
|
||||
|
||||
/**
|
||||
* Suspends WebAudio when no playback is in place
|
||||
* Suspends WebAudio
|
||||
*/
|
||||
async function detachWebAudio(): Promise<void> {
|
||||
/**
|
||||
* We need this to avoid cracks when switching tracks really fast.
|
||||
* setValueAtTime and promisifyTimeout gives enough time for WebAudio to apply the gain, avoiding cracks
|
||||
*/
|
||||
if (mediaWebAudio.gainNode) {
|
||||
mediaWebAudio.gainNode.gain.value = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is needed so WebAudio has enough time to apply the gain.
|
||||
* nextTick is faster than this and doesn't ensure the event loop is not as busy, so it's not enough
|
||||
* for WebAudio to apply the gain.
|
||||
*/
|
||||
await promisifyTimeout();
|
||||
await promisifyTimeout(() => {
|
||||
if (mediaWebAudio.context.state === 'running') {
|
||||
void mediaWebAudio.context.suspend();
|
||||
}
|
||||
});
|
||||
|
||||
if (mediaWebAudio.sourceNode) {
|
||||
mediaWebAudio.sourceNode.disconnect();
|
||||
mediaWebAudio.sourceNode = undefined;
|
||||
}
|
||||
|
||||
await mediaWebAudio.context.suspend();
|
||||
if (mediaWebAudio.gainNode) {
|
||||
mediaWebAudio.gainNode.disconnect();
|
||||
mediaWebAudio.gainNode = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes and attaches WebAudio and all the nodes to the current element.
|
||||
*/
|
||||
async function attachWebAudio(): Promise<void> {
|
||||
await detachWebAudio();
|
||||
|
||||
if (mediaElementRef.value && !attachingWebAudio) {
|
||||
attachingWebAudio = true;
|
||||
await mediaWebAudio.context.resume();
|
||||
mediaWebAudio.sourceNode = mediaWebAudio.context.createMediaElementSource(
|
||||
mediaElementRef.value
|
||||
);
|
||||
mediaWebAudio.gainNode = mediaWebAudio.context.createGain();
|
||||
mediaWebAudio.sourceNode.connect(mediaWebAudio.context.destination);
|
||||
mediaWebAudio.sourceNode.connect(mediaWebAudio.gainNode);
|
||||
attachingWebAudio = false;
|
||||
}
|
||||
}
|
||||
|
||||
const mediaElementType = computed<'audio' | 'video' | undefined>(() => {
|
||||
@ -115,7 +165,7 @@ function onHlsEror(_event: typeof Hls.Events.ERROR, data: ErrorData): void {
|
||||
// Try to recover network error
|
||||
useSnackbar(t('networkError'), 'error');
|
||||
console.error('fatal network error encountered, try to recover');
|
||||
hls.startLoad();
|
||||
hls.startLoad(playbackManager.currentTime);
|
||||
break;
|
||||
}
|
||||
case ErrorTypes.MEDIA_ERROR: {
|
||||
@ -136,28 +186,18 @@ function onHlsEror(_event: typeof Hls.Events.ERROR, data: ErrorData): void {
|
||||
}
|
||||
}
|
||||
|
||||
watch(mediaElementRef, async () => {
|
||||
watch(mediaElementRef, () => {
|
||||
detachHls();
|
||||
await detachWebAudio();
|
||||
|
||||
if (mediaElementRef.value) {
|
||||
await nextTick();
|
||||
|
||||
if (mediaElementType.value === 'video' && hls) {
|
||||
hls.attachMedia(mediaElementRef.value);
|
||||
hls.on(Events.ERROR, onHlsEror);
|
||||
}
|
||||
|
||||
await mediaWebAudio.context.resume();
|
||||
mediaWebAudio.sourceNode = mediaWebAudio.context.createMediaElementSource(
|
||||
mediaElementRef.value
|
||||
);
|
||||
mediaWebAudio.sourceNode.connect(mediaWebAudio.context.destination);
|
||||
}
|
||||
/**
|
||||
* Needed so WebAudio is properly disposed
|
||||
*/
|
||||
}, { flush: 'sync' });
|
||||
|
||||
void attachWebAudio();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => playbackManager.currentSourceUrl,
|
||||
|
@ -300,7 +300,7 @@ function _sharedInternalLogic<T extends Record<K, (...args: any[]) => any>, K ex
|
||||
* If there's available data before component mount, we return the cached data rightaway (see below how
|
||||
* we skip the promise) to get the component mounted as soon as possible.
|
||||
* However, we queue a request to the server to update the data after the component is
|
||||
* mounted. setTimeout executes it when the event loop is clear, avoiding overwhelming the engine.
|
||||
* mounted.
|
||||
*/
|
||||
if (isCached.value) {
|
||||
void run({ isRefresh: true });
|
||||
|
@ -30,7 +30,8 @@ export const mediaControls = useMediaControls(mediaElementRef);
|
||||
*/
|
||||
export const mediaWebAudio = {
|
||||
context: new AudioContext(),
|
||||
sourceNode: undefined as undefined | MediaElementAudioSourceNode
|
||||
sourceNode: undefined as undefined | MediaElementAudioSourceNode,
|
||||
gainNode: undefined as undefined | GainNode
|
||||
};
|
||||
/**
|
||||
* Reactively tracks if the user wants animations (false) or not (true).
|
||||
|
@ -1,4 +1,5 @@
|
||||
import type { AxiosError } from 'axios';
|
||||
import { NOOP } from '@vue/shared';
|
||||
|
||||
/**
|
||||
* Validator to which enforces that a select component has at least one value selected
|
||||
@ -87,3 +88,15 @@ export function sealed(constructor: Function): void {
|
||||
Object.seal(constructor);
|
||||
Object.seal(constructor.prototype);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a promise from setTimeout
|
||||
*/
|
||||
export async function promisifyTimeout(fn = NOOP, timeout = 0): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
fn();
|
||||
resolve();
|
||||
}, timeout);
|
||||
});
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user