mirror of
https://github.com/zoriya/react-native-omni.git
synced 2026-08-16 02:44:51 +00:00
Cleanup web player
This commit is contained in:
+25
-21
@@ -4,7 +4,6 @@ import {
|
|||||||
selectError,
|
selectError,
|
||||||
selectPlayback,
|
selectPlayback,
|
||||||
selectPlaybackRate,
|
selectPlaybackRate,
|
||||||
selectSource,
|
|
||||||
selectTextTrack,
|
selectTextTrack,
|
||||||
selectTime,
|
selectTime,
|
||||||
selectVolume,
|
selectVolume,
|
||||||
@@ -85,22 +84,23 @@ export const useEvent = <Event extends keyof OmniEvents>(
|
|||||||
}, [value, config]);
|
}, [value, config]);
|
||||||
};
|
};
|
||||||
|
|
||||||
type MapperConfig = {
|
function createMapper<Key extends keyof OmniPlayerState, State, Result>(
|
||||||
[Key in keyof OmniPlayerState]?: {
|
key: Key,
|
||||||
selector: Selector<any, any>;
|
selector: Selector<State, Result>,
|
||||||
mapper: (ret: any) => OmniPlayerState[Key];
|
mapper: (ret: Result) => OmniPlayerState[Key],
|
||||||
};
|
): Pick<
|
||||||
};
|
{
|
||||||
|
[K in keyof OmniPlayerState]: {
|
||||||
function createMapper<
|
selector: Selector<State, Result>;
|
||||||
Key extends keyof OmniPlayerState,
|
mapper: (ret: Result) => OmniPlayerState[K];
|
||||||
Result,
|
};
|
||||||
Ret extends OmniPlayerState[Key],
|
},
|
||||||
>(key: Key, selector: Selector<any, Result>, mapper: (ret: Result) => Ret) {
|
Key
|
||||||
return { [key]: { selector, mapper } };
|
> {
|
||||||
|
return { [key]: { selector, mapper } } as any;
|
||||||
}
|
}
|
||||||
|
|
||||||
const mapper: MapperConfig = {
|
export const stateMapper = {
|
||||||
...createMapper("status", selectPlayback, (s) => {
|
...createMapper("status", selectPlayback, (s) => {
|
||||||
if (s?.waiting) return "loading";
|
if (s?.waiting) return "loading";
|
||||||
if (s?.ended) return "idle";
|
if (s?.ended) return "idle";
|
||||||
@@ -130,9 +130,13 @@ const mapper: MapperConfig = {
|
|||||||
...createMapper("muted", selectVolume, (s) => {
|
...createMapper("muted", selectVolume, (s) => {
|
||||||
return s?.muted ?? false;
|
return s?.muted ?? false;
|
||||||
}),
|
}),
|
||||||
...createMapper("isAutoQuality", selectSource, (s) => {
|
...createMapper(
|
||||||
return false;
|
"isAutoQuality",
|
||||||
}),
|
() => {},
|
||||||
|
() => {
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
export function usePlayerState<Key extends keyof OmniPlayerState>(
|
export function usePlayerState<Key extends keyof OmniPlayerState>(
|
||||||
@@ -146,8 +150,8 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
|
|||||||
key: Key,
|
key: Key,
|
||||||
_refresh?: number,
|
_refresh?: number,
|
||||||
): OmniPlayerState[Key] {
|
): OmniPlayerState[Key] {
|
||||||
const config = mapper[key];
|
const config = stateMapper[key];
|
||||||
if (!config) throw new Error(`No mapper for ${key}`);
|
if (!config) throw new Error(`No mapper for ${key}`);
|
||||||
const ret = usePlayer(config.selector);
|
const ret = usePlayer(config.selector as Selector<any, any>);
|
||||||
return config.mapper(ret);
|
return config.mapper(ret) as OmniPlayerState[Key];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
import type { VideoPlayerStore } from "@videojs/core/dom";
|
||||||
|
import { stateMapper } from "./events.web";
|
||||||
|
import type {
|
||||||
|
OmniPlayer,
|
||||||
|
PlayerStatus,
|
||||||
|
Rendition,
|
||||||
|
Track,
|
||||||
|
} from "./types/player";
|
||||||
|
import type { Source } from "./types/source";
|
||||||
|
|
||||||
|
export class WebOmniPlayer implements OmniPlayer {
|
||||||
|
_store: VideoPlayerStore;
|
||||||
|
onPrev?: () => void;
|
||||||
|
onNext?: () => void;
|
||||||
|
|
||||||
|
constructor(store: VideoPlayerStore) {
|
||||||
|
this._store = store;
|
||||||
|
}
|
||||||
|
|
||||||
|
get source(): Source {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
set source(source: Source) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
get showNotification(): boolean {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
set showNotification(value: boolean) {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
get status(): PlayerStatus {
|
||||||
|
const state = this._store.state;
|
||||||
|
return stateMapper.status.mapper(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.length === 0) return 0;
|
||||||
|
return buffered[buffered.length - 1]?.[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.onPrev?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
playNext(): void {
|
||||||
|
this.onNext?.();
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasPrev(): boolean {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
get hasNext(): boolean {
|
||||||
|
// TODO
|
||||||
|
}
|
||||||
|
|
||||||
|
get videos(): Track[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectVideo(video: Track): void {}
|
||||||
|
|
||||||
|
get audios(): Track[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectAudio(audio: Track): void {}
|
||||||
|
|
||||||
|
get subtitles(): Track[] {
|
||||||
|
return this._store.textTrackList
|
||||||
|
.map((x, i) => ({
|
||||||
|
id: i.toString(),
|
||||||
|
kind: x.kind,
|
||||||
|
label: x.label,
|
||||||
|
language: x.language,
|
||||||
|
selected: x.mode === "showing",
|
||||||
|
}))
|
||||||
|
.filter((x) => x.kind === "subtitles" || x.kind === "captions");
|
||||||
|
}
|
||||||
|
|
||||||
|
selectSubtitle(subtitle?: Track): void {
|
||||||
|
for (let i = 0; i < this._store.textTrackList.length; i++) {
|
||||||
|
const track = this._store.textTrackList[i]!;
|
||||||
|
if (track.kind !== "subtitles" && track.kind !== "captions") continue;
|
||||||
|
track.mode =
|
||||||
|
subtitle && i.toString() === subtitle.id ? "showing" : "hidden";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
get rendition(): Rendition[] {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
selectRendition(_rendition?: Rendition): void {
|
||||||
|
// Auto quality is handled by videojs/HLS
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-312
@@ -1,54 +1,18 @@
|
|||||||
import { createPlayer } from "@videojs/react";
|
import { createPlayer } from "@videojs/react";
|
||||||
import { videoFeatures } from "@videojs/react/video";
|
import { videoFeatures } from "@videojs/react/video";
|
||||||
import type { VideoPlayerStore } from "@videojs/core/dom";
|
|
||||||
import {
|
import {
|
||||||
createContext,
|
createContext,
|
||||||
type ReactNode,
|
type ReactNode,
|
||||||
useContext,
|
useContext,
|
||||||
useEffect,
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
|
import { WebOmniPlayer } from "./player.web";
|
||||||
import type {
|
import type {
|
||||||
OmniPlayer,
|
OmniPlayer,
|
||||||
PlayerStatus,
|
|
||||||
Rendition,
|
|
||||||
Track,
|
|
||||||
} from "./types/player";
|
} from "./types/player";
|
||||||
import type { Source } from "./types/source";
|
import type { Source } from "./types/source";
|
||||||
import { useLazyRef } from "./utils/lazy-ref";
|
import { useLazyRef } from "./utils/lazy-ref";
|
||||||
|
|
||||||
interface VideoTrack {
|
|
||||||
readonly id: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly language: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VideoTrackList {
|
|
||||||
readonly length: number;
|
|
||||||
[index: number]: VideoTrack;
|
|
||||||
addEventListener(type: "change", listener: () => void): void;
|
|
||||||
removeEventListener(type: "change", listener: () => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioTrack {
|
|
||||||
readonly id: string;
|
|
||||||
readonly label: string;
|
|
||||||
readonly language: string;
|
|
||||||
enabled: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AudioTrackList {
|
|
||||||
readonly length: number;
|
|
||||||
[index: number]: AudioTrack;
|
|
||||||
addEventListener(type: "change", listener: () => void): void;
|
|
||||||
removeEventListener(type: "change", listener: () => void): void;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface HtmlVideoElementWithTracks extends HTMLVideoElement {
|
|
||||||
videoTracks?: VideoTrackList;
|
|
||||||
audioTracks?: AudioTrackList;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const VideoPlayer = createPlayer({ features: videoFeatures });
|
export const VideoPlayer = createPlayer({ features: videoFeatures });
|
||||||
|
|
||||||
const PlayerCtx = createContext<OmniPlayer>(null!);
|
const PlayerCtx = createContext<OmniPlayer>(null!);
|
||||||
@@ -95,278 +59,3 @@ const PlayerInitializer = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const usePlayer = () => useContext(PlayerCtx);
|
export const usePlayer = () => useContext(PlayerCtx);
|
||||||
|
|
||||||
interface ExtendedStoreState {
|
|
||||||
onPrev?: () => void;
|
|
||||||
onNext?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class WebOmniPlayer implements OmniPlayer {
|
|
||||||
private _store: VideoPlayerStore;
|
|
||||||
private _source: Source;
|
|
||||||
private _showNotification = false;
|
|
||||||
private _mediaElement: HtmlVideoElementWithTracks | null = null;
|
|
||||||
|
|
||||||
constructor(store: VideoPlayerStore, source: Source) {
|
|
||||||
this._store = store;
|
|
||||||
this._source = source;
|
|
||||||
}
|
|
||||||
|
|
||||||
setMediaElement(media: HTMLVideoElement | null) {
|
|
||||||
this._mediaElement = media as HtmlVideoElementWithTracks | null;
|
|
||||||
if (media) {
|
|
||||||
this.setupSubtitleTracks(media);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get mediaElement(): HtmlVideoElementWithTracks | null {
|
|
||||||
return this._mediaElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
get store(): VideoPlayerStore {
|
|
||||||
return this._store;
|
|
||||||
}
|
|
||||||
|
|
||||||
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.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(() => {});
|
|
||||||
}
|
|
||||||
|
|
||||||
get onPrev(): (() => void) | undefined {
|
|
||||||
return (this._store.state as ExtendedStoreState).onPrev;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onPrev(cb: (() => void) | undefined) {
|
|
||||||
(this._store.state as ExtendedStoreState).onPrev = cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
get onNext(): (() => void) | undefined {
|
|
||||||
return (this._store.state as ExtendedStoreState).onNext;
|
|
||||||
}
|
|
||||||
|
|
||||||
set onNext(cb: (() => void) | undefined) {
|
|
||||||
(this._store.state as ExtendedStoreState).onNext = cb;
|
|
||||||
}
|
|
||||||
|
|
||||||
playPrev(): void {
|
|
||||||
this.onPrev?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
playNext(): void {
|
|
||||||
this.onNext?.();
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasPrev(): boolean {
|
|
||||||
return this._source.metadata?.hasPrev ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
get hasNext(): boolean {
|
|
||||||
return this._source.metadata?.hasNext ?? false;
|
|
||||||
}
|
|
||||||
|
|
||||||
get videos(): Track[] {
|
|
||||||
const videoTracks = this._mediaElement?.videoTracks;
|
|
||||||
if (!videoTracks) return [];
|
|
||||||
const tracks: Track[] = [];
|
|
||||||
for (let i = 0; i < videoTracks.length; i++) {
|
|
||||||
const track = videoTracks[i];
|
|
||||||
if (track) {
|
|
||||||
tracks.push({
|
|
||||||
id: track.id || `video-${i}`,
|
|
||||||
label: track.label,
|
|
||||||
language: track.language,
|
|
||||||
selected: track.enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectVideo(video: Track): void {
|
|
||||||
const videoTracks = this._mediaElement?.videoTracks;
|
|
||||||
if (!videoTracks) return;
|
|
||||||
for (let i = 0; i < videoTracks.length; i++) {
|
|
||||||
const track = videoTracks[i];
|
|
||||||
if (track) {
|
|
||||||
track.enabled = track.id === video.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get audios(): Track[] {
|
|
||||||
const audioTracks = this._mediaElement?.audioTracks;
|
|
||||||
if (!audioTracks) return [];
|
|
||||||
const tracks: Track[] = [];
|
|
||||||
for (let i = 0; i < audioTracks.length; i++) {
|
|
||||||
const track = audioTracks[i];
|
|
||||||
if (track) {
|
|
||||||
tracks.push({
|
|
||||||
id: track.id || `audio-${i}`,
|
|
||||||
label: track.label,
|
|
||||||
language: track.language,
|
|
||||||
selected: track.enabled,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tracks;
|
|
||||||
}
|
|
||||||
|
|
||||||
selectAudio(audio: Track): void {
|
|
||||||
const audioTracks = this._mediaElement?.audioTracks;
|
|
||||||
if (!audioTracks) return;
|
|
||||||
for (let i = 0; i < audioTracks.length; i++) {
|
|
||||||
const track = audioTracks[i];
|
|
||||||
if (track) {
|
|
||||||
track.enabled = track.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 textTracks = this._mediaElement?.textTracks;
|
|
||||||
if (!textTracks) return sourceSubtitles;
|
|
||||||
|
|
||||||
const mediaTracks: Track[] = [];
|
|
||||||
for (let i = 0; i < textTracks.length; i++) {
|
|
||||||
const track = 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 textTracks = this._mediaElement?.textTracks;
|
|
||||||
if (!textTracks) return;
|
|
||||||
for (let i = 0; i < textTracks.length; i++) {
|
|
||||||
const track = 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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user