mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-13 01:18:38 +00:00
wip: use videojs for audio & rendition
This commit is contained in:
+74
-15
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user