mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
Init videojs v10 implementation
This commit is contained in:
@@ -0,0 +1,179 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { usePlayer } from "./provider.web";
|
||||
import type { OmniEvents } from "./types/events";
|
||||
import type { OmniPlayerState, PlayerStatus } from "./types/player";
|
||||
|
||||
export const useEvent = <Event extends keyof OmniEvents>(
|
||||
event: Event,
|
||||
callback: OmniEvents[Event],
|
||||
) => {
|
||||
const player = usePlayer();
|
||||
const callbackRef = useRef(callback);
|
||||
callbackRef.current = callback;
|
||||
|
||||
useEffect(() => {
|
||||
const store = player.store;
|
||||
const media = store.state.media as any;
|
||||
|
||||
if (!media) return;
|
||||
|
||||
switch (event) {
|
||||
case "end": {
|
||||
const handler = () => (callbackRef.current as OmniEvents["end"])();
|
||||
media.addEventListener("ended", handler);
|
||||
return () => media.removeEventListener("ended", handler);
|
||||
}
|
||||
case "error": {
|
||||
const handler = () => {
|
||||
const error = store.state.error;
|
||||
(callbackRef.current as OmniEvents["error"])(
|
||||
error?.message ?? "Unknown error",
|
||||
error?.message ?? "Unknown error",
|
||||
);
|
||||
};
|
||||
media.addEventListener("error", handler);
|
||||
return () => media.removeEventListener("error", handler);
|
||||
}
|
||||
case "prev": {
|
||||
const handler = () => (callbackRef.current as OmniEvents["prev"])();
|
||||
store.state.onPrev = handler;
|
||||
return () => {
|
||||
if (store.state.onPrev === handler) store.state.onPrev = undefined;
|
||||
};
|
||||
}
|
||||
case "next": {
|
||||
const handler = () => (callbackRef.current as OmniEvents["next"])();
|
||||
store.state.onNext = handler;
|
||||
return () => {
|
||||
if (store.state.onNext === handler) store.state.onNext = undefined;
|
||||
};
|
||||
}
|
||||
case "videoTrackChange": {
|
||||
const handler = () => {
|
||||
if (!media.videoTracks?.length) return;
|
||||
for (let i = 0; i < media.videoTracks.length; i++) {
|
||||
const track = media.videoTracks[i];
|
||||
if (track?.enabled) {
|
||||
(callbackRef.current as OmniEvents["videoTrackChange"])({
|
||||
id: track.id || `video-${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
media.videoTracks?.addEventListener("change", handler);
|
||||
return () =>
|
||||
media.videoTracks?.removeEventListener("change", handler);
|
||||
}
|
||||
case "audioTrackChange": {
|
||||
const handler = () => {
|
||||
if (!media.audioTracks?.length) return;
|
||||
for (let i = 0; i < media.audioTracks.length; i++) {
|
||||
const track = media.audioTracks[i];
|
||||
if (track?.enabled) {
|
||||
(callbackRef.current as OmniEvents["audioTrackChange"])({
|
||||
id: track.id || `audio-${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
media.audioTracks?.addEventListener("change", handler);
|
||||
return () =>
|
||||
media.audioTracks?.removeEventListener("change", handler);
|
||||
}
|
||||
case "subtitleChange": {
|
||||
const handler = () => {
|
||||
if (!media.textTracks?.length) return;
|
||||
for (let i = 0; i < media.textTracks.length; i++) {
|
||||
const track = media.textTracks[i];
|
||||
if (track?.mode === "showing") {
|
||||
(callbackRef.current as OmniEvents["subtitleChange"])({
|
||||
id: track.id || `text-${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
(callbackRef.current as OmniEvents["subtitleChange"])(undefined);
|
||||
};
|
||||
media.textTracks?.addEventListener("cuechange", handler);
|
||||
return () =>
|
||||
media.textTracks?.removeEventListener("cuechange", handler);
|
||||
}
|
||||
case "audioFocusChange":
|
||||
case "renditionChange":
|
||||
return;
|
||||
}
|
||||
}, [player, event]);
|
||||
};
|
||||
|
||||
export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
key: Key,
|
||||
): OmniPlayerState[Key];
|
||||
export function usePlayerState(
|
||||
key: "currentTime",
|
||||
refresh?: number,
|
||||
): OmniPlayerState["currentTime"];
|
||||
export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||
key: Key,
|
||||
refresh?: number,
|
||||
): OmniPlayerState[Key] {
|
||||
const player = usePlayer();
|
||||
const store = player.store;
|
||||
const [ret, setState] = useState<any>(player[key]);
|
||||
|
||||
useEffect(() => {
|
||||
switch (key) {
|
||||
case "currentTime":
|
||||
case "buffered":
|
||||
case "duration":
|
||||
case "playbackRate":
|
||||
case "volume": {
|
||||
setState(store.state[key]);
|
||||
const handler = () => setState(store.state[key]);
|
||||
const unsub = store.subscribe(handler);
|
||||
return unsub;
|
||||
}
|
||||
case "isPlaying":
|
||||
case "muted":
|
||||
case "isAutoQuality": {
|
||||
setState(player[key]);
|
||||
const handler = () => setState(player[key]);
|
||||
const unsub = store.subscribe(handler);
|
||||
return unsub;
|
||||
}
|
||||
case "status": {
|
||||
const prevRef = { current: player.status as PlayerStatus };
|
||||
setState(prevRef.current);
|
||||
const int = setInterval(() => {
|
||||
const status = player.status;
|
||||
if (status !== prevRef.current) {
|
||||
prevRef.current = status;
|
||||
setState(status);
|
||||
}
|
||||
}, 100);
|
||||
return () => clearInterval(int);
|
||||
}
|
||||
}
|
||||
}, [player, key, store]);
|
||||
|
||||
if (key === "currentTime") refresh ??= 1;
|
||||
useEffect(() => {
|
||||
if (!refresh || refresh <= 0) return;
|
||||
const int = setInterval(() => {
|
||||
setState(player[key]);
|
||||
}, refresh * 1000);
|
||||
return () => clearInterval(int);
|
||||
}, [refresh, key, player]);
|
||||
|
||||
return ret;
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { createPlayer } from "@videojs/react";
|
||||
import { videoFeatures } from "@videojs/react/video";
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import type { PlayerStatus, Rendition, Track } from "./types/player";
|
||||
import type { Source } from "./types/source";
|
||||
import { useLazyRef } from "./utils/lazy-ref";
|
||||
|
||||
export const VideoPlayer = createPlayer({ features: videoFeatures });
|
||||
|
||||
const PlayerCtx = createContext<WebOmniPlayer>(null!);
|
||||
|
||||
export const OmniProvider = ({
|
||||
children,
|
||||
source,
|
||||
showNotification = false,
|
||||
}: {
|
||||
source: Source;
|
||||
children: ReactNode;
|
||||
showNotification?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<VideoPlayer.Provider>
|
||||
<PlayerInitializer source={source} showNotification={showNotification}>
|
||||
{children}
|
||||
</PlayerInitializer>
|
||||
</VideoPlayer.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
const PlayerInitializer = ({
|
||||
children,
|
||||
source,
|
||||
showNotification,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
source: Source;
|
||||
showNotification: boolean;
|
||||
}) => {
|
||||
const store = VideoPlayer.usePlayer();
|
||||
const player = useLazyRef(() => new WebOmniPlayer(store, source));
|
||||
|
||||
useEffect(() => {
|
||||
player.updateSource(source);
|
||||
}, [source]);
|
||||
|
||||
useEffect(() => {
|
||||
player.showNotification = showNotification;
|
||||
}, [showNotification]);
|
||||
|
||||
return <PlayerCtx.Provider value={player}>{children}</PlayerCtx.Provider>;
|
||||
};
|
||||
|
||||
export const usePlayer = () => useContext(PlayerCtx);
|
||||
|
||||
class WebOmniPlayer {
|
||||
private _store: any;
|
||||
private _source: Source;
|
||||
private _showNotification = false;
|
||||
|
||||
constructor(store: any, source: Source) {
|
||||
this._store = store;
|
||||
this._source = source;
|
||||
}
|
||||
|
||||
get store() {
|
||||
return this._store;
|
||||
}
|
||||
|
||||
setMediaElement(media: HTMLVideoElement | null) {
|
||||
if (media) {
|
||||
this.setupSubtitleTracks(media);
|
||||
}
|
||||
}
|
||||
|
||||
updateSource(source: Source) {
|
||||
this._source = source;
|
||||
}
|
||||
|
||||
get source(): Source {
|
||||
return this._source;
|
||||
}
|
||||
|
||||
set source(source: Source) {
|
||||
this.updateSource(source);
|
||||
}
|
||||
|
||||
get showNotification(): boolean {
|
||||
return this._showNotification;
|
||||
}
|
||||
|
||||
set showNotification(value: boolean) {
|
||||
this._showNotification = value;
|
||||
}
|
||||
|
||||
get status(): PlayerStatus {
|
||||
const state = this._store.state;
|
||||
if (state.error) return "error";
|
||||
if (state.canPlay) return "readyToPlay";
|
||||
if (this._source.src.length > 0) return "loading";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
get isPlaying(): boolean {
|
||||
return !this._store.state.paused && !this._store.state.ended;
|
||||
}
|
||||
|
||||
get currentTime(): number {
|
||||
return this._store.state.currentTime;
|
||||
}
|
||||
|
||||
get buffered(): number {
|
||||
const buffered = this._store.state.buffered;
|
||||
if (!buffered || buffered.length === 0) return 0;
|
||||
const lastRange = buffered[buffered.length - 1];
|
||||
return lastRange ? lastRange[1] : 0;
|
||||
}
|
||||
|
||||
get duration(): number {
|
||||
return this._store.state.duration;
|
||||
}
|
||||
|
||||
get playbackRate(): number {
|
||||
return this._store.state.playbackRate;
|
||||
}
|
||||
|
||||
set playbackRate(rate: number) {
|
||||
this._store.setPlaybackRate(rate);
|
||||
}
|
||||
|
||||
get volume(): number {
|
||||
return this._store.state.volume;
|
||||
}
|
||||
|
||||
set volume(vol: number) {
|
||||
this._store.setVolume(vol);
|
||||
}
|
||||
|
||||
get muted(): boolean {
|
||||
return this._store.state.muted;
|
||||
}
|
||||
|
||||
set muted(value: boolean) {
|
||||
if (value !== this.muted) {
|
||||
this._store.toggleMuted();
|
||||
}
|
||||
}
|
||||
|
||||
get isAutoQuality(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
play(): void {
|
||||
this._store.play();
|
||||
}
|
||||
|
||||
pause(): void {
|
||||
this._store.pause();
|
||||
}
|
||||
|
||||
seekBy(offset: number): void {
|
||||
this._store.seek(this.currentTime + offset).catch(() => {});
|
||||
}
|
||||
|
||||
playPrev(): void {
|
||||
this._store.state.onPrev?.();
|
||||
}
|
||||
|
||||
playNext(): void {
|
||||
this._store.state.onNext?.();
|
||||
}
|
||||
|
||||
get hasPrev(): boolean {
|
||||
return this._source.metadata?.hasPrev ?? false;
|
||||
}
|
||||
|
||||
get hasNext(): boolean {
|
||||
return this._source.metadata?.hasNext ?? false;
|
||||
}
|
||||
|
||||
get videos(): Track[] {
|
||||
const media = this._store.state.media;
|
||||
if (!media?.videoTracks) return [];
|
||||
return Array.from(media.videoTracks).map(
|
||||
(track: any, i: number) => ({
|
||||
id: track.id || `video-${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: track.enabled,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
selectVideo(video: Track): void {
|
||||
const media = this._store.state.media;
|
||||
if (!media?.videoTracks) return;
|
||||
for (let i = 0; i < media.videoTracks.length; i++) {
|
||||
media.videoTracks[i].enabled = media.videoTracks[i].id === video.id;
|
||||
}
|
||||
}
|
||||
|
||||
get audios(): Track[] {
|
||||
const media = this._store.state.media;
|
||||
if (!media?.audioTracks) return [];
|
||||
return Array.from(media.audioTracks).map((track: any, i: number) => ({
|
||||
id: track.id || `audio-${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: track.enabled,
|
||||
}));
|
||||
}
|
||||
|
||||
selectAudio(audio: Track): void {
|
||||
const media = this._store.state.media;
|
||||
if (!media?.audioTracks) return;
|
||||
for (let i = 0; i < media.audioTracks.length; i++) {
|
||||
media.audioTracks[i].enabled = media.audioTracks[i].id === audio.id;
|
||||
}
|
||||
}
|
||||
|
||||
get subtitles(): Track[] {
|
||||
const sourceSubtitles = this._source.subtitles.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
language: s.language,
|
||||
selected: false,
|
||||
}));
|
||||
|
||||
const media = this._store.state.media as any;
|
||||
if (!media?.textTracks) return sourceSubtitles;
|
||||
|
||||
const mediaTracks: Track[] = [];
|
||||
for (let i = 0; i < media.textTracks.length; i++) {
|
||||
const track = media.textTracks[i];
|
||||
if (track && (track.kind === "subtitles" || track.kind === "captions")) {
|
||||
mediaTracks.push({
|
||||
id: track.id || `text-${i}`,
|
||||
label: track.label,
|
||||
language: track.language,
|
||||
selected: track.mode === "showing",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return sourceSubtitles.length > 0 ? sourceSubtitles : mediaTracks;
|
||||
}
|
||||
|
||||
selectSubtitle(subtitle?: Track): void {
|
||||
const media = this._store.state.media as any;
|
||||
if (!media?.textTracks) return;
|
||||
for (let i = 0; i < media.textTracks.length; i++) {
|
||||
const track = media.textTracks[i];
|
||||
if (track && (track.kind === "subtitles" || track.kind === "captions")) {
|
||||
track.mode = subtitle && (track.id === subtitle.id || track.label === subtitle.label)
|
||||
? "showing"
|
||||
: "hidden";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
get rendition(): Rendition[] {
|
||||
return [];
|
||||
}
|
||||
|
||||
selectRendition(_rendition?: Rendition): void {
|
||||
// Auto quality is handled by videojs/HLS
|
||||
}
|
||||
|
||||
private setupSubtitleTracks(media: HTMLVideoElement) {
|
||||
const existingTracks = media.querySelectorAll("track");
|
||||
existingTracks.forEach((t) => t.remove());
|
||||
|
||||
for (const subtitle of this._source.subtitles) {
|
||||
const track = document.createElement("track");
|
||||
track.kind = "subtitles";
|
||||
track.src = subtitle.link;
|
||||
if (subtitle.label) track.label = subtitle.label;
|
||||
if (subtitle.language) track.srclang = subtitle.language;
|
||||
track.id = subtitle.id;
|
||||
media.appendChild(track);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import "@videojs/react/video/skin.css";
|
||||
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
import { Video } from "@videojs/react/video";
|
||||
import type { CSSProperties } from "react";
|
||||
import { VideoPlayer, usePlayer } from "./provider.web";
|
||||
import type { OmniViewProps } from "./types/view";
|
||||
|
||||
export const OmniView = memo(
|
||||
({
|
||||
style,
|
||||
autoplay,
|
||||
autoPip,
|
||||
}: OmniViewProps & { style: CSSProperties }) => {
|
||||
const player = usePlayer();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const media = containerRef.current?.querySelector("video");
|
||||
(player as any).setMediaElement(media ?? null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoPip) return;
|
||||
const media = containerRef.current?.querySelector("video");
|
||||
if (media && document.pictureInPictureEnabled) {
|
||||
media.requestPictureInPicture().catch(() => {});
|
||||
}
|
||||
}, [autoPip]);
|
||||
|
||||
const src = player.source.src[0]?.uri;
|
||||
|
||||
return (
|
||||
<VideoPlayer.Container ref={containerRef} style={style}>
|
||||
{src && (
|
||||
<Video
|
||||
src={src}
|
||||
autoPlay={autoplay}
|
||||
playsInline
|
||||
crossOrigin="anonymous"
|
||||
/>
|
||||
)}
|
||||
</VideoPlayer.Container>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user