Cleanup web player

This commit is contained in:
2026-05-24 23:38:18 +02:00
parent d603f95720
commit 89799cd5cc
3 changed files with 183 additions and 333 deletions
+25 -21
View File
@@ -4,7 +4,6 @@ import {
selectError,
selectPlayback,
selectPlaybackRate,
selectSource,
selectTextTrack,
selectTime,
selectVolume,
@@ -85,22 +84,23 @@ export const useEvent = <Event extends keyof OmniEvents>(
}, [value, config]);
};
type MapperConfig = {
[Key in keyof OmniPlayerState]?: {
selector: Selector<any, any>;
mapper: (ret: any) => OmniPlayerState[Key];
};
};
function createMapper<
Key extends keyof OmniPlayerState,
Result,
Ret extends OmniPlayerState[Key],
>(key: Key, selector: Selector<any, Result>, mapper: (ret: Result) => Ret) {
return { [key]: { selector, mapper } };
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;
}
const mapper: MapperConfig = {
export const stateMapper = {
...createMapper("status", selectPlayback, (s) => {
if (s?.waiting) return "loading";
if (s?.ended) return "idle";
@@ -130,9 +130,13 @@ const mapper: MapperConfig = {
...createMapper("muted", selectVolume, (s) => {
return s?.muted ?? false;
}),
...createMapper("isAutoQuality", selectSource, (s) => {
return false;
}),
...createMapper(
"isAutoQuality",
() => {},
() => {
return true;
},
),
};
export function usePlayerState<Key extends keyof OmniPlayerState>(
@@ -146,8 +150,8 @@ export function usePlayerState<Key extends keyof OmniPlayerState>(
key: Key,
_refresh?: number,
): OmniPlayerState[Key] {
const config = mapper[key];
const config = stateMapper[key];
if (!config) throw new Error(`No mapper for ${key}`);
const ret = usePlayer(config.selector);
return config.mapper(ret);
const ret = usePlayer(config.selector as Selector<any, any>);
return config.mapper(ret) as OmniPlayerState[Key];
}
+157
View File
@@ -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
View File
@@ -1,54 +1,18 @@
import { createPlayer } from "@videojs/react";
import { videoFeatures } from "@videojs/react/video";
import type { VideoPlayerStore } from "@videojs/core/dom";
import {
createContext,
type ReactNode,
useContext,
useEffect,
} from "react";
import { WebOmniPlayer } from "./player.web";
import type {
OmniPlayer,
PlayerStatus,
Rendition,
Track,
} from "./types/player";
import type { Source } from "./types/source";
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 });
const PlayerCtx = createContext<OmniPlayer>(null!);
@@ -95,278 +59,3 @@ const PlayerInitializer = ({
};
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);
}
}
}