fix(web): proper store attach order and video element fallback

This commit is contained in:
Kamil Moskała
2026-03-25 15:30:55 +01:00
parent 0bdd36a0b4
commit 41c97080e3
4 changed files with 211 additions and 283 deletions
@@ -20,19 +20,23 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
private mediaSession: MediaSessionHandler | null = null;
private _source: NativeVideoConfig | undefined;
/**
* Creates a detached <video> element that works immediately.
* VideoView later mounts it into the DOM and connects the video.js store.
*/
constructor(source: VideoSource | VideoConfig | VideoPlayerSourceBase) {
if (typeof window === "undefined") {
throw new Error("[react-native-video] VideoPlayer cannot be created in SSR environment.");
}
const video = document.createElement("video");
// Emitter starts without store — will be connected via __setStore
const emitter = new WebEventEmitter(null, () => this.video);
video.playsInline = true;
const emitter = new WebEventEmitter(null, () => video);
super(emitter);
this.video = video;
// Bridge web errors to JS event system
(this.eventEmitter as WebEventEmitter).addOnErrorListener((error) => {
this.triggerJSEvent("onError", error);
});
@@ -40,11 +44,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
this.replaceSourceAsync(source);
}
/**
* Called by VideoView's StoreBridge when the v10 Provider mounts.
* Connects the v10 store to the adapter, enabling store-based features.
* @internal
*/
/** @internal */
__setStore(store: VideoStore | null) {
this._store = store;
(this.eventEmitter as WebEventEmitter).setStore(store);
@@ -63,12 +63,11 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
this.clearAllEvents();
}
/** Returns the HTMLVideoElement. @internal */
/** @internal */
__getMedia(): HTMLVideoElement {
return this.video;
}
// Source
get source(): VideoPlayerSourceBase {
return {
uri: this._source?.uri ?? "",
@@ -86,8 +85,18 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
};
}
// Status — works with or without store
// Store when available, video element fallback.
// Store may provide more accurate values for HLS/DASH/DRM content.
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.video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) return "readyToPlay";
if (this.video.readyState > HTMLMediaElement.HAVE_NOTHING) return "loading";
@@ -95,64 +104,36 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
return "idle";
}
get duration(): number {
return this.video.duration || NaN;
get duration(): number { return this._store?.duration || this.video.duration || NaN; }
get volume(): number { return this._store?.volume ?? this.video.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 volume(): number {
return this.video.volume;
get currentTime(): number { return this._store?.currentTime ?? this.video.currentTime; }
set currentTime(v: number) {
if (this._store) { this._store.seek(v); } else { this.video.currentTime = v; }
}
set volume(value: number) {
this.video.volume = Math.max(0, Math.min(1, value));
}
get currentTime(): number {
return this.video.currentTime;
}
set currentTime(value: number) {
this.video.currentTime = value;
}
get muted(): boolean {
return this.video.muted;
}
set muted(value: boolean) {
this.video.muted = value;
}
get loop(): boolean {
return this.video.loop;
}
set loop(value: boolean) {
this.video.loop = value;
}
get rate(): number {
return this.video.playbackRate;
}
set rate(value: number) {
this.video.playbackRate = value;
get muted(): boolean { return this._store?.muted ?? this.video.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; }
set rate(v: number) {
if (this._store) { this._store.setPlaybackRate(v); } else { this.video.playbackRate = v; }
}
get mixAudioMode(): MixAudioMode { return "auto"; }
set mixAudioMode(_: MixAudioMode) {}
get ignoreSilentSwitchMode(): IgnoreSilentSwitchMode { return "auto"; }
set ignoreSilentSwitchMode(_: IgnoreSilentSwitchMode) {}
get playInBackground(): boolean { return true; }
set playInBackground(_: boolean) {}
get playWhenInactive(): boolean { return true; }
set playWhenInactive(_: boolean) {}
get isPlaying(): boolean {
return !this.video.paused && !this.video.ended;
return this._store ? !this._store.paused : !this.video.paused && !this.video.ended;
}
get showNotificationControls(): boolean {
@@ -161,40 +142,30 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
set showNotificationControls(value: boolean) {
if (!this.mediaSession) return;
if (!value) {
this.mediaSession.disable();
return;
}
if (!value) { this.mediaSession.disable(); return; }
this.mediaSession.enable();
this.mediaSession.updateMediaSession(this._source?.metadata);
}
async initialize(): Promise<void> {
// noop on web
}
async preload(): Promise<void> {
this.video.load();
}
release(): void {
this.__destroy();
}
async initialize(): Promise<void> {}
async preload(): Promise<void> { this.video.load(); }
release(): void { this.__destroy(); }
play(): void {
this.video.play()?.catch(() => {});
if (this._store) { this._store.play()?.catch(() => {}); } else { this.video.play()?.catch(() => {}); }
}
pause(): void {
this.video.pause();
if (this._store) { this._store.pause(); } else { this.video.pause(); }
}
seekBy(time: number): void {
this.video.currentTime += time;
const now = this._store?.currentTime ?? this.video.currentTime;
if (this._store) { this._store.seek(now + time); } else { this.video.currentTime = now + time; }
}
seekTo(time: number): void {
this.video.currentTime = time;
if (this._store) { this._store.seek(time); } else { this.video.currentTime = time; }
}
async replaceSourceAsync(
@@ -221,17 +192,19 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
}
this._source = source as NativeVideoConfig;
this.video.src = source.uri;
if (this._store) {
this._store.loadSource(source.uri);
} else {
this.video.src = source.uri;
}
if (this.mediaSession?.enabled) {
this.mediaSession.updateMediaSession(source.metadata);
}
// Remove old subtitle tracks
const existingTracks = this.video.querySelectorAll("track");
existingTracks.forEach((t) => t.remove());
// Add external subtitles as <track> elements
for (const sub of source.externalSubtitles ?? []) {
const track = document.createElement("track");
track.kind = "subtitles";
@@ -244,19 +217,12 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
if (source.initializeOnCreation) await this.preload();
}
// Text Track Management
getAvailableTextTracks(): TextTrack[] {
const tracks = this.video.textTracks;
const result: TextTrack[] = [];
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i]!;
result.push({
id: t.id || t.label,
label: t.label,
language: t.language,
selected: t.mode === "showing",
});
result.push({ id: t.id || t.label, label: t.label, language: t.language, selected: t.mode === "showing" });
}
return result;
}
@@ -265,8 +231,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
const tracks = this.video.textTracks;
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i]!;
const id = t.id || t.label;
t.mode = id === textTrack?.id ? "showing" : "disabled";
t.mode = (t.id || t.label) === textTrack?.id ? "showing" : "disabled";
}
}
@@ -1,9 +1,9 @@
import {
forwardRef,
memo,
useCallback,
useEffect,
useImperativeHandle,
useRef,
type CSSProperties,
} from "react";
import { View } from "react-native";
@@ -14,58 +14,51 @@ import type { VideoViewProps, VideoViewRef } from "./VideoViewProps";
import { createPlayer, videoFeatures, usePlayerContext, useMediaAttach } from "@videojs/react";
import { VideoSkin } from "@videojs/react/video";
// Create Player factory once at module level (v10 pattern)
const Player = createPlayer({ features: videoFeatures });
/**
* Connects the VideoPlayer adapter's store to our adapter class
* and mounts the existing HTMLVideoElement into the v10 Provider.
* Attaches the adapter's pre-existing <video> element to the video.js store,
* then passes the ready store to the adapter.
*/
function PlayerBridge({ player }: { player: VideoPlayer }) {
const { store } = usePlayerContext();
const setMedia = useMediaAttach();
const videoRef = useRef<HTMLVideoElement | null>(null);
// Connect store to adapter
useEffect(() => {
player.__setStore(store);
return () => player.__setStore(null);
}, [store, player]);
// Mount our existing video element and register with Provider
useEffect(() => {
const video = player.__getMedia();
videoRef.current = video;
setMedia?.(video);
return () => setMedia?.(null);
}, [player, setMedia]);
const detach = store.attach({ media: video, container: null });
player.__setStore(store);
return () => {
player.__setStore(null);
detach?.();
setMedia?.(null);
};
}, [store, player, setMedia]);
return null;
}
/**
* Mounts our video element into the DOM within the v10 Container.
* Mounts the adapter's <video> element into the DOM via ref callback.
* The element is created in VideoPlayer constructor so it already has
* source and event listeners attached.
*/
function VideoElement({ player, objectFit }: { player: VideoPlayer; objectFit: string }) {
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const video = player.__getMedia();
Object.assign(video.style, {
width: "100%",
height: "100%",
objectFit,
});
video.playsInline = true;
containerRef.current?.appendChild(video);
return () => {
if (video.parentNode === containerRef.current) {
containerRef.current?.removeChild(video);
const ref = useCallback(
(container: HTMLDivElement | null) => {
if (!container) return;
const video = player.__getMedia();
Object.assign(video.style, { width: "100%", height: "100%", objectFit });
if (video.parentNode !== container) {
container.appendChild(video);
}
};
}, [player, objectFit]);
},
[player, objectFit],
);
return <div ref={containerRef} style={{ width: "100%", height: "100%" }} />;
return <div ref={ref} style={{ width: "100%", height: "100%" }} />;
}
const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
@@ -113,6 +106,8 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
[player],
);
const videoContent = <VideoElement player={player} objectFit={objectFit} />;
return (
<View {...props}>
<Player.Provider>
@@ -125,13 +120,7 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
height: "100%",
}}
>
{controls ? (
<VideoSkin>
<VideoElement player={player} objectFit={objectFit} />
</VideoSkin>
) : (
<VideoElement player={player} objectFit={objectFit} />
)}
{controls ? <VideoSkin>{videoContent}</VideoSkin> : videoContent}
</Player.Container>
</Player.Provider>
</View>
@@ -6,7 +6,7 @@ function getMediaSession(): MediaSession | undefined {
}
/**
* v10 store interface — the subset MediaSession needs.
* video.js store interface — the subset MediaSession needs.
*/
interface MediaSessionStore {
readonly paused: boolean;
@@ -23,10 +23,9 @@ import type {
} from "../types/EventEmitter";
/**
* v10 store interface — the subset we use for event bridging.
* Avoids importing v10 types directly to keep this file framework-agnostic.
* video.js store interface — optional, used for enhanced buffering info when available.
*/
interface VideoStore {
export interface VideoStore {
readonly paused: boolean;
readonly ended: boolean;
readonly waiting: boolean;
@@ -42,171 +41,159 @@ interface VideoStore {
readonly error: { code: number; message: string } | null;
readonly textTrackList: Array<{ kind: string; label: string; language: string; mode: string }>;
subscribe(callback: () => void): () => 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>;
}
export type { VideoStore };
/**
* WebEventEmitter bridges HTML5 media events to our event system.
* Works with or without a video.js store — video element events are the primary source.
* When a video.js store is connected, it provides enhanced buffering info.
*/
export class WebEventEmitter implements VideoPlayerEventEmitterBase {
private _listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
private _unsubscribe: (() => void) | null = null;
private store: VideoStore | null = null;
private _prevState = {
paused: true,
waiting: false,
ended: false,
seeking: false,
canPlay: false,
currentTime: 0,
duration: 0,
volume: 1,
muted: false,
playbackRate: 1,
source: null as string | null,
error: null as { code: number; message: string } | null,
};
private _mediaCleanup: (() => void) | null = null;
private _storeUnsubscribe: (() => void) | null = null;
private _store: VideoStore | null = null;
private _isBuffering = false;
constructor(
store: VideoStore | null,
private getMedia: () => HTMLVideoElement | null,
) {
// Attach to video element immediately if available
this._attachMediaListeners();
if (store) this.setStore(store);
}
/**
* Connect or disconnect the v10 store.
* Called by VideoPlayer.__setStore() when VideoView mounts/unmounts.
* Connect or disconnect the video.js store (optional enhancement).
*/
setStore(store: VideoStore | null) {
this._unsubscribe?.();
this._unsubscribe = null;
this.store = store;
if (store) {
this._prevState = {
paused: store.paused,
waiting: store.waiting,
ended: store.ended,
seeking: store.seeking,
canPlay: store.canPlay,
currentTime: store.currentTime,
duration: store.duration,
volume: store.volume,
muted: store.muted,
playbackRate: store.playbackRate,
source: store.source,
error: store.error,
};
this._unsubscribe = store.subscribe(() => this._onStateChange());
}
this._storeUnsubscribe?.();
this._storeUnsubscribe = null;
this._store = store;
}
destroy() {
this._unsubscribe?.();
this._unsubscribe = null;
this._storeUnsubscribe?.();
this._storeUnsubscribe = null;
this._mediaCleanup?.();
this._mediaCleanup = null;
}
private _onStateChange() {
const s = this.store;
if (!s) return;
const prev = this._prevState;
/**
* Re-attach media listeners. Called when the video element might have changed.
* @internal
*/
reattach() {
this._mediaCleanup?.();
this._attachMediaListeners();
}
// Playback state (play/pause)
if (s.paused !== prev.paused) {
private _attachMediaListeners() {
const video = this.getMedia();
if (!video) return;
const on = (event: string, handler: () => void) => {
video.addEventListener(event, handler);
return () => video.removeEventListener(event, handler);
};
const cleanups: Array<() => void> = [];
cleanups.push(on("play", () => {
this._emit("onPlaybackStateChange", {
isPlaying: !s.paused,
isBuffering: s.waiting,
isPlaying: true,
isBuffering: this._isBuffering,
});
}
}));
// Buffering
if (s.waiting !== prev.waiting) {
this._emit("onBuffer", s.waiting);
this._emit("onStatusChange", s.waiting ? "loading" : "readyToPlay");
}
cleanups.push(on("pause", () => {
this._emit("onPlaybackStateChange", {
isPlaying: false,
isBuffering: this._isBuffering,
});
}));
// Progress (currentTime changed)
if (s.currentTime !== prev.currentTime) {
const lastBuffered = s.buffered.length > 0
? s.buffered[s.buffered.length - 1]![1]
: 0;
cleanups.push(on("waiting", () => {
this._isBuffering = true;
this._emit("onBuffer", true);
this._emit("onStatusChange", "loading");
}));
cleanups.push(on("canplay", () => {
this._isBuffering = false;
this._emit("onBuffer", false);
this._emit("onStatusChange", "readyToPlay");
}));
cleanups.push(on("timeupdate", () => {
const buffered = video.buffered;
const lastBuffered = buffered.length > 0 ? buffered.end(buffered.length - 1) : 0;
this._emit("onProgress", {
currentTime: s.currentTime,
currentTime: video.currentTime,
bufferDuration: lastBuffered,
});
}
}));
// Duration changed → onLoad
if (s.duration !== prev.duration && s.duration > 0) {
const media = this.getMedia();
this._emit("onLoad", {
currentTime: s.currentTime,
duration: s.duration,
width: media?.videoWidth ?? NaN,
height: media?.videoHeight ?? NaN,
orientation: "unknown",
});
}
cleanups.push(on("durationchange", () => {
if (video.duration > 0) {
this._emit("onLoad", {
currentTime: video.currentTime,
duration: video.duration,
width: video.videoWidth,
height: video.videoHeight,
orientation: "unknown",
});
}
}));
// Can play → ready
if (s.canPlay && !prev.canPlay) {
this._emit("onStatusChange", "readyToPlay");
this._emit("onReadyToDisplay");
}
// Error
if (s.error && !prev.error) {
this._emit("onStatusChange", "error");
const codeMap: Record<number, LibraryError | PlayerError | SourceError | UnknownError> = {
1: "player/asset-not-initialized",
2: "player/network",
3: "player/invalid-source",
4: "source/unsupported-content-type",
};
this._emit(
"onError",
new VideoError(codeMap[s.error.code] ?? "unknown/unknown", s.error.message),
);
}
// Ended
if (s.ended && !prev.ended) {
cleanups.push(on("ended", () => {
this._emit("onEnd");
this._emit("onStatusChange", "idle");
}
}));
// Playback rate
if (s.playbackRate !== prev.playbackRate) {
this._emit("onPlaybackRateChange", s.playbackRate);
}
cleanups.push(on("ratechange", () => {
this._emit("onPlaybackRateChange", video.playbackRate);
}));
// Volume / muted
if (s.volume !== prev.volume || s.muted !== prev.muted) {
cleanups.push(on("loadeddata", () => {
this._emit("onReadyToDisplay");
}));
cleanups.push(on("seeked", () => {
this._emit("onSeek", video.currentTime);
}));
cleanups.push(on("volumechange", () => {
this._emit("onVolumeChange", {
volume: s.volume,
muted: s.muted,
volume: video.volume,
muted: video.muted,
});
}
}));
// Seek completed
if (!s.seeking && prev.seeking) {
this._emit("onSeek", s.currentTime);
}
// Source changed → onLoadStart
if (s.source !== prev.source && s.source) {
const media = this.getMedia();
cleanups.push(on("loadstart", () => {
this._emit("onLoadStart", {
sourceType: "network",
source: {
uri: s.source,
uri: video.currentSrc || video.src,
config: {
uri: s.source,
uri: video.currentSrc || video.src,
externalSubtitles: [],
},
getAssetInformationAsync: async () => ({
duration: s.duration,
width: media?.videoWidth ?? NaN,
height: media?.videoHeight ?? NaN,
duration: video.duration || NaN,
width: video.videoWidth,
height: video.videoHeight,
orientation: "unknown",
bitrate: NaN,
fileSize: -1n,
@@ -215,41 +202,28 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
}),
},
});
}
}));
// Text track changes
const currentTrack = s.textTrackList.find(
(t) => t.mode === "showing" && (t.kind === "subtitles" || t.kind === "captions"),
);
// Simple comparison — emit on every state change that includes textTrackList
// This is acceptable since _emit only notifies if listeners exist
if (currentTrack) {
this._emit("onTrackChange", {
id: currentTrack.label,
label: currentTrack.label,
language: currentTrack.language,
selected: true,
});
}
cleanups.push(on("error", () => {
this._emit("onStatusChange", "error");
const err = video.error;
if (!err) {
console.error("Unknown error occurred in player");
return;
}
const codeMap: Record<number, LibraryError | PlayerError | SourceError | UnknownError> = {
1: "player/asset-not-initialized",
2: "player/network",
3: "player/invalid-source",
4: "source/unsupported-content-type",
};
this._emit("onError", new VideoError(codeMap[err.code] ?? "unknown/unknown", err.message));
}));
// Update prev state
this._prevState = {
paused: s.paused,
waiting: s.waiting,
ended: s.ended,
seeking: s.seeking,
canPlay: s.canPlay,
currentTime: s.currentTime,
duration: s.duration,
volume: s.volume,
muted: s.muted,
playbackRate: s.playbackRate,
source: s.source,
error: s.error,
};
this._mediaCleanup = () => { cleanups.forEach((fn) => fn()); };
}
// --- Listener infrastructure (unchanged) ---
// --- Listener infrastructure ---
private _addListener(
event: string,