mirror of
https://github.com/zoriya/react-native-video.git
synced 2026-06-06 03:56:53 +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 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,
|
||||||
|
|||||||
Reference in New Issue
Block a user