mirror of
https://github.com/zoriya/react-native-video.git
synced 2026-05-24 15:29:48 +00:00
refactor: events logic
This commit is contained in:
@@ -104,13 +104,11 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
|
||||
video.playsInline = true;
|
||||
|
||||
const media = new WebMediaProxy(video);
|
||||
super(new WebEventEmitter(media));
|
||||
const emitter = new WebEventEmitter(media);
|
||||
// WebEventEmitter uses generic dispatch, cast to satisfy base class
|
||||
super(emitter as any);
|
||||
this._media = media;
|
||||
|
||||
(this.eventEmitter as WebEventEmitter).addOnErrorListener((error) => {
|
||||
this.triggerJSEvent('onError', error);
|
||||
});
|
||||
|
||||
this.replaceSourceAsync(source);
|
||||
}
|
||||
|
||||
@@ -135,6 +133,11 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
|
||||
this._media.setStore(null);
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
__getEmitter(): WebEventEmitter {
|
||||
return this.eventEmitter as WebEventEmitter;
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
__getMedia(): HTMLVideoElement {
|
||||
return this._media.video;
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import type { AllPlayerEvents as PlayerEvents } from '../types/Events';
|
||||
import type {
|
||||
JSVideoPlayerEvents,
|
||||
AllPlayerEvents as PlayerEvents,
|
||||
} from '../types/Events';
|
||||
import type { ListenerSubscription } from '../types/EventEmitter';
|
||||
import { VideoPlayerEventsBase } from './VideoPlayerEventsBase';
|
||||
|
||||
@@ -8,7 +11,68 @@ export class VideoPlayerEvents extends VideoPlayerEventsBase {
|
||||
callback: PlayerEvents[Event]
|
||||
): ListenerSubscription {
|
||||
switch (event) {
|
||||
// ----------------- Native-only Events -----------------
|
||||
// --- JS-only events ---
|
||||
case 'onError':
|
||||
this.jsEventListeners.onError ??= new Set();
|
||||
this.jsEventListeners.onError.add(
|
||||
callback as JSVideoPlayerEvents['onError']
|
||||
);
|
||||
return {
|
||||
remove: () =>
|
||||
this.jsEventListeners.onError?.delete(
|
||||
callback as JSVideoPlayerEvents['onError']
|
||||
),
|
||||
};
|
||||
// --- Shared events ---
|
||||
case 'onBuffer':
|
||||
return this.eventEmitter.addOnBufferListener(
|
||||
callback as PlayerEvents['onBuffer']
|
||||
);
|
||||
case 'onEnd':
|
||||
return this.eventEmitter.addOnEndListener(
|
||||
callback as PlayerEvents['onEnd']
|
||||
);
|
||||
case 'onLoad':
|
||||
return this.eventEmitter.addOnLoadListener(
|
||||
callback as PlayerEvents['onLoad']
|
||||
);
|
||||
case 'onLoadStart':
|
||||
return this.eventEmitter.addOnLoadStartListener(
|
||||
callback as PlayerEvents['onLoadStart']
|
||||
);
|
||||
case 'onPlaybackStateChange':
|
||||
return this.eventEmitter.addOnPlaybackStateChangeListener(
|
||||
callback as PlayerEvents['onPlaybackStateChange']
|
||||
);
|
||||
case 'onPlaybackRateChange':
|
||||
return this.eventEmitter.addOnPlaybackRateChangeListener(
|
||||
callback as PlayerEvents['onPlaybackRateChange']
|
||||
);
|
||||
case 'onProgress':
|
||||
return this.eventEmitter.addOnProgressListener(
|
||||
callback as PlayerEvents['onProgress']
|
||||
);
|
||||
case 'onReadyToDisplay':
|
||||
return this.eventEmitter.addOnReadyToDisplayListener(
|
||||
callback as PlayerEvents['onReadyToDisplay']
|
||||
);
|
||||
case 'onSeek':
|
||||
return this.eventEmitter.addOnSeekListener(
|
||||
callback as PlayerEvents['onSeek']
|
||||
);
|
||||
case 'onTrackChange':
|
||||
return this.eventEmitter.addOnTrackChangeListener(
|
||||
callback as PlayerEvents['onTrackChange']
|
||||
);
|
||||
case 'onVolumeChange':
|
||||
return this.eventEmitter.addOnVolumeChangeListener(
|
||||
callback as PlayerEvents['onVolumeChange']
|
||||
);
|
||||
case 'onStatusChange':
|
||||
return this.eventEmitter.addOnStatusChangeListener(
|
||||
callback as PlayerEvents['onStatusChange']
|
||||
);
|
||||
// --- Native-only events ---
|
||||
case 'onAudioBecomingNoisy':
|
||||
return this.eventEmitter.addOnAudioBecomingNoisyListener(
|
||||
callback as PlayerEvents['onAudioBecomingNoisy']
|
||||
@@ -38,7 +102,7 @@ export class VideoPlayerEvents extends VideoPlayerEventsBase {
|
||||
callback as PlayerEvents['onTextTrackDataChanged']
|
||||
);
|
||||
default:
|
||||
return super.addEventListener(event, callback);
|
||||
throw new Error(`[React Native Video] Unsupported event: ${event}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
import type { AllPlayerEvents as PlayerEvents } from '../types/Events';
|
||||
import type { ListenerSubscription } from '../types/EventEmitter';
|
||||
import { VideoPlayerEventsBase } from './VideoPlayerEventsBase';
|
||||
import type { WebEventEmitter } from '../web/WebEventEmitter';
|
||||
|
||||
/**
|
||||
* Web event dispatch — all events (including onError) go through
|
||||
* WebEventEmitter.addListener(). No switch, no special cases.
|
||||
*
|
||||
* This file must exist so the bundler doesn't fall back to
|
||||
* VideoPlayerEvents.ts which re-exports the native version.
|
||||
*/
|
||||
export class VideoPlayerEvents extends VideoPlayerEventsBase {
|
||||
addEventListener<Event extends keyof PlayerEvents>(
|
||||
event: Event,
|
||||
callback: PlayerEvents[Event]
|
||||
): ListenerSubscription {
|
||||
switch (event) {
|
||||
// Web-only events will be added here
|
||||
default:
|
||||
return super.addEventListener(event, callback);
|
||||
}
|
||||
return (this.eventEmitter as unknown as WebEventEmitter).addListener(
|
||||
event,
|
||||
callback as (...args: any[]) => void
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,74 +31,12 @@ export class VideoPlayerEventsBase {
|
||||
}
|
||||
|
||||
addEventListener<Event extends keyof PlayerEvents>(
|
||||
event: Event,
|
||||
callback: PlayerEvents[Event]
|
||||
_event: Event,
|
||||
_callback: PlayerEvents[Event]
|
||||
): ListenerSubscription {
|
||||
switch (event) {
|
||||
// ----------------- JS Events -----------------
|
||||
case 'onError':
|
||||
this.jsEventListeners.onError ??= new Set();
|
||||
this.jsEventListeners.onError.add(
|
||||
callback as JSVideoPlayerEvents['onError']
|
||||
);
|
||||
return {
|
||||
remove: () =>
|
||||
this.jsEventListeners.onError?.delete(
|
||||
callback as JSVideoPlayerEvents['onError']
|
||||
),
|
||||
};
|
||||
// ----------------- Shared Events -----------------
|
||||
case 'onBuffer':
|
||||
return this.eventEmitter.addOnBufferListener(
|
||||
callback as PlayerEvents['onBuffer']
|
||||
);
|
||||
case 'onEnd':
|
||||
return this.eventEmitter.addOnEndListener(
|
||||
callback as PlayerEvents['onEnd']
|
||||
);
|
||||
case 'onLoad':
|
||||
return this.eventEmitter.addOnLoadListener(
|
||||
callback as PlayerEvents['onLoad']
|
||||
);
|
||||
case 'onLoadStart':
|
||||
return this.eventEmitter.addOnLoadStartListener(
|
||||
callback as PlayerEvents['onLoadStart']
|
||||
);
|
||||
case 'onPlaybackStateChange':
|
||||
return this.eventEmitter.addOnPlaybackStateChangeListener(
|
||||
callback as PlayerEvents['onPlaybackStateChange']
|
||||
);
|
||||
case 'onPlaybackRateChange':
|
||||
return this.eventEmitter.addOnPlaybackRateChangeListener(
|
||||
callback as PlayerEvents['onPlaybackRateChange']
|
||||
);
|
||||
case 'onProgress':
|
||||
return this.eventEmitter.addOnProgressListener(
|
||||
callback as PlayerEvents['onProgress']
|
||||
);
|
||||
case 'onReadyToDisplay':
|
||||
return this.eventEmitter.addOnReadyToDisplayListener(
|
||||
callback as PlayerEvents['onReadyToDisplay']
|
||||
);
|
||||
case 'onSeek':
|
||||
return this.eventEmitter.addOnSeekListener(
|
||||
callback as PlayerEvents['onSeek']
|
||||
);
|
||||
case 'onTrackChange':
|
||||
return this.eventEmitter.addOnTrackChangeListener(
|
||||
callback as PlayerEvents['onTrackChange']
|
||||
);
|
||||
case 'onVolumeChange':
|
||||
return this.eventEmitter.addOnVolumeChangeListener(
|
||||
callback as PlayerEvents['onVolumeChange']
|
||||
);
|
||||
case 'onStatusChange':
|
||||
return this.eventEmitter.addOnStatusChangeListener(
|
||||
callback as PlayerEvents['onStatusChange']
|
||||
);
|
||||
default:
|
||||
throw new Error(`[React Native Video] Unsupported event: ${event}`);
|
||||
}
|
||||
throw new Error(
|
||||
'[React Native Video] addEventListener must be implemented by platform'
|
||||
);
|
||||
}
|
||||
|
||||
clearAllEvents() {
|
||||
|
||||
@@ -21,13 +21,17 @@ import {
|
||||
import { VideoSkin } from '@videojs/react/video';
|
||||
import '@videojs/react/video/skin.css';
|
||||
import type { VideoStore } from '../web/VideoStore';
|
||||
import { useViewEvents, addViewEventListener } from './useViewEvents.web';
|
||||
|
||||
const Player = createPlayer({ features: videoFeatures });
|
||||
|
||||
/**
|
||||
* Attaches the adapter's pre-existing <video> element to the video.js store,
|
||||
* then passes the ready store to the adapter.
|
||||
*/
|
||||
const OBJECT_FIT_MAP: Record<string, CSSProperties['objectFit']> = {
|
||||
contain: 'contain',
|
||||
cover: 'cover',
|
||||
stretch: 'fill',
|
||||
none: 'contain',
|
||||
};
|
||||
|
||||
function PlayerBridge({ player }: { player: VideoPlayer }) {
|
||||
const { store: rawStore, container } = usePlayerContext();
|
||||
const store = rawStore as unknown as VideoStore;
|
||||
@@ -55,11 +59,6 @@ function PlayerBridge({ player }: { player: VideoPlayer }) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@@ -88,7 +87,12 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
|
||||
player: nPlayer,
|
||||
controls = false,
|
||||
resizeMode = 'none',
|
||||
// Destructured to exclude from ...props (not used on web)
|
||||
onFullscreenChange,
|
||||
onPictureInPictureChange,
|
||||
willEnterFullscreen,
|
||||
willExitFullscreen,
|
||||
willEnterPictureInPicture,
|
||||
willExitPictureInPicture,
|
||||
pictureInPicture: _pip = false,
|
||||
autoEnterPictureInPicture: _autoPip = false,
|
||||
keepScreenAwake: _keepAwake = true,
|
||||
@@ -98,14 +102,16 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
|
||||
) => {
|
||||
const player = nPlayer as unknown as VideoPlayer;
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const objectFit = OBJECT_FIT_MAP[resizeMode] ?? 'contain';
|
||||
|
||||
const objectFitMap: Record<string, CSSProperties['objectFit']> = {
|
||||
contain: 'contain',
|
||||
cover: 'cover',
|
||||
stretch: 'fill',
|
||||
none: 'contain',
|
||||
};
|
||||
const objectFit = objectFitMap[resizeMode] ?? 'contain';
|
||||
useViewEvents(player, containerRef, {
|
||||
onFullscreenChange,
|
||||
onPictureInPictureChange,
|
||||
willEnterFullscreen,
|
||||
willExitFullscreen,
|
||||
willEnterPictureInPicture,
|
||||
willExitPictureInPicture,
|
||||
});
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
@@ -125,11 +131,10 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
|
||||
canEnterPictureInPicture: () =>
|
||||
document.pictureInPictureEnabled ?? false,
|
||||
addEventListener: <Event extends keyof VideoViewEvents>(
|
||||
_event: Event,
|
||||
_callback: VideoViewEvents[Event]
|
||||
): ListenerSubscription => {
|
||||
return { remove: () => {} };
|
||||
},
|
||||
event: Event,
|
||||
callback: VideoViewEvents[Event]
|
||||
): ListenerSubscription =>
|
||||
addViewEventListener(player, event, callback),
|
||||
}),
|
||||
[player]
|
||||
);
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useEffect } from 'react';
|
||||
import type { VideoPlayer } from '../VideoPlayer.web';
|
||||
import type { VideoViewEvents } from '../types/Events';
|
||||
import type { ListenerSubscription } from '../types/EventEmitter';
|
||||
import type { WebEventEmitter } from '../web/WebEventEmitter';
|
||||
|
||||
function bindViewDOMEvents(
|
||||
emitter: WebEventEmitter,
|
||||
video: HTMLVideoElement,
|
||||
container: HTMLElement | null
|
||||
): () => void {
|
||||
const cleanups: Array<() => void> = [];
|
||||
|
||||
const on = (target: EventTarget, event: string, handler: () => void) => {
|
||||
target.addEventListener(event, handler);
|
||||
cleanups.push(() => target.removeEventListener(event, handler));
|
||||
};
|
||||
|
||||
if (container) {
|
||||
on(container, 'fullscreenchange', () => {
|
||||
const el = document.fullscreenElement;
|
||||
const isFullscreen = el === container || container.contains(el);
|
||||
emitter.__emit(
|
||||
isFullscreen ? 'willEnterFullscreen' : 'willExitFullscreen'
|
||||
);
|
||||
emitter.__emit('onFullscreenChange', isFullscreen);
|
||||
});
|
||||
}
|
||||
|
||||
on(video, 'enterpictureinpicture', () => {
|
||||
emitter.__emit('willEnterPictureInPicture');
|
||||
emitter.__emit('onPictureInPictureChange', true);
|
||||
});
|
||||
|
||||
on(video, 'leavepictureinpicture', () => {
|
||||
emitter.__emit('willExitPictureInPicture');
|
||||
emitter.__emit('onPictureInPictureChange', false);
|
||||
});
|
||||
|
||||
return () => cleanups.forEach((fn) => fn());
|
||||
}
|
||||
|
||||
export function useViewEvents(
|
||||
player: VideoPlayer,
|
||||
containerRef: React.RefObject<HTMLDivElement | null>,
|
||||
props: Partial<VideoViewEvents>
|
||||
) {
|
||||
const emitter = player.__getEmitter();
|
||||
|
||||
useEffect(() => {
|
||||
const unbindDOM = bindViewDOMEvents(
|
||||
emitter,
|
||||
player.__getMedia(),
|
||||
containerRef.current
|
||||
);
|
||||
|
||||
const subs: ListenerSubscription[] = [];
|
||||
for (const [event, callback] of Object.entries(props)) {
|
||||
if (callback) {
|
||||
subs.push(emitter.__addListener(event, callback));
|
||||
}
|
||||
}
|
||||
|
||||
return () => {
|
||||
unbindDOM();
|
||||
subs.forEach((sub) => sub.remove());
|
||||
};
|
||||
}, [
|
||||
player,
|
||||
emitter,
|
||||
containerRef,
|
||||
props.onFullscreenChange,
|
||||
props.onPictureInPictureChange,
|
||||
props.willEnterFullscreen,
|
||||
props.willExitFullscreen,
|
||||
props.willEnterPictureInPicture,
|
||||
props.willExitPictureInPicture,
|
||||
]);
|
||||
}
|
||||
|
||||
export function addViewEventListener<Event extends keyof VideoViewEvents>(
|
||||
player: VideoPlayer,
|
||||
event: Event,
|
||||
callback: VideoViewEvents[Event]
|
||||
): ListenerSubscription {
|
||||
return player.__getEmitter().__addListener(event, callback);
|
||||
}
|
||||
@@ -1,200 +1,30 @@
|
||||
import type {
|
||||
BandwidthData,
|
||||
onLoadData,
|
||||
onLoadStartData,
|
||||
onPlaybackStateChangeData,
|
||||
onProgressData,
|
||||
onVolumeChangeData,
|
||||
TimedMetadata,
|
||||
} from '../types/Events';
|
||||
import type { TextTrack } from '../types/TextTrack';
|
||||
import {
|
||||
type WebError,
|
||||
VideoError,
|
||||
type VideoRuntimeError,
|
||||
} from '../types/VideoError';
|
||||
import type { VideoPlayerStatus } from '../types/VideoPlayerStatus';
|
||||
import type {
|
||||
ListenerSubscription,
|
||||
VideoPlayerEventEmitterBase,
|
||||
} from '../types/EventEmitter';
|
||||
|
||||
import type { ListenerSubscription } from '../types/EventEmitter';
|
||||
import type { WebMediaProxy } from './WebMediaProxy';
|
||||
import {
|
||||
attachPlaybackListeners,
|
||||
attachMediaInfoListeners,
|
||||
attachTrackListeners,
|
||||
} from './webDOMEvents';
|
||||
|
||||
/**
|
||||
* WebEventEmitter bridges HTML5 media events to our event system.
|
||||
* Reads state from WebMediaProxy (store when available, video element fallback).
|
||||
* Generic string-based event emitter for web.
|
||||
* DOM event bridging is handled by webDOMEvents.ts.
|
||||
*/
|
||||
export class WebEventEmitter implements VideoPlayerEventEmitterBase {
|
||||
export class WebEventEmitter {
|
||||
private _listeners: Map<string, Set<(...args: any[]) => void>> = new Map();
|
||||
private _mediaCleanup: (() => void) | null = null;
|
||||
private _cleanup: (() => void) | null = null;
|
||||
private _isBuffering = false;
|
||||
|
||||
constructor(private _media: WebMediaProxy) {
|
||||
this._attachMediaListeners();
|
||||
this._bindDOMEvents();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this._mediaCleanup?.();
|
||||
this._mediaCleanup = null;
|
||||
this._cleanup?.();
|
||||
this._cleanup = null;
|
||||
}
|
||||
|
||||
private _attachMediaListeners() {
|
||||
const video = this._media.video;
|
||||
const media = this._media;
|
||||
|
||||
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: !media.paused,
|
||||
isBuffering: this._isBuffering,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('pause', () => {
|
||||
this._emit('onPlaybackStateChange', {
|
||||
isPlaying: !media.paused,
|
||||
isBuffering: this._isBuffering,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
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', () => {
|
||||
this._emit('onProgress', {
|
||||
currentTime: media.currentTime,
|
||||
bufferDuration: media.bufferAhead,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('durationchange', () => {
|
||||
if (media.duration > 0) {
|
||||
this._emit('onLoad', {
|
||||
currentTime: media.currentTime,
|
||||
duration: media.duration,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
orientation: 'unknown',
|
||||
});
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('ended', () => {
|
||||
this._emit('onEnd');
|
||||
this._emit('onStatusChange', 'idle');
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('ratechange', () => {
|
||||
this._emit('onPlaybackRateChange', media.playbackRate);
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('loadeddata', () => {
|
||||
this._emit('onReadyToDisplay');
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('seeked', () => {
|
||||
this._emit('onSeek', media.currentTime);
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('volumechange', () => {
|
||||
this._emit('onVolumeChange', {
|
||||
volume: media.volume,
|
||||
muted: media.muted,
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
cleanups.push(
|
||||
on('loadstart', () => {
|
||||
this._emit('onLoadStart', {
|
||||
sourceType: 'network',
|
||||
source: {
|
||||
uri: video.currentSrc || video.src,
|
||||
config: {
|
||||
uri: video.currentSrc || video.src,
|
||||
externalSubtitles: [],
|
||||
},
|
||||
getAssetInformationAsync: async () => ({
|
||||
duration: media.duration || NaN,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
orientation: 'unknown',
|
||||
bitrate: NaN,
|
||||
fileSize: -1n,
|
||||
isHDR: false,
|
||||
isLive: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
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, WebError> = {
|
||||
1: 'web/aborted',
|
||||
2: 'web/network',
|
||||
3: 'web/decode',
|
||||
4: 'web/unsupported-source',
|
||||
};
|
||||
this._emit(
|
||||
'onError',
|
||||
new VideoError(codeMap[err.code] ?? 'unknown/unknown', err.message)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
this._mediaCleanup = () => {
|
||||
cleanups.forEach((fn) => fn());
|
||||
};
|
||||
}
|
||||
|
||||
// --- Listener infrastructure ---
|
||||
|
||||
private _addListener(
|
||||
addListener(
|
||||
event: string,
|
||||
listener: (...args: any[]) => void
|
||||
): ListenerSubscription {
|
||||
@@ -209,127 +39,46 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
|
||||
};
|
||||
}
|
||||
|
||||
private _emit(event: string, ...args: any[]) {
|
||||
this._listeners.get(event)?.forEach((fn) => fn(...args));
|
||||
}
|
||||
|
||||
// --- Listener registration (implements VideoPlayerEventEmitterBase) ---
|
||||
|
||||
addOnAudioBecomingNoisyListener(listener: () => void): ListenerSubscription {
|
||||
return this._addListener('onAudioBecomingNoisy', listener);
|
||||
}
|
||||
|
||||
addOnAudioFocusChangeListener(
|
||||
listener: (hasAudioFocus: boolean) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onAudioFocusChange', listener);
|
||||
}
|
||||
|
||||
addOnBandwidthUpdateListener(
|
||||
listener: (data: BandwidthData) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onBandwidthUpdate', listener);
|
||||
}
|
||||
|
||||
addOnBufferListener(
|
||||
listener: (buffering: boolean) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onBuffer', listener);
|
||||
}
|
||||
|
||||
addOnControlsVisibleChangeListener(
|
||||
listener: (visible: boolean) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onControlsVisibleChange', listener);
|
||||
}
|
||||
|
||||
addOnEndListener(listener: () => void): ListenerSubscription {
|
||||
return this._addListener('onEnd', listener);
|
||||
}
|
||||
|
||||
addOnExternalPlaybackChangeListener(
|
||||
listener: (externalPlaybackActive: boolean) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onExternalPlaybackChange', listener);
|
||||
}
|
||||
|
||||
addOnLoadListener(
|
||||
listener: (data: onLoadData) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onLoad', listener);
|
||||
}
|
||||
|
||||
addOnLoadStartListener(
|
||||
listener: (data: onLoadStartData) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onLoadStart', listener);
|
||||
}
|
||||
|
||||
addOnPlaybackStateChangeListener(
|
||||
listener: (data: onPlaybackStateChangeData) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onPlaybackStateChange', listener);
|
||||
}
|
||||
|
||||
addOnPlaybackRateChangeListener(
|
||||
listener: (rate: number) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onPlaybackRateChange', listener);
|
||||
}
|
||||
|
||||
addOnProgressListener(
|
||||
listener: (data: onProgressData) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onProgress', listener);
|
||||
}
|
||||
|
||||
addOnReadyToDisplayListener(listener: () => void): ListenerSubscription {
|
||||
return this._addListener('onReadyToDisplay', listener);
|
||||
}
|
||||
|
||||
addOnSeekListener(
|
||||
listener: (position: number) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onSeek', listener);
|
||||
}
|
||||
|
||||
addOnStatusChangeListener(
|
||||
listener: (status: VideoPlayerStatus) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onStatusChange', listener);
|
||||
}
|
||||
|
||||
addOnTimedMetadataListener(
|
||||
listener: (data: TimedMetadata) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onTimedMetadata', listener);
|
||||
}
|
||||
|
||||
addOnTextTrackDataChangedListener(
|
||||
listener: (data: string[]) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onTextTrackDataChanged', listener);
|
||||
}
|
||||
|
||||
addOnTrackChangeListener(
|
||||
listener: (track: TextTrack | null) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onTrackChange', listener);
|
||||
}
|
||||
|
||||
addOnVolumeChangeListener(
|
||||
listener: (data: onVolumeChangeData) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onVolumeChange', listener);
|
||||
}
|
||||
|
||||
clearAllListeners(): void {
|
||||
this._listeners.clear();
|
||||
}
|
||||
|
||||
addOnErrorListener(
|
||||
listener: (error: VideoRuntimeError) => void
|
||||
/** @internal Used by VideoView for view-level events. */
|
||||
__emit(event: string, ...args: any[]) {
|
||||
this._emit(event, ...args);
|
||||
}
|
||||
|
||||
/** @internal Used by VideoView for view-level events. */
|
||||
__addListener(
|
||||
event: string,
|
||||
listener: (...args: any[]) => void
|
||||
): ListenerSubscription {
|
||||
return this._addListener('onError', listener);
|
||||
return this.addListener(event, listener);
|
||||
}
|
||||
|
||||
private _emit(event: string, ...args: any[]) {
|
||||
this._listeners.get(event)?.forEach((fn) => fn(...args));
|
||||
}
|
||||
|
||||
private _bindDOMEvents() {
|
||||
const video = this._media.video;
|
||||
const media = this._media;
|
||||
const emit = this._emit.bind(this);
|
||||
|
||||
const cleanups = [
|
||||
...attachPlaybackListeners(
|
||||
video,
|
||||
media,
|
||||
emit,
|
||||
() => this._isBuffering,
|
||||
(v) => {
|
||||
this._isBuffering = v;
|
||||
}
|
||||
),
|
||||
...attachMediaInfoListeners(video, media, emit),
|
||||
...attachTrackListeners(video, emit),
|
||||
];
|
||||
|
||||
this._cleanup = () => cleanups.forEach((fn) => fn());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { TextTrack } from '../types/TextTrack';
|
||||
import { type WebError, VideoError } from '../types/VideoError';
|
||||
import type { WebMediaProxy } from './WebMediaProxy';
|
||||
|
||||
type Emit = (event: string, ...args: any[]) => void;
|
||||
type Cleanup = () => void;
|
||||
|
||||
function on(target: EventTarget, event: string, handler: () => void): Cleanup {
|
||||
target.addEventListener(event, handler);
|
||||
return () => target.removeEventListener(event, handler);
|
||||
}
|
||||
|
||||
// --- Playback: play/pause, buffering, progress, end, rate, seek ---
|
||||
|
||||
export function attachPlaybackListeners(
|
||||
video: HTMLVideoElement,
|
||||
media: WebMediaProxy,
|
||||
emit: Emit,
|
||||
getIsBuffering: () => boolean,
|
||||
setIsBuffering: (v: boolean) => void
|
||||
): Cleanup[] {
|
||||
return [
|
||||
on(video, 'play', () => {
|
||||
emit('onPlaybackStateChange', {
|
||||
isPlaying: !media.paused,
|
||||
isBuffering: getIsBuffering(),
|
||||
});
|
||||
}),
|
||||
on(video, 'pause', () => {
|
||||
emit('onPlaybackStateChange', {
|
||||
isPlaying: !media.paused,
|
||||
isBuffering: getIsBuffering(),
|
||||
});
|
||||
}),
|
||||
on(video, 'waiting', () => {
|
||||
setIsBuffering(true);
|
||||
emit('onBuffer', true);
|
||||
emit('onStatusChange', 'loading');
|
||||
}),
|
||||
on(video, 'canplay', () => {
|
||||
setIsBuffering(false);
|
||||
emit('onBuffer', false);
|
||||
emit('onStatusChange', 'readyToPlay');
|
||||
}),
|
||||
on(video, 'timeupdate', () => {
|
||||
emit('onProgress', {
|
||||
currentTime: media.currentTime,
|
||||
bufferDuration: media.bufferAhead,
|
||||
});
|
||||
}),
|
||||
on(video, 'ended', () => {
|
||||
emit('onEnd');
|
||||
emit('onStatusChange', 'idle');
|
||||
}),
|
||||
// Read directly from video element — store may not have synced yet
|
||||
// when the DOM event fires, causing a stale (previous) value.
|
||||
on(video, 'ratechange', () => {
|
||||
emit('onPlaybackRateChange', video.playbackRate);
|
||||
}),
|
||||
on(video, 'seeked', () => {
|
||||
emit('onSeek', media.currentTime);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// --- Media info: load, loadstart, ready, volume, error ---
|
||||
|
||||
export function attachMediaInfoListeners(
|
||||
video: HTMLVideoElement,
|
||||
media: WebMediaProxy,
|
||||
emit: Emit
|
||||
): Cleanup[] {
|
||||
return [
|
||||
on(video, 'durationchange', () => {
|
||||
if (media.duration > 0) {
|
||||
emit('onLoad', {
|
||||
currentTime: media.currentTime,
|
||||
duration: media.duration,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
orientation: 'unknown',
|
||||
});
|
||||
}
|
||||
}),
|
||||
on(video, 'loadstart', () => {
|
||||
emit('onLoadStart', {
|
||||
sourceType: 'network',
|
||||
source: {
|
||||
uri: video.currentSrc || video.src,
|
||||
config: {
|
||||
uri: video.currentSrc || video.src,
|
||||
externalSubtitles: [],
|
||||
},
|
||||
getAssetInformationAsync: async () => ({
|
||||
duration: media.duration || NaN,
|
||||
width: video.videoWidth,
|
||||
height: video.videoHeight,
|
||||
orientation: 'unknown',
|
||||
bitrate: NaN,
|
||||
fileSize: -1n,
|
||||
isHDR: false,
|
||||
isLive: false,
|
||||
}),
|
||||
},
|
||||
});
|
||||
}),
|
||||
on(video, 'loadeddata', () => {
|
||||
emit('onReadyToDisplay');
|
||||
}),
|
||||
on(video, 'volumechange', () => {
|
||||
emit('onVolumeChange', {
|
||||
volume: media.volume,
|
||||
muted: media.muted,
|
||||
});
|
||||
}),
|
||||
on(video, 'error', () => {
|
||||
emit('onStatusChange', 'error');
|
||||
const err = video.error;
|
||||
if (!err) {
|
||||
console.error('Unknown error occurred in player');
|
||||
return;
|
||||
}
|
||||
const codeMap: Record<number, WebError> = {
|
||||
1: 'web/aborted',
|
||||
2: 'web/network',
|
||||
3: 'web/decode',
|
||||
4: 'web/unsupported-source',
|
||||
};
|
||||
emit(
|
||||
'onError',
|
||||
new VideoError(codeMap[err.code] ?? 'unknown/unknown', err.message)
|
||||
);
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
// --- Text tracks: selection, cue changes, timed metadata ---
|
||||
|
||||
export function attachTrackListeners(
|
||||
video: HTMLVideoElement,
|
||||
emit: Emit
|
||||
): Cleanup[] {
|
||||
const cleanups: Cleanup[] = [];
|
||||
const textTracks = video.textTracks;
|
||||
|
||||
const onTrackChange = () => {
|
||||
let selected: TextTrack | null = null;
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const t = textTracks[i]!;
|
||||
if (t.mode === 'showing') {
|
||||
selected = {
|
||||
id: t.id || t.label,
|
||||
label: t.label,
|
||||
language: t.language,
|
||||
selected: true,
|
||||
};
|
||||
break;
|
||||
}
|
||||
}
|
||||
emit('onTrackChange', selected);
|
||||
};
|
||||
textTracks.addEventListener('change', onTrackChange);
|
||||
cleanups.push(() => textTracks.removeEventListener('change', onTrackChange));
|
||||
|
||||
const cueChangeHandlers = new Map<globalThis.TextTrack, () => void>();
|
||||
|
||||
const attachCueListeners = () => {
|
||||
for (let i = 0; i < textTracks.length; i++) {
|
||||
const track = textTracks[i]!;
|
||||
if (cueChangeHandlers.has(track)) continue;
|
||||
|
||||
const handler = () => {
|
||||
const cues = track.activeCues;
|
||||
if (!cues) return;
|
||||
|
||||
if (track.kind === 'metadata') {
|
||||
const metadata = [];
|
||||
for (let j = 0; j < cues.length; j++) {
|
||||
const cue = cues[j] as VTTCue;
|
||||
metadata.push({
|
||||
value: cue.text ?? '',
|
||||
identifier: cue.id ?? '',
|
||||
});
|
||||
}
|
||||
if (metadata.length > 0) {
|
||||
emit('onTimedMetadata', { metadata });
|
||||
}
|
||||
} else {
|
||||
const texts = [];
|
||||
for (let j = 0; j < cues.length; j++) {
|
||||
texts.push((cues[j] as VTTCue).text ?? '');
|
||||
}
|
||||
emit('onTextTrackDataChanged', texts);
|
||||
}
|
||||
};
|
||||
|
||||
track.addEventListener('cuechange', handler);
|
||||
cueChangeHandlers.set(track, handler);
|
||||
}
|
||||
};
|
||||
|
||||
attachCueListeners();
|
||||
textTracks.addEventListener('addtrack', attachCueListeners);
|
||||
cleanups.push(() => {
|
||||
textTracks.removeEventListener('addtrack', attachCueListeners);
|
||||
for (const [track, handler] of cueChangeHandlers) {
|
||||
track.removeEventListener('cuechange', handler);
|
||||
}
|
||||
cueChangeHandlers.clear();
|
||||
});
|
||||
|
||||
return cleanups;
|
||||
}
|
||||
Reference in New Issue
Block a user