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