167
common/resources/client/player/providers/hls-provider.tsx
Executable file
167
common/resources/client/player/providers/hls-provider.tsx
Executable file
@@ -0,0 +1,167 @@
|
||||
import {useCallback, useContext, useEffect, useRef, useState} from 'react';
|
||||
import {PlayerStoreContext} from '@common/player/player-context';
|
||||
import {usePlayerStore} from '@common/player/hooks/use-player-store';
|
||||
import Hls, {LevelLoadedData} from 'hls.js';
|
||||
import {useHtmlMediaInternalState} from '@common/player/providers/html-media/use-html-media-internal-state';
|
||||
import {useHtmlMediaEvents} from '@common/player/providers/html-media/use-html-media-events';
|
||||
import {useHtmlMediaApi} from '@common/player/providers/html-media/use-html-media-api';
|
||||
import {HlsMediaItem} from '@common/player/media-item';
|
||||
import {AudioTrack} from '@common/player/state/player-state';
|
||||
|
||||
export default function HlsProvider() {
|
||||
const store = useContext(PlayerStoreContext);
|
||||
const cuedMedia = usePlayerStore(s => s.cuedMedia) as HlsMediaItem;
|
||||
|
||||
// html medial element state
|
||||
const videoRef = useRef<HTMLVideoElement>(null!);
|
||||
const htmlMediaState = useHtmlMediaInternalState(videoRef);
|
||||
const htmlMediaEvents = useHtmlMediaEvents(htmlMediaState);
|
||||
const htmlMediaApi = useHtmlMediaApi(htmlMediaState);
|
||||
|
||||
// need both so we can "loadSource" when hls is ready, while keeping other callbacks stable
|
||||
const hls = useRef<Hls | undefined>();
|
||||
const [hlsReady, setHlsReady] = useState(false);
|
||||
|
||||
const destroyHls = useCallback(() => {
|
||||
if (hls) {
|
||||
hls.current?.destroy();
|
||||
hls.current = undefined;
|
||||
setHlsReady(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const setupHls = useCallback(() => {
|
||||
if (!Hls.isSupported()) {
|
||||
store.getState().emit('error', {fatal: true});
|
||||
return;
|
||||
}
|
||||
|
||||
const hlsInstance = new Hls({
|
||||
startLevel: -1,
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.ERROR, (event: any, data: any) => {
|
||||
if (data.fatal) {
|
||||
switch (data.type) {
|
||||
case Hls.ErrorTypes.NETWORK_ERROR:
|
||||
hlsInstance.startLoad();
|
||||
break;
|
||||
case Hls.ErrorTypes.MEDIA_ERROR:
|
||||
hlsInstance.recoverMediaError();
|
||||
break;
|
||||
default:
|
||||
destroyHls();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
store.getState().emit('error', {sourceEvent: data, fatal: data.fatal});
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.MANIFEST_PARSED, () => {
|
||||
if (!hlsInstance.levels?.length) return;
|
||||
|
||||
store.getState().emit('playbackQualities', {
|
||||
qualities: ['auto', ...hlsInstance.levels.map(levelToPlaybackQuality)],
|
||||
});
|
||||
|
||||
store.getState().emit('playbackQualityChange', {quality: 'auto'});
|
||||
});
|
||||
|
||||
hlsInstance.on(Hls.Events.AUDIO_TRACK_SWITCHED, (eventType, data) => {
|
||||
const track = store.getState().audioTracks.find(t => t.id === data.id);
|
||||
if (track) {
|
||||
store.getState().emit('currentAudioTrackChange', {trackId: track.id});
|
||||
}
|
||||
});
|
||||
|
||||
hlsInstance.on(
|
||||
Hls.Events.LEVEL_LOADED,
|
||||
(eventType: string, data: LevelLoadedData) => {
|
||||
if (!store.getState().providerReady) {
|
||||
const {type, live, totalduration: duration} = data.details;
|
||||
const inferredStreamType = live
|
||||
? type === 'EVENT' && Number.isFinite(duration)
|
||||
? 'live:dvr'
|
||||
: 'live'
|
||||
: 'on-demand';
|
||||
store.getState().emit('streamTypeChange', {
|
||||
streamType:
|
||||
(store.getState().cuedMedia as HlsMediaItem)?.streamType ||
|
||||
inferredStreamType,
|
||||
});
|
||||
store.getState().emit('durationChange', {duration});
|
||||
|
||||
const audioTracks: AudioTrack[] = hlsInstance.audioTracks.map(
|
||||
track => ({
|
||||
id: track.id,
|
||||
label: track.name,
|
||||
language: track.lang || '',
|
||||
kind: 'main',
|
||||
})
|
||||
);
|
||||
store.getState().emit('audioTracks', {tracks: audioTracks});
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
hlsInstance.attachMedia(videoRef.current);
|
||||
|
||||
hls.current = hlsInstance;
|
||||
// trigger initial load source
|
||||
setHlsReady(true);
|
||||
}, [destroyHls, store]);
|
||||
|
||||
// setup and destroy hls on mount and unmount
|
||||
useEffect(() => {
|
||||
setupHls();
|
||||
return () => {
|
||||
destroyHls();
|
||||
};
|
||||
}, [setupHls, destroyHls]);
|
||||
|
||||
// load source via hls when media src changes and hls is ready
|
||||
useEffect(() => {
|
||||
if (
|
||||
hls.current &&
|
||||
cuedMedia?.src &&
|
||||
(hls.current as any).url !== cuedMedia?.src
|
||||
) {
|
||||
hls.current.loadSource(cuedMedia.src);
|
||||
}
|
||||
}, [cuedMedia?.src, hlsReady]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hlsReady) return;
|
||||
store.setState({
|
||||
providerApi: {
|
||||
...htmlMediaApi,
|
||||
setCurrentAudioTrack: (trackId: number) => {
|
||||
if (!hls.current) return;
|
||||
hls.current.audioTrack = trackId;
|
||||
},
|
||||
setPlaybackQuality: (quality: string) => {
|
||||
if (!hls.current) return;
|
||||
hls.current.currentLevel = hls.current.levels.findIndex(
|
||||
level => levelToPlaybackQuality(level) === quality
|
||||
);
|
||||
store.getState().emit('playbackQualityChange', {quality});
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [htmlMediaApi, store, hlsReady]);
|
||||
|
||||
return (
|
||||
<video
|
||||
className="h-full w-full"
|
||||
ref={videoRef}
|
||||
playsInline
|
||||
poster={cuedMedia?.poster}
|
||||
{...htmlMediaEvents}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const levelToPlaybackQuality = (level: any) => {
|
||||
return level === -1 ? 'auto' : `${level.height}p`;
|
||||
};
|
||||
Reference in New Issue
Block a user