refactor: events logic

This commit is contained in:
Kamil Moskała
2026-03-26 19:52:07 +01:00
parent ef634aa624
commit b99dc68117
8 changed files with 469 additions and 403 deletions
@@ -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;
}