mirror of
https://github.com/zoriya/react-native-video.git
synced 2026-05-24 23:37:00 +00:00
fix(web): proper store attach order and video element fallback
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user