refactor(web): extract VideoStore type, simplify player with media getter

This commit is contained in:
Kamil Moskała
2026-03-25 18:11:31 +01:00
parent 9fda566603
commit 82e7d2e065
5 changed files with 70 additions and 78 deletions
@@ -12,7 +12,8 @@ import type { VideoPlayerSourceBase } from "./types/VideoPlayerSourceBase";
import type { VideoPlayerStatus } from "./types/VideoPlayerStatus";
import { VideoPlayerEvents } from "./events/VideoPlayerEvents";
import { MediaSessionHandler } from "./web/MediaSession";
import { WebEventEmitter, type VideoStore } from "./web/WebEventEmitter";
import { WebEventEmitter } from "./web/WebEventEmitter";
import type { VideoStore } from "./web/VideoStore";
class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
private video: HTMLVideoElement;
@@ -90,40 +91,34 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
};
}
// Store when available, video element fallback.
// Store may provide more accurate values for HLS/DASH/DRM content.
/** Store when available, video element fallback. */
private get media(): VideoStore | HTMLVideoElement {
return this._store ?? this.video;
}
get status(): VideoPlayerStatus {
const s = this._store;
if (s) {
if (s.error) return "error";
if (s.canPlay) return "readyToPlay";
if (s.waiting) return "loading";
if (s.source) return "loading";
return "idle";
}
if (this.video.error) return "error";
if (this.media.error) return "error";
if (this.video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) return "readyToPlay";
if (this.video.readyState > HTMLMediaElement.HAVE_NOTHING) return "loading";
if (this.video.src) return "loading";
return "idle";
}
get duration(): number { return this._store?.duration || this.video.duration || NaN; }
get volume(): number { return this._store?.volume ?? this.video.volume; }
get duration(): number { return this.media.duration || NaN; }
get volume(): number { return this.media.volume; }
set volume(v: number) {
const clamped = Math.max(0, Math.min(1, v));
if (this._store) { this._store.setVolume(clamped); } else { this.video.volume = clamped; }
}
get currentTime(): number { return this._store?.currentTime ?? this.video.currentTime; }
get currentTime(): number { return this.media.currentTime; }
set currentTime(v: number) {
if (this._store) { this._store.seek(v); } else { this.video.currentTime = v; }
}
get muted(): boolean { return this._store?.muted ?? this.video.muted; }
get muted(): boolean { return this.media.muted; }
set muted(v: boolean) { this.video.muted = v; }
get loop(): boolean { return this.video.loop; }
set loop(v: boolean) { this.video.loop = v; }
get rate(): number { return this._store?.playbackRate ?? this.video.playbackRate; }
get rate(): number { return this.media.playbackRate; }
set rate(v: number) {
if (this._store) { this._store.setPlaybackRate(v); } else { this.video.playbackRate = v; }
}
@@ -137,9 +132,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
get playWhenInactive(): boolean { return true; }
set playWhenInactive(_: boolean) {}
get isPlaying(): boolean {
return this._store ? !this._store.paused : !this.video.paused && !this.video.ended;
}
get isPlaying(): boolean { return !this.media.paused; }
get showNotificationControls(): boolean {
return this.mediaSession?.enabled ?? false;
@@ -159,16 +152,11 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
}
release(): void { this.__destroy(); }
play(): void {
if (this._store) { this._store.play()?.catch(() => {}); } else { this.video.play()?.catch(() => {}); }
}
pause(): void {
if (this._store) { this._store.pause(); } else { this.video.pause(); }
}
play(): void { this.media.play()?.catch(() => {}); }
pause(): void { this.media.pause(); }
seekBy(time: number): void {
const now = this._store?.currentTime ?? this.video.currentTime;
const now = this.media.currentTime;
if (this._store) { this._store.seek(now + time); } else { this.video.currentTime = now + time; }
}
@@ -14,7 +14,7 @@ import type { VideoViewProps, VideoViewRef } from "./VideoViewProps";
import { createPlayer, videoFeatures, usePlayerContext, useMediaAttach } from "@videojs/react";
import { VideoSkin } from "@videojs/react/video";
import "@videojs/react/video/skin.css";
import type { VideoStore } from "../web/WebEventEmitter";
import type { VideoStore } from "../web/VideoStore";
const Player = createPlayer({ features: videoFeatures });
@@ -1,24 +1,11 @@
import type { CustomVideoMetadata } from "../types/VideoConfig";
import type { MediaSessionStore } from "./VideoStore";
function getMediaSession(): MediaSession | undefined {
if (typeof window === "undefined") return undefined;
return window.navigator?.mediaSession;
}
/**
* video.js store interface the subset MediaSession needs.
*/
interface MediaSessionStore {
readonly paused: boolean;
readonly currentTime: number;
readonly duration: number;
readonly playbackRate: number;
play(): Promise<void>;
pause(): void;
seek(time: number): Promise<number>;
subscribe(callback: () => void): () => void;
}
export class MediaSessionHandler {
enabled: boolean = false;
@@ -0,0 +1,51 @@
/**
* video.js store interface.
* Represents the reactive store created by @videojs/react's createPlayer.
* Used as the primary data source when VideoView is mounted;
* HTMLVideoElement is the fallback when store is not available.
*/
export interface VideoStore {
// State
readonly paused: boolean;
readonly ended: boolean;
readonly waiting: boolean;
readonly seeking: boolean;
readonly canPlay: boolean;
readonly currentTime: number;
readonly duration: number;
readonly volume: number;
readonly muted: boolean;
readonly playbackRate: number;
readonly source: string | null;
readonly buffered: [number, number][];
readonly error: { code: number; message: string } | null;
readonly textTrackList: Array<{ kind: string; label: string; language: string; mode: string }>;
readonly destroyed: boolean;
readonly target: unknown;
readonly pipAvailability: string;
// Actions
play(): Promise<void>;
pause(): void;
seek(time: number): Promise<number>;
setVolume(volume: number): number;
setPlaybackRate(rate: number): void;
loadSource(src: string): string;
requestFullscreen(): Promise<void>;
exitFullscreen(): Promise<void>;
requestPictureInPicture(): Promise<void>;
exitPictureInPicture(): Promise<void>;
// Lifecycle
subscribe(callback: () => void): () => void;
attach(target: { media: HTMLVideoElement; container: HTMLElement | null }): () => void;
destroy(): void;
}
/**
* Subset of VideoStore used by MediaSessionHandler.
*/
export type MediaSessionStore = Pick<
VideoStore,
"paused" | "currentTime" | "duration" | "playbackRate" | "play" | "pause" | "seek" | "subscribe"
>;
@@ -22,41 +22,7 @@ import type {
VideoPlayerEventEmitterBase,
} from "../types/EventEmitter";
/**
* video.js store interface optional, used for enhanced buffering info when available.
*/
export interface VideoStore {
readonly paused: boolean;
readonly ended: boolean;
readonly waiting: boolean;
readonly seeking: boolean;
readonly canPlay: boolean;
readonly currentTime: number;
readonly duration: number;
readonly volume: number;
readonly muted: boolean;
readonly playbackRate: number;
readonly source: string | null;
readonly buffered: [number, number][];
readonly error: { code: number; message: string } | null;
readonly textTrackList: Array<{ kind: string; label: string; language: string; mode: string }>;
readonly destroyed: boolean;
readonly target: unknown;
subscribe(callback: () => void): () => void;
attach(target: { media: HTMLVideoElement; container: HTMLElement | null }): () => void;
destroy(): void;
loadSource(src: string): string;
play(): Promise<void>;
pause(): void;
seek(time: number): Promise<number>;
setVolume(volume: number): number;
setPlaybackRate(rate: number): void;
requestFullscreen(): Promise<void>;
exitFullscreen(): Promise<void>;
requestPictureInPicture(): Promise<void>;
exitPictureInPicture(): Promise<void>;
readonly pipAvailability: string;
}
import type { VideoStore } from "./VideoStore";
/**
* WebEventEmitter bridges HTML5 media events to our event system.