use videojs for audio & rendition

This commit is contained in:
2026-07-12 21:20:39 +02:00
parent 453f86a448
commit 1c7f922996
7 changed files with 289 additions and 207 deletions
+56 -14
View File
@@ -1,15 +1,19 @@
import {
type Selector,
selectAudioTrack,
selectBuffer,
selectError,
selectPlayback,
selectPlaybackRate,
selectQuality,
selectTextTrack,
selectTime,
selectVolume,
usePlayer,
usePlayer as useStoreSelector,
} from "@videojs/react";
import { useEffect, useRef } from "react";
import type { WebOmniPlayer } from "./player.web";
import { usePlayer } from "./provider.web";
import type { OmniEvents } from "./types/events";
import type { OmniPlayerState } from "./types/player";
@@ -63,6 +67,29 @@ const eventMapper: EventMapperConfig = {
}
cb(undefined);
}),
...createEventMapper("audioTrackChange", selectAudioTrack, (cb, value) => {
if (!value) return;
const track = value.audioTrackList.find((t) => t.enabled);
if (!track) return;
cb({
id: track.id!,
label: track.label,
language: track.language,
selected: true,
});
}),
...createEventMapper("renditionChange", selectQuality, (cb, value) => {
const active = value?.activeVideoRendition;
if (!active) return;
cb({
id: active.id!,
width: active.width ?? 0,
height: active.height ?? 0,
bitrate: active.bitrate ?? 0,
selected: true,
});
}),
};
export const useEvent = <Event extends keyof OmniEvents>(
@@ -73,15 +100,33 @@ export const useEvent = <Event extends keyof OmniEvents>(
const callbackRef = useRef(callback);
callbackRef.current = callback;
const prevRef = useRef<any>(undefined);
const value = usePlayer(config?.selector ?? (() => ({})));
const value = useStoreSelector(config?.selector ?? (() => undefined));
const player = usePlayer() as WebOmniPlayer;
useEffect(() => {
if (!config) return;
const prev = prevRef.current;
config.handler(callbackRef.current as any, value, prev);
config.handler(callbackRef.current as any, value, prevRef.current);
prevRef.current = value;
}, [value, config]);
// prev/next are triggered by the app (or the media session) rather than the
// media element
useEffect(() => {
if (event === "prev") {
const cb = callbackRef.current as () => void;
player.onPrev.add(cb);
return () => {
player.onPrev.delete(cb);
};
}
if (event === "next") {
const cb = callbackRef.current as () => void;
player.onNext.add(cb);
return () => {
player.onNext.delete(cb);
};
}
return undefined;
}, [event, player]);
};
function createMapper<Key extends keyof OmniPlayerState, State, Result>(
@@ -130,13 +175,10 @@ export const stateMapper = {
...createMapper("muted", selectVolume, (s) => {
return s?.muted ?? false;
}),
...createMapper(
"isAutoQuality",
() => {},
() => {
return true;
},
),
...createMapper("isAutoQuality", selectQuality, (q) => {
if (!q) return true;
return !q.videoRenditionList.some((r) => r.selected);
}),
};
export function usePlayerState<Key extends keyof OmniPlayerState>(
@@ -152,6 +194,6 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
): OmniPlayerState[Key] {
const config = stateMapper[key];
if (!config) throw new Error(`No mapper for ${key}`);
const ret = usePlayer(config.selector as Selector<any, any>);
const ret = useStoreSelector(config.selector as Selector<any, any>);
return config.mapper(ret) as OmniPlayerState[Key];
}
+127 -43
View File
@@ -1,5 +1,6 @@
import type { MediaVideoRendition } from "@videojs/core";
import type { VideoPlayerStore } from "@videojs/core/dom";
import type Hls from "hls.js";
import { selectAudioTrack, selectQuality } from "@videojs/react";
import { stateMapper } from "./events.web";
import type {
OmniPlayer,
@@ -11,27 +12,32 @@ import type { Source } from "./types/source";
export class WebOmniPlayer implements OmniPlayer {
_store: VideoPlayerStore;
onPrev?: () => void;
onNext?: () => void;
onPrev = new Set<() => void>();
onNext = new Set<() => void>();
constructor(store: VideoPlayerStore) {
this._store = store;
}
private getHls(): Hls | null {
const media = this._store.target?.media;
return (media?.engine as Hls | undefined) ?? null;
_source: Source | null = null;
private _showNotification = false;
get source(): Source | null {
return this._source;
}
source: Source | null = null;
set source(source: Source | null) {
this._source = source;
this.updateMediaSession();
}
get showNotification(): boolean {
// TODO
return false;
return this._showNotification;
}
set showNotification(_: boolean) {
// TODO
set showNotification(value: boolean) {
this._showNotification = value;
this.updateMediaSession();
}
get status(): PlayerStatus {
@@ -47,6 +53,10 @@ export class WebOmniPlayer implements OmniPlayer {
return this._store.state.currentTime;
}
set currentTime(time: number) {
this._store.seek(time).catch(() => {});
}
get buffered(): number {
const buffered = this._store.state.buffered;
if (buffered.length === 0) return 0;
@@ -84,17 +94,20 @@ export class WebOmniPlayer implements OmniPlayer {
}
get isAutoQuality(): boolean {
const hls = this.getHls();
if (!hls) return false;
return hls.autoLevelEnabled;
const quality = selectQuality(this._store.state);
if (!quality) return true;
// ABR ("auto") is on whenever no rendition is explicitly pinned.
return !quality.videoRenditionList.some((r) => r.selected);
}
play(): void {
this._store.play();
this.setPlaybackState("playing");
}
pause(): void {
this._store.pause();
this.setPlaybackState("paused");
}
seekBy(offset: number): void {
@@ -102,21 +115,19 @@ export class WebOmniPlayer implements OmniPlayer {
}
playPrev(): void {
this.onPrev?.();
for (const cb of this.onPrev) cb();
}
playNext(): void {
this.onNext?.();
for (const cb of this.onNext) cb();
}
get hasPrev(): boolean {
// TODO
return false;
return this._source?.metadata?.hasPrev ?? false;
}
get hasNext(): boolean {
// TODO
return false;
return this._source?.metadata?.hasNext ?? false;
}
get videos(): Track[] {
@@ -129,21 +140,20 @@ export class WebOmniPlayer implements OmniPlayer {
}
get audios(): Track[] {
const hls = this.getHls();
if (!hls) return [];
const currentTrackId = hls.audioTrack;
return hls.audioTracks.map((track, index) => ({
id: index.toString(),
label: track.name,
language: track.lang,
selected: index === currentTrackId,
const audio = selectAudioTrack(this._store.state);
if (!audio) return [];
return audio.audioTrackList.map((track, i) => ({
id: track.id ?? i.toString(),
label: track.label,
language: track.language,
selected: track.enabled,
}));
}
selectAudio(audio: Track): void {
const hls = this.getHls();
if (!hls) return;
hls.audioTrack = parseInt(audio.id, 10);
const tracks = selectAudioTrack(this._store.state);
if (!tracks) return;
tracks.selectAudioTrack(audio.id);
}
get subtitles(): Track[] {
@@ -168,21 +178,95 @@ export class WebOmniPlayer implements OmniPlayer {
}
get rendition(): Rendition[] {
const hls = this.getHls();
if (!hls) return [];
const currentLevel = hls.currentLevel;
return hls.levels.map((level, index) => ({
id: index.toString(),
width: level.width,
height: level.height,
bitrate: level.bitrate,
selected: index === currentLevel,
function isSameRendition(
a: MediaVideoRendition,
b: MediaVideoRendition | null,
): boolean {
return (
b != null &&
a.id === b.id &&
a.width === b.width &&
a.height === b.height &&
a.bitrate === b.bitrate
);
}
const quality = selectQuality(this._store.state);
if (!quality) return [];
const active = quality.activeVideoRendition;
return quality.videoRenditionList.map((rendition, i) => ({
id: rendition.id ?? i.toString(),
width: rendition.width ?? 0,
height: rendition.height ?? 0,
bitrate: rendition.bitrate ?? 0,
selected: rendition.selected || isSameRendition(rendition, active),
}));
}
selectRendition(rendition?: Rendition): void {
const hls = this.getHls();
if (!hls) return;
hls.nextLevel = rendition ? parseInt(rendition.id, 10) : -1;
const tracks = selectQuality(this._store.state);
if (!tracks) return;
tracks.selectVideoRendition(rendition ? rendition.id : "auto");
}
private setPlaybackState(state: MediaSessionPlaybackState): void {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
return;
}
if (this._showNotification) navigator.mediaSession.playbackState = state;
}
private updateMediaSession(): void {
if (typeof navigator === "undefined" || !("mediaSession" in navigator)) {
return;
}
const session = navigator.mediaSession;
const actions: MediaSessionAction[] = [
"play",
"pause",
"seekbackward",
"seekforward",
"seekto",
"previoustrack",
"nexttrack",
];
if (!this._showNotification) {
session.metadata = null;
for (const action of actions) {
try {
session.setActionHandler(action, null);
} catch {}
}
return;
}
const metadata = this._source?.metadata;
if (metadata && typeof MediaMetadata !== "undefined") {
session.metadata = new MediaMetadata({
title: metadata.title,
artist: metadata.artist ?? "",
album: metadata.album ?? "",
artwork: metadata.imageLink ? [{ src: metadata.imageLink }] : [],
});
}
const set = (
action: MediaSessionAction,
handler: MediaSessionActionHandler | null,
) => {
try {
session.setActionHandler(action, handler);
} catch {}
};
set("play", () => this.play());
set("pause", () => this.pause());
set("seekbackward", (d) => this.seekBy(-(d.seekOffset ?? 10)));
set("seekforward", (d) => this.seekBy(d.seekOffset ?? 10));
set("seekto", (d) => {
if (d.seekTime != null) this.currentTime = d.seekTime;
});
set("previoustrack", this.hasPrev ? () => this.playPrev() : null);
set("nexttrack", this.hasNext ? () => this.playNext() : null);
}
}
+2 -1
View File
@@ -42,7 +42,8 @@ const PlayerInitializer = ({
useEffect(() => {
player.source = source;
}, [source]);
if (source.startTime) store.seek(source.startTime).catch(() => {});
}, [source, store]);
useEffect(() => {
player.showNotification = showNotification;
+47 -10
View File
@@ -1,11 +1,27 @@
import { HlsVideo } from "@videojs/react/media/hls-video";
import type { HlsMediaConfig } from "@videojs/core/dom/media/hls-js";
import { HlsJsVideo } from "@videojs/react/media/hlsjs-video";
import { Video } from "@videojs/react/video";
import type { CSSProperties } from "react";
import { useRef } from "react";
import { type CSSProperties, useMemo, useRef } from "react";
import type { WebOmniPlayer } from "./player.web";
import { usePlayer, VideoPlayer } from "./provider.web";
import { usePlayer } from "./provider";
import { VideoPlayer } from "./provider.web";
import type { Subtitle } from "./types/source";
import type { OmniViewProps } from "./types/view";
const SubtitleTracks = ({ subtitles }: { subtitles: Subtitle[] }) => (
<>
{subtitles.map((subtitle) => (
<track
key={subtitle.id}
kind="subtitles"
src={subtitle.link}
srcLang={subtitle.language}
label={subtitle.label ?? subtitle.language ?? subtitle.id}
/>
))}
</>
);
export const OmniView = ({
style,
autoplay,
@@ -13,19 +29,40 @@ export const OmniView = ({
const player = usePlayer() as WebOmniPlayer;
const containerRef = useRef<HTMLDivElement>(null);
const uri = player.source?.src[0]?.uri;
const Tech = uri?.endsWith("m3u8") ? HlsVideo : Video;
const src = player.source?.src[0];
const Tech = src?.uri.endsWith("m3u8") ? HlsJsVideo : Video;
const config = useMemo<HlsMediaConfig | undefined>(() => {
const headers = src?.headers;
if (!headers || Object.keys(headers).length === 0) return undefined;
return {
hlsJs: {
xhrSetup: (xhr: XMLHttpRequest) => {
for (const [key, value] of Object.entries(headers)) {
xhr.setRequestHeader(key, value);
}
},
},
};
}, [src?.headers]);
return (
<VideoPlayer.Container ref={containerRef} style={style}>
{uri && (
{src && (
<Tech
src={uri}
src={src.uri}
config={config}
autoPlay={autoplay}
playsInline
crossOrigin="anonymous"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
style={{
width: "100%",
height: "100%",
objectFit: "contain",
}}
>
<SubtitleTracks subtitles={player.source?.subtitles ?? []} />
</Tech>
)}
</VideoPlayer.Container>
);