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; handler: (cb: OmniEvents[Key], value: any, prev: any) => void; }; }; function createEventMapper( key: Key, selector: Selector, 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: Event, callback: OmniEvents[Event], ) => { const config = eventMapper[event]; const callbackRef = useRef(callback); callbackRef.current = callback; const prevRef = useRef(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: Key, selector: Selector, mapper: (ret: Result) => OmniPlayerState[Key], ): Pick< { [K in keyof OmniPlayerState]: { selector: Selector; 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: Key, ): OmniPlayerState[Key]; export function usePlayerState( key: "currentTime", refresh?: number, ): OmniPlayerState["currentTime"]; export function usePlayerState( 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); 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