wip: use videojs for audio & rendition

This commit is contained in:
2026-07-08 11:49:01 +02:00
parent 453f86a448
commit 8494e3d600
6 changed files with 380 additions and 214 deletions
+74 -15
View File
@@ -1,15 +1,20 @@
import type { MediaVideoRendition } from "@videojs/core";
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";
@@ -32,6 +37,9 @@ function createEventMapper<Key extends keyof OmniEvents, Result>(
return { [key]: { selector, handler } };
}
const renditionKey = (r: MediaVideoRendition | null | undefined): string =>
r ? `${r.id ?? ""}|${r.width}x${r.height}|${r.bitrate}` : "";
const eventMapper: EventMapperConfig = {
...createEventMapper("end", selectPlayback, (cb, value, prev) => {
if (value?.ended && prev && !prev.ended) cb();
@@ -63,25 +71,79 @@ const eventMapper: EventMapperConfig = {
}
cb(undefined);
}),
...createEventMapper("audioTrackChange", selectAudioTrack, (cb, value, prev) => {
if (!value) return;
const index = value.audioTrackList.findIndex((t) => t.enabled);
if (index === -1) return;
const track = value.audioTrackList[index]!;
const nextValue = track.id ?? String(index);
const prevIndex = prev?.audioTrackList.findIndex((t) => t.enabled) ?? -1;
const prevTrack = prevIndex >= 0 ? prev!.audioTrackList[prevIndex] : undefined;
const prevValue = prevTrack ? (prevTrack.id ?? String(prevIndex)) : undefined;
if (nextValue === prevValue) return;
cb({
id: nextValue,
label: track.label,
language: track.language,
selected: true,
});
}),
...createEventMapper("renditionChange", selectQuality, (cb, value, prev) => {
const active = value?.activeVideoRendition;
if (!active) return;
if (renditionKey(active) === renditionKey(prev?.activeVideoRendition)) return;
const index = value.videoRenditionList.findIndex(
(r) => renditionKey(r) === renditionKey(active),
);
cb({
id: active.id ?? String(index),
width: active.width ?? 0,
height: active.height ?? 0,
bitrate: active.bitrate ?? 0,
selected: true,
});
}),
};
export const useEvent = <Event extends keyof OmniEvents>(
event: Event,
callback: OmniEvents[Event],
) => {
const config = eventMapper[event];
const player = usePlayer() as WebOmniPlayer;
const callbackRef = useRef(callback);
callbackRef.current = callback;
// Events derived from the reactive store state.
const config = eventMapper[event];
const prevRef = useRef<any>(undefined);
const value = usePlayer(config?.selector ?? (() => ({})));
const value = useStoreSelector(config?.selector ?? (() => undefined));
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; wire the callback onto the player so `playPrev`/`playNext`
// can invoke it.
useEffect(() => {
if (event === "prev") {
player.onPrev = () => (callbackRef.current as () => void)();
return () => {
player.onPrev = undefined;
};
}
if (event === "next") {
player.onNext = () => (callbackRef.current as () => void)();
return () => {
player.onNext = undefined;
};
}
return undefined;
}, [event, player]);
};
function createMapper<Key extends keyof OmniPlayerState, State, Result>(
@@ -130,13 +192,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 +211,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];
}
+130 -40
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,
@@ -9,6 +10,21 @@ import type {
} from "./types/player";
import type { Source } from "./types/source";
/**
* The quality feature reports the actively-playing rendition (`active`)
* separately from the list. Match them so the UI can highlight which
* rendition is currently on screen, including while ABR ("auto") is on.
*/
const isSameRendition = (
a: MediaVideoRendition,
b: MediaVideoRendition | null,
): boolean =>
b != null &&
a.id === b.id &&
a.width === b.width &&
a.height === b.height &&
a.bitrate === b.bitrate;
export class WebOmniPlayer implements OmniPlayer {
_store: VideoPlayerStore;
onPrev?: () => void;
@@ -18,20 +34,25 @@ export class WebOmniPlayer implements OmniPlayer {
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 +68,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 +109,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 {
@@ -110,17 +138,16 @@ export class WebOmniPlayer implements OmniPlayer {
}
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[] {
// hls.js does not support alternative video tracks (e.g. camera angles)
// hls.js only ever exposes a single "main" video track; alternative video
// tracks (e.g. camera angles) are not supported.
return [];
}
@@ -129,21 +156,19 @@ 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) => ({
// The id doubles as the menu value passed back to `selectAudioTrack`.
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);
selectAudioTrack(this._store.state)?.selectAudioTrack(audio.id);
}
get subtitles(): Track[] {
@@ -168,21 +193,86 @@ 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,
const quality = selectQuality(this._store.state);
if (!quality) return [];
const active = quality.activeVideoRendition;
return quality.videoRenditionList.map((rendition, i) => ({
// The id doubles as the menu value passed back to `selectVideoRendition`.
id: rendition.id ?? i.toString(),
width: rendition.width ?? 0,
height: rendition.height ?? 0,
bitrate: rendition.bitrate ?? 0,
// Mark the rendition currently on screen so callers can highlight it
// even while ABR ("auto") is picking the level.
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;
// `"auto"` restores adaptive (ABR) selection.
selectQuality(this._store.state)?.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);
}
}
+45 -3
View File
@@ -1,6 +1,12 @@
import { createPlayer } from "@videojs/react";
import { createPlayer, selectSource } from "@videojs/react";
import { videoFeatures } from "@videojs/react/video";
import { createContext, type ReactNode, useContext, useEffect } from "react";
import {
createContext,
type ReactNode,
useContext,
useEffect,
useRef,
} from "react";
import { WebOmniPlayer } from "./player.web";
import type { OmniPlayer } from "./types/player";
import type { Source } from "./types/source";
@@ -9,6 +15,7 @@ import { useLazyRef } from "./utils/lazy-ref";
export const VideoPlayer = createPlayer({ features: videoFeatures });
const PlayerCtx = createContext<OmniPlayer>(null!);
const SourceCtx = createContext<Source | null>(null);
export const OmniProvider = ({
children,
@@ -48,7 +55,42 @@ const PlayerInitializer = ({
player.showNotification = showNotification;
}, [showNotification]);
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
// Apply `startTime` once the new source is ready to play. Seeking before the
// media can play is a no-op, so we wait for `canPlay`. We also wait for the
// media to reload (`canPlay` going false) after a source change so we don't
// seek the previous media on a stale `canPlay`.
const canPlay =
VideoPlayer.usePlayer((state) => selectSource(state)?.canPlay) ?? false;
const seekRef = useRef({
uri: undefined as string | undefined,
done: false,
reloaded: false,
});
useEffect(() => {
const uri = source.src[0]?.uri;
if (seekRef.current.uri !== uri) {
seekRef.current = { uri, done: false, reloaded: false };
}
}, [source]);
useEffect(() => {
const state = seekRef.current;
if (!canPlay) {
state.reloaded = true;
return;
}
if (!state.reloaded || state.done) return;
state.done = true;
if (source.startTime && source.startTime > 0) {
store.seek(source.startTime).catch(() => {});
}
}, [canPlay, source, store]);
return (
<PlayerCtx.Provider value={player}>
<SourceCtx.Provider value={source}>{children}</SourceCtx.Provider>
</PlayerCtx.Provider>
);
};
export const usePlayer = () => useContext(PlayerCtx);
export const useSource = () => useContext(SourceCtx);
+75 -17
View File
@@ -1,32 +1,90 @@
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 { WebOmniPlayer } from "./player.web";
import { usePlayer, VideoPlayer } from "./provider.web";
import { type CSSProperties, useMemo, useRef } from "react";
import { useSource, VideoPlayer } from "./provider.web";
import type { Subtitle, VideoSrc } from "./types/source";
import type { OmniViewProps } from "./types/view";
const isHls = (src: VideoSrc): boolean => {
if (src.mimeType) {
return /mpegurl/i.test(src.mimeType);
}
return /\.m3u8($|\?)/i.test(src.uri);
};
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,
}: OmniViewProps & { style: CSSProperties }) => {
const player = usePlayer() as WebOmniPlayer;
const source = useSource();
const containerRef = useRef<HTMLDivElement>(null);
const uri = player.source?.src[0]?.uri;
const Tech = uri?.endsWith("m3u8") ? HlsVideo : Video;
const src = source?.src[0];
const hls = src ? isHls(src) : false;
// Forward per-source request headers to hls.js xhr requests. Plain <video>
// requests cannot carry custom headers from the browser, so this only
// applies to the adaptive (hls.js) tech.
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]);
const mediaStyle: CSSProperties = {
width: "100%",
height: "100%",
objectFit: "contain",
};
return (
<VideoPlayer.Container ref={containerRef} style={style}>
{uri && (
<Tech
src={uri}
autoPlay={autoplay}
playsInline
crossOrigin="anonymous"
style={{ width: "100%", height: "100%", objectFit: "cover" }}
/>
)}
{src &&
(hls ? (
<HlsJsVideo
src={src.uri}
config={config}
autoPlay={autoplay}
playsInline
crossOrigin="anonymous"
style={mediaStyle}
>
<SubtitleTracks subtitles={source?.subtitles ?? []} />
</HlsJsVideo>
) : (
<Video
src={src.uri}
autoPlay={autoplay}
playsInline
crossOrigin="anonymous"
style={mediaStyle}
>
<SubtitleTracks subtitles={source?.subtitles ?? []} />
</Video>
))}
</VideoPlayer.Container>
);
};