mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
278 lines
7.4 KiB
TypeScript
278 lines
7.4 KiB
TypeScript
import {
|
|
type Selector,
|
|
selectAudioTrack,
|
|
selectBuffer,
|
|
selectError,
|
|
selectPlayback,
|
|
selectPlaybackRate,
|
|
selectQuality,
|
|
selectRemotePlayback,
|
|
selectTextTrack,
|
|
selectTime,
|
|
selectVolume,
|
|
usePlayer as useStoreSelector,
|
|
} from "@videojs/react";
|
|
import { useEffect, useRef, useSyncExternalStore } 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";
|
|
|
|
type EventMapperConfig = {
|
|
[Key in keyof OmniEvents]?: {
|
|
selector: Selector<any, any>;
|
|
handler: (cb: OmniEvents[Key], value: any, prev: any) => void;
|
|
};
|
|
};
|
|
|
|
function createEventMapper<Key extends keyof OmniEvents, Result>(
|
|
key: Key,
|
|
selector: Selector<any, Result>,
|
|
handler: (
|
|
cb: OmniEvents[Key],
|
|
value: Result,
|
|
prev: Result | undefined,
|
|
) => void,
|
|
) {
|
|
return { [key]: { selector, handler } };
|
|
}
|
|
|
|
const eventMapper: EventMapperConfig = {
|
|
...createEventMapper("end", selectPlayback, (cb, value, prev) => {
|
|
if (value?.ended && prev && !prev.ended) cb();
|
|
}),
|
|
...createEventMapper("error", selectError, (cb, value, prev) => {
|
|
if (value?.error && value.error !== prev?.error) {
|
|
cb(
|
|
value.error.message ?? "Unknown error",
|
|
value.error.message ?? "Unknown error",
|
|
);
|
|
}
|
|
}),
|
|
...createEventMapper("subtitleChange", selectTextTrack, (cb, value, prev) => {
|
|
if (value === prev) return;
|
|
const tracks = value?.textTrackList;
|
|
if (tracks?.length) {
|
|
for (let i = 0; i < tracks.length; i++) {
|
|
const track = tracks[i];
|
|
if (track?.mode === "showing") {
|
|
cb({
|
|
id: `text-${i}`,
|
|
label: track.label,
|
|
language: track.language,
|
|
selected: true,
|
|
});
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
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>(
|
|
event: Event,
|
|
callback: OmniEvents[Event],
|
|
) => {
|
|
const config = eventMapper[event];
|
|
const callbackRef = useRef(callback);
|
|
callbackRef.current = callback;
|
|
const prevRef = useRef<any>(undefined);
|
|
const value = useStoreSelector(config?.selector ?? (() => undefined));
|
|
const player = usePlayer() as WebOmniPlayer;
|
|
useEffect(() => {
|
|
if (!config) return;
|
|
config.handler(callbackRef.current as any, value, prevRef.current);
|
|
prevRef.current = value;
|
|
}, [value, config]);
|
|
|
|
useEffect(() => {
|
|
// don't use callbackRef.current directly to keep future callback updates
|
|
const cb = () => (callbackRef.current as () => void)();
|
|
if (event === "prev") {
|
|
player.onPrev.add(cb);
|
|
return () => {
|
|
player.onPrev.delete(cb);
|
|
};
|
|
}
|
|
if (event === "next") {
|
|
player.onNext.add(cb);
|
|
return () => {
|
|
player.onNext.delete(cb);
|
|
};
|
|
}
|
|
return undefined;
|
|
}, [event, player]);
|
|
|
|
useEffect(() => {
|
|
if (event !== "subtitleChange") return;
|
|
return player.subscribeOverlaySubtitle(() => {
|
|
const sel = player.subtitles.find((s) => s.selected);
|
|
const cb = callbackRef.current as (s: unknown) => void;
|
|
cb(
|
|
sel
|
|
? {
|
|
id: sel.id,
|
|
label: sel.label,
|
|
language: sel.language,
|
|
selected: true,
|
|
}
|
|
: undefined,
|
|
);
|
|
});
|
|
}, [event, player]);
|
|
};
|
|
|
|
function createMapper<Key extends keyof OmniPlayerState, State, Result>(
|
|
key: Key,
|
|
selector: Selector<State, Result>,
|
|
mapper: (ret: Result) => OmniPlayerState[Key],
|
|
): Pick<
|
|
{
|
|
[K in keyof OmniPlayerState]: {
|
|
selector: Selector<State, Result>;
|
|
mapper: (ret: Result) => OmniPlayerState[K];
|
|
};
|
|
},
|
|
Key
|
|
> {
|
|
return { [key]: { selector, mapper } } as any;
|
|
}
|
|
|
|
export const stateMapper = {
|
|
...createMapper("status", selectPlayback, (s) => {
|
|
if (s?.waiting) return "loading";
|
|
if (s?.ended) return "idle";
|
|
return "readyToPlay";
|
|
}),
|
|
...createMapper("isPlaying", selectPlayback, (s) => {
|
|
if (!s) return false;
|
|
return !s.paused;
|
|
}),
|
|
...createMapper("currentTime", selectTime, (s) => {
|
|
return s?.currentTime ?? 0;
|
|
}),
|
|
...createMapper("buffered", selectBuffer, (s) => {
|
|
if (!s?.buffered.length) return 0;
|
|
const last = s.buffered[s.buffered.length - 1];
|
|
return last?.[1] ?? 0;
|
|
}),
|
|
...createMapper("duration", selectTime, (s) => {
|
|
return s?.duration ?? 0;
|
|
}),
|
|
...createMapper("playbackRate", selectPlaybackRate, (s) => {
|
|
return s?.playbackRate ?? 1;
|
|
}),
|
|
...createMapper("volume", selectVolume, (s) => {
|
|
return s?.volume ?? 1;
|
|
}),
|
|
...createMapper("muted", selectVolume, (s) => {
|
|
return s?.muted ?? false;
|
|
}),
|
|
...createMapper("isAutoQuality", selectQuality, (q) => {
|
|
if (!q) return true;
|
|
return !q.videoRenditionList.some((r) => r.selected);
|
|
}),
|
|
...createMapper("castStatus", selectRemotePlayback, (r) => {
|
|
if (!r) return "unavailable";
|
|
if (r.remotePlaybackState === "connecting") return "connecting";
|
|
if (r.remotePlaybackState === "connected") return "connected";
|
|
return r.remotePlaybackAvailability;
|
|
}),
|
|
...createMapper("audios", selectAudioTrack, (audio) => {
|
|
if (!audio) return [];
|
|
return audio.audioTrackList.map((track, i) => ({
|
|
id: track.id ?? i.toString(),
|
|
label: track.label,
|
|
language: track.language,
|
|
selected: track.enabled,
|
|
}));
|
|
}),
|
|
...createMapper("renditions", selectQuality, (quality) => {
|
|
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 ||
|
|
(active != null &&
|
|
rendition.id === active.id &&
|
|
rendition.width === active.width &&
|
|
rendition.height === active.height &&
|
|
rendition.bitrate === active.bitrate),
|
|
}));
|
|
}),
|
|
};
|
|
|
|
// biome-ignore-start lint/correctness/useHookAtTopLevel: key must be const
|
|
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] {
|
|
switch (key as keyof OmniPlayerState) {
|
|
case "source": {
|
|
const player = usePlayer() as WebOmniPlayer;
|
|
const source = useSyncExternalStore(
|
|
player.subscribeSource,
|
|
() => player.source,
|
|
() => player.source,
|
|
);
|
|
return source as OmniPlayerState[Key];
|
|
}
|
|
case "videos": {
|
|
const player = usePlayer();
|
|
return player.videos as OmniPlayerState[Key];
|
|
}
|
|
case "subtitles": {
|
|
const player = usePlayer() as WebOmniPlayer;
|
|
useStoreSelector(selectTextTrack);
|
|
useSyncExternalStore(
|
|
player.subscribeOverlaySubtitle,
|
|
player.getOverlaySubtitle,
|
|
() => null,
|
|
);
|
|
return player.subtitles as OmniPlayerState[Key];
|
|
}
|
|
default: {
|
|
const config = stateMapper[key];
|
|
const ret = useStoreSelector(config?.selector as Selector<any, any>);
|
|
if (!config) throw new Error(`No mapper for ${key}`);
|
|
return config.mapper(ret) as OmniPlayerState[Key];
|
|
}
|
|
}
|
|
}
|
|
// biome-ignore-end lint/correctness/useHookAtTopLevel: key must be const
|