refactor: extract WebMediaProxy for unified store/video access

This commit is contained in:
Kamil Moskała
2026-03-26 18:13:39 +01:00
parent 42f32b079d
commit 9bce20ea7a
3 changed files with 144 additions and 98 deletions
@@ -15,6 +15,7 @@ import type { VideoPlayerStatus } from './types/VideoPlayerStatus';
import { VideoPlayerEvents } from './events/VideoPlayerEvents'; import { VideoPlayerEvents } from './events/VideoPlayerEvents';
import { MediaSessionHandler } from './web/MediaSession'; import { MediaSessionHandler } from './web/MediaSession';
import { WebEventEmitter } from './web/WebEventEmitter'; import { WebEventEmitter } from './web/WebEventEmitter';
import { WebMediaProxy } from './web/WebMediaProxy';
import type { VideoStore } from './web/VideoStore'; import type { VideoStore } from './web/VideoStore';
function setExternalSubtitles( function setExternalSubtitles(
@@ -84,22 +85,10 @@ function selectTrack(
} }
class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer { class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
private video: HTMLVideoElement; private _media: WebMediaProxy;
private _storeRef: WeakRef<VideoStore> | null = null;
private mediaSession: MediaSessionHandler | null = null; private mediaSession: MediaSessionHandler | null = null;
private _source: NativeVideoConfig | undefined; private _source: NativeVideoConfig | undefined;
/** Returns store if alive, null if destroyed or disconnected. */
private get _store(): VideoStore | null {
const store = this._storeRef?.deref() ?? null;
return store?.destroyed ? null : store;
}
/** Store when available, video element fallback. */
private get media(): VideoStore | HTMLVideoElement {
return this._store ?? this.video;
}
/** /**
* Creates a detached <video> element that works immediately. * Creates a detached <video> element that works immediately.
* VideoView later mounts it into the DOM and connects the video.js store. * VideoView later mounts it into the DOM and connects the video.js store.
@@ -114,8 +103,9 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
const video = document.createElement('video'); const video = document.createElement('video');
video.playsInline = true; video.playsInline = true;
super(new WebEventEmitter(null, () => video)); const media = new WebMediaProxy(video);
this.video = video; super(new WebEventEmitter(media));
this._media = media;
(this.eventEmitter as WebEventEmitter).addOnErrorListener((error) => { (this.eventEmitter as WebEventEmitter).addOnErrorListener((error) => {
this.triggerJSEvent('onError', error); this.triggerJSEvent('onError', error);
@@ -128,8 +118,7 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
/** @internal */ /** @internal */
__setStore(store: VideoStore | null) { __setStore(store: VideoStore | null) {
this._storeRef = store ? new WeakRef(store) : null; this._media.setStore(store);
(this.eventEmitter as WebEventEmitter).setStore(store);
if (store) { if (store) {
this.mediaSession = new MediaSessionHandler(store); this.mediaSession = new MediaSessionHandler(store);
} else { } else {
@@ -143,12 +132,12 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
this.mediaSession?.disable(); this.mediaSession?.disable();
(this.eventEmitter as WebEventEmitter).destroy(); (this.eventEmitter as WebEventEmitter).destroy();
this.clearAllEvents(); this.clearAllEvents();
this._storeRef = null; this._media.setStore(null);
} }
/** @internal */ /** @internal */
__getMedia(): HTMLVideoElement { __getMedia(): HTMLVideoElement {
return this.video; return this._media.video;
} }
// --- Playback state (read from store or video element) --- // --- Playback state (read from store or video element) ---
@@ -159,9 +148,9 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
config: this._source ?? { uri: '' }, config: this._source ?? { uri: '' },
getAssetInformationAsync: async () => ({ getAssetInformationAsync: async () => ({
bitrate: NaN, bitrate: NaN,
width: this.video.videoWidth, width: this._media.video.videoWidth,
height: this.video.videoHeight, height: this._media.video.videoHeight,
duration: this.video.duration || NaN, duration: this._media.duration || NaN,
fileSize: -1n, fileSize: -1n,
isHDR: false, isHDR: false,
isLive: false, isLive: false,
@@ -171,61 +160,57 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
} }
get status(): VideoPlayerStatus { get status(): VideoPlayerStatus {
if (this.media.error) return 'error'; const video = this._media.video;
if (this.video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) if (this._media.error) return 'error';
if (video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA)
return 'readyToPlay'; return 'readyToPlay';
if (this.video.readyState > HTMLMediaElement.HAVE_NOTHING) return 'loading'; if (video.readyState > HTMLMediaElement.HAVE_NOTHING) return 'loading';
if (this.video.src) return 'loading'; if (video.src) return 'loading';
return 'idle'; return 'idle';
} }
get duration(): number { get duration(): number {
return this.media.duration || NaN; return this._media.duration || NaN;
} }
get currentTime(): number { get currentTime(): number {
return this.media.currentTime; return this._media.currentTime;
} }
get volume(): number { get volume(): number {
return this.media.volume; return this._media.volume;
} }
get muted(): boolean { get muted(): boolean {
return this.media.muted; return this._media.muted;
} }
get loop(): boolean { get loop(): boolean {
return this.video.loop; return this._media.video.loop;
} }
get rate(): number { get rate(): number {
return this.media.playbackRate; return this._media.playbackRate;
} }
get isPlaying(): boolean { get isPlaying(): boolean {
return !this.media.paused; return !this._media.paused;
} }
// --- Playback state (write through store or video element) --- // --- Playback state (write through store or video element) ---
set volume(v: number) { set volume(v: number) {
const clamped = Math.max(0, Math.min(1, v)); this._media.setVolume(Math.max(0, Math.min(1, v)));
this._store
? this._store.setVolume(clamped)
: (this.video.volume = clamped);
} }
set currentTime(v: number) { set currentTime(v: number) {
this._store ? this._store.seek(v) : (this.video.currentTime = v); this._media.seek(v);
} }
// video.js store has toggleMuted() but no direct setter // video.js store has toggleMuted() but no direct setter
set muted(v: boolean) { set muted(v: boolean) {
this.video.muted = v; this._media.video.muted = v;
} }
set loop(v: boolean) { set loop(v: boolean) {
this.video.loop = v; this._media.video.loop = v;
} }
set rate(v: number) { set rate(v: number) {
this._store this._media.setPlaybackRate(v);
? this._store.setPlaybackRate(v)
: (this.video.playbackRate = v);
} }
// --- Unsupported on web (no-op) --- // --- Unsupported on web (no-op) ---
@@ -268,26 +253,26 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
async initialize(): Promise<void> {} async initialize(): Promise<void> {}
async preload(): Promise<void> { async preload(): Promise<void> {
this.video.preload = 'auto'; this._media.video.preload = 'auto';
this.video.load(); this._media.video.load();
} }
release(): void { release(): void {
this.__destroy(); this.__destroy();
} }
play(): void { play(): void {
this.media.play()?.catch(() => {}); this._media.play()?.catch(() => {});
} }
pause(): void { pause(): void {
this.media.pause(); this._media.pause();
} }
seekTo(time: number): void { seekTo(time: number): void {
this._store ? this._store.seek(time) : (this.video.currentTime = time); this._media.seek(time);
} }
seekBy(time: number): void { seekBy(time: number): void {
this.seekTo(this.media.currentTime + time); this.seekTo(this._media.currentTime + time);
} }
// --- Source management --- // --- Source management ---
@@ -299,9 +284,11 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
| NoAutocomplete<VideoPlayerSourceBase> | NoAutocomplete<VideoPlayerSourceBase>
| null | null
): Promise<void> { ): Promise<void> {
const video = this._media.video;
if (!source) { if (!source) {
this.video.removeAttribute('src'); video.removeAttribute('src');
this.video.load(); video.load();
this._source = undefined; this._source = undefined;
return; return;
} }
@@ -318,15 +305,13 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
} }
this._source = source as NativeVideoConfig; this._source = source as NativeVideoConfig;
this._store this._media.loadSource(source.uri);
? this._store.loadSource(source.uri)
: (this.video.src = source.uri);
if (this.mediaSession?.enabled) { if (this.mediaSession?.enabled) {
this.mediaSession.updateMediaSession(source.metadata); this.mediaSession.updateMediaSession(source.metadata);
} }
setExternalSubtitles(this.video, source.externalSubtitles); setExternalSubtitles(video, source.externalSubtitles);
if (source.initializeOnCreation) await this.preload(); if (source.initializeOnCreation) await this.preload();
} }
@@ -334,10 +319,10 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
// --- Tracks --- // --- Tracks ---
getAvailableTextTracks(): TextTrack[] { getAvailableTextTracks(): TextTrack[] {
return getTracks(this.video, 'textTracks'); return getTracks(this._media.video, 'textTracks');
} }
selectTextTrack(t: TextTrack | null): void { selectTextTrack(t: TextTrack | null): void {
selectTrack(this.video, 'textTracks', t?.id ?? null); selectTrack(this._media.video, 'textTracks', t?.id ?? null);
} }
get selectedTrack(): TextTrack | undefined { get selectedTrack(): TextTrack | undefined {
return this.getAvailableTextTracks().find((x) => x.selected); return this.getAvailableTextTracks().find((x) => x.selected);
@@ -345,20 +330,20 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
// Audio/video tracks: web-only, ~16% browser support (Safari only, flags in Chrome/Firefox) // Audio/video tracks: web-only, ~16% browser support (Safari only, flags in Chrome/Firefox)
getAvailableAudioTracks(): AudioTrack[] { getAvailableAudioTracks(): AudioTrack[] {
return getTracks(this.video, 'audioTracks'); return getTracks(this._media.video, 'audioTracks');
} }
selectAudioTrack(t: AudioTrack | null): void { selectAudioTrack(t: AudioTrack | null): void {
selectTrack(this.video, 'audioTracks', t?.id ?? null); selectTrack(this._media.video, 'audioTracks', t?.id ?? null);
} }
get selectedAudioTrack(): AudioTrack | undefined { get selectedAudioTrack(): AudioTrack | undefined {
return this.getAvailableAudioTracks().find((x) => x.selected); return this.getAvailableAudioTracks().find((x) => x.selected);
} }
getAvailableVideoTracks(): VideoTrack[] { getAvailableVideoTracks(): VideoTrack[] {
return getTracks(this.video, 'videoTracks'); return getTracks(this._media.video, 'videoTracks');
} }
selectVideoTrack(t: VideoTrack | null): void { selectVideoTrack(t: VideoTrack | null): void {
selectTrack(this.video, 'videoTracks', t?.id ?? null); selectTrack(this._media.video, 'videoTracks', t?.id ?? null);
} }
get selectedVideoTrack(): VideoTrack | undefined { get selectedVideoTrack(): VideoTrack | undefined {
return this.getAvailableVideoTracks().find((x) => x.selected); return this.getAvailableVideoTracks().find((x) => x.selected);
@@ -19,46 +19,29 @@ import type {
VideoPlayerEventEmitterBase, VideoPlayerEventEmitterBase,
} from '../types/EventEmitter'; } from '../types/EventEmitter';
import type { VideoStore } from './VideoStore'; import type { WebMediaProxy } from './WebMediaProxy';
/** /**
* WebEventEmitter bridges HTML5 media events to our event system. * WebEventEmitter bridges HTML5 media events to our event system.
* Works with or without a video.js store — video element events are the primary source. * Reads state from WebMediaProxy (store when available, video element fallback).
* When a video.js store is connected, it provides enhanced buffering info.
*/ */
export class WebEventEmitter implements VideoPlayerEventEmitterBase { export class WebEventEmitter implements VideoPlayerEventEmitterBase {
private _listeners: Map<string, Set<(...args: any[]) => void>> = new Map(); private _listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private _mediaCleanup: (() => void) | null = null; private _mediaCleanup: (() => void) | null = null;
private _storeUnsubscribe: (() => void) | null = null;
private _isBuffering = false; private _isBuffering = false;
constructor( constructor(private _media: WebMediaProxy) {
store: VideoStore | null,
private getMedia: () => HTMLVideoElement | null
) {
// Attach to video element immediately if available
this._attachMediaListeners(); this._attachMediaListeners();
if (store) this.setStore(store);
}
/**
* Connect or disconnect the video.js store (optional enhancement).
*/
setStore(_store: VideoStore | null) {
this._storeUnsubscribe?.();
this._storeUnsubscribe = null;
} }
destroy() { destroy() {
this._storeUnsubscribe?.();
this._storeUnsubscribe = null;
this._mediaCleanup?.(); this._mediaCleanup?.();
this._mediaCleanup = null; this._mediaCleanup = null;
} }
private _attachMediaListeners() { private _attachMediaListeners() {
const video = this.getMedia(); const video = this._media.video;
if (!video) return; const media = this._media;
const on = (event: string, handler: () => void) => { const on = (event: string, handler: () => void) => {
video.addEventListener(event, handler); video.addEventListener(event, handler);
@@ -70,7 +53,7 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
cleanups.push( cleanups.push(
on('play', () => { on('play', () => {
this._emit('onPlaybackStateChange', { this._emit('onPlaybackStateChange', {
isPlaying: true, isPlaying: !media.paused,
isBuffering: this._isBuffering, isBuffering: this._isBuffering,
}); });
}) })
@@ -79,7 +62,7 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
cleanups.push( cleanups.push(
on('pause', () => { on('pause', () => {
this._emit('onPlaybackStateChange', { this._emit('onPlaybackStateChange', {
isPlaying: false, isPlaying: !media.paused,
isBuffering: this._isBuffering, isBuffering: this._isBuffering,
}); });
}) })
@@ -103,22 +86,19 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
cleanups.push( cleanups.push(
on('timeupdate', () => { on('timeupdate', () => {
const buffered = video.buffered;
const lastBuffered =
buffered.length > 0 ? buffered.end(buffered.length - 1) : 0;
this._emit('onProgress', { this._emit('onProgress', {
currentTime: video.currentTime, currentTime: media.currentTime,
bufferDuration: lastBuffered, bufferDuration: media.bufferEnd,
}); });
}) })
); );
cleanups.push( cleanups.push(
on('durationchange', () => { on('durationchange', () => {
if (video.duration > 0) { if (media.duration > 0) {
this._emit('onLoad', { this._emit('onLoad', {
currentTime: video.currentTime, currentTime: media.currentTime,
duration: video.duration, duration: media.duration,
width: video.videoWidth, width: video.videoWidth,
height: video.videoHeight, height: video.videoHeight,
orientation: 'unknown', orientation: 'unknown',
@@ -136,7 +116,7 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
cleanups.push( cleanups.push(
on('ratechange', () => { on('ratechange', () => {
this._emit('onPlaybackRateChange', video.playbackRate); this._emit('onPlaybackRateChange', media.playbackRate);
}) })
); );
@@ -148,15 +128,15 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
cleanups.push( cleanups.push(
on('seeked', () => { on('seeked', () => {
this._emit('onSeek', video.currentTime); this._emit('onSeek', media.currentTime);
}) })
); );
cleanups.push( cleanups.push(
on('volumechange', () => { on('volumechange', () => {
this._emit('onVolumeChange', { this._emit('onVolumeChange', {
volume: video.volume, volume: media.volume,
muted: video.muted, muted: media.muted,
}); });
}) })
); );
@@ -172,7 +152,7 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
externalSubtitles: [], externalSubtitles: [],
}, },
getAssetInformationAsync: async () => ({ getAssetInformationAsync: async () => ({
duration: video.duration || NaN, duration: media.duration || NaN,
width: video.videoWidth, width: video.videoWidth,
height: video.videoHeight, height: video.videoHeight,
orientation: 'unknown', orientation: 'unknown',
@@ -0,0 +1,81 @@
import type { VideoStore } from './VideoStore';
/**
* Unified read/write access to video state.
* Reads from video.js store when available, falls back to HTMLVideoElement.
* Shared by VideoPlayer and WebEventEmitter.
*/
export class WebMediaProxy {
private _storeRef: WeakRef<VideoStore> | null = null;
constructor(readonly video: HTMLVideoElement) {}
get store(): VideoStore | null {
const store = this._storeRef?.deref() ?? null;
return store?.destroyed ? null : store;
}
setStore(store: VideoStore | null) {
this._storeRef = store ? new WeakRef(store) : null;
}
// --- Read (store preferred, video fallback) ---
get paused() {
return (this.store ?? this.video).paused;
}
get currentTime() {
return (this.store ?? this.video).currentTime;
}
get duration() {
return (this.store ?? this.video).duration;
}
get volume() {
return (this.store ?? this.video).volume;
}
get muted() {
return (this.store ?? this.video).muted;
}
get playbackRate() {
return (this.store ?? this.video).playbackRate;
}
get error() {
return (this.store ?? this.video).error;
}
get bufferEnd(): number {
const store = this.store;
if (store) {
const ranges = store.buffered;
return ranges.length > 0 ? ranges[ranges.length - 1]![1] : 0;
}
const ranges = this.video.buffered;
return ranges.length > 0 ? ranges.end(ranges.length - 1) : 0;
}
// --- Write (route to store or video) ---
play() {
return (this.store ?? this.video).play();
}
pause() {
(this.store ?? this.video).pause();
}
seek(time: number) {
this.store ? this.store.seek(time) : (this.video.currentTime = time);
}
setVolume(v: number) {
this.store ? this.store.setVolume(v) : (this.video.volume = v);
}
setPlaybackRate(v: number) {
this.store ? this.store.setPlaybackRate(v) : (this.video.playbackRate = v);
}
loadSource(src: string) {
this.store ? this.store.loadSource(src) : (this.video.src = src);
}
}