fix: lint errors, add missing VideoPlayerBase members, fix eslint for web files

This commit is contained in:
Kamil Moskała
2026-03-26 15:11:29 +01:00
parent 138d5f7856
commit b9dd036670
17 changed files with 504 additions and 323 deletions

View File

@@ -3,6 +3,6 @@ module.exports = {
extends: ["../../config/.eslintrc.js"],
parserOptions: {
tsconfigRootDir: __dirname,
project: true,
project: ['./tsconfig.json', './tsconfig.web.json'],
},
};

View File

@@ -326,7 +326,6 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
get selectedTrack(): TextTrack | undefined {
return this.player.selectedTrack;
}
}
export { VideoPlayer };

View File

@@ -1,38 +1,38 @@
import type { AudioTrack } from "./types/AudioTrack";
import type { IgnoreSilentSwitchMode } from "./types/IgnoreSilentSwitchMode";
import type { MixAudioMode } from "./types/MixAudioMode";
import type { TextTrack } from "./types/TextTrack";
import type { VideoTrack } from "./types/VideoTrack";
import type { NoAutocomplete } from "./types/Utils";
import type { AudioTrack } from './types/AudioTrack';
import type { IgnoreSilentSwitchMode } from './types/IgnoreSilentSwitchMode';
import type { MixAudioMode } from './types/MixAudioMode';
import type { TextTrack } from './types/TextTrack';
import type { VideoTrack } from './types/VideoTrack';
import type { NoAutocomplete } from './types/Utils';
import type {
NativeVideoConfig,
VideoConfig,
VideoSource,
} from "./types/VideoConfig";
import type { WebVideoPlayer } from "./types/WebVideoPlayer";
import type { VideoPlayerSourceBase } from "./types/VideoPlayerSourceBase";
import type { VideoPlayerStatus } from "./types/VideoPlayerStatus";
import { VideoPlayerEvents } from "./events/VideoPlayerEvents";
import { MediaSessionHandler } from "./web/MediaSession";
import { WebEventEmitter } from "./web/WebEventEmitter";
import type { VideoStore } from "./web/VideoStore";
} from './types/VideoConfig';
import type { WebVideoPlayer } from './types/WebVideoPlayer';
import type { VideoPlayerSourceBase } from './types/VideoPlayerSourceBase';
import type { VideoPlayerStatus } from './types/VideoPlayerStatus';
import { VideoPlayerEvents } from './events/VideoPlayerEvents';
import { MediaSessionHandler } from './web/MediaSession';
import { WebEventEmitter } from './web/WebEventEmitter';
import type { VideoStore } from './web/VideoStore';
function setExternalSubtitles(
video: HTMLVideoElement,
subtitles: NativeVideoConfig["externalSubtitles"],
subtitles: NativeVideoConfig['externalSubtitles']
) {
video.querySelectorAll("track").forEach((t) => t.remove());
video.querySelectorAll('track').forEach((t) => t.remove());
for (const sub of subtitles ?? []) {
const track = document.createElement("track");
track.kind = "subtitles";
const track = document.createElement('track');
track.kind = 'subtitles';
track.src = sub.uri;
track.srclang = sub.language ?? "und";
track.srclang = sub.language ?? 'und';
track.label = sub.label;
video.appendChild(track);
}
}
type TrackType = "textTracks" | "audioTracks" | "videoTracks";
type TrackType = 'textTracks' | 'audioTracks' | 'videoTracks';
/**
* Reads tracks from HTMLVideoElement.
@@ -41,17 +41,25 @@ type TrackType = "textTracks" | "audioTracks" | "videoTracks";
*/
function getTracks(
video: HTMLVideoElement,
prop: TrackType,
prop: TrackType
): Array<{ id: string; label: string; language?: string; selected: boolean }> {
const tracks = (video as any)[prop];
if (!tracks) return [];
const result = [];
for (let i = 0; i < tracks.length; i++) {
const t = tracks[i]!;
const selected = prop === "textTracks"
? t.mode === "showing"
: prop === "audioTracks" ? t.enabled : t.selected;
result.push({ id: t.id || t.label, label: t.label, language: t.language, selected });
const selected =
prop === 'textTracks'
? t.mode === 'showing'
: prop === 'audioTracks'
? t.enabled
: t.selected;
result.push({
id: t.id || t.label,
label: t.label,
language: t.language,
selected,
});
}
return result;
}
@@ -59,15 +67,15 @@ function getTracks(
function selectTrack(
video: HTMLVideoElement,
prop: TrackType,
trackId: string | null,
trackId: string | null
): void {
const tracks = (video as any)[prop];
if (!tracks) return;
for (let i = 0; i < tracks.length; i++) {
const id = tracks[i]!.id || tracks[i]!.label;
if (prop === "textTracks") {
tracks[i]!.mode = id === trackId ? "showing" : "disabled";
} else if (prop === "audioTracks") {
if (prop === 'textTracks') {
tracks[i]!.mode = id === trackId ? 'showing' : 'disabled';
} else if (prop === 'audioTracks') {
tracks[i]!.enabled = id === trackId;
} else {
tracks[i]!.selected = id === trackId;
@@ -97,18 +105,20 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
* 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.");
if (typeof window === 'undefined') {
throw new Error(
'[react-native-video] VideoPlayer cannot be created in SSR environment.'
);
}
const video = document.createElement("video");
const video = document.createElement('video');
video.playsInline = true;
super(new WebEventEmitter(null, () => video));
this.video = video;
(this.eventEmitter as WebEventEmitter).addOnErrorListener((error) => {
this.triggerJSEvent("onError", error);
this.triggerJSEvent('onError', error);
});
this.replaceSourceAsync(source);
@@ -145,8 +155,8 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
get source(): VideoPlayerSourceBase {
return {
uri: this._source?.uri ?? "",
config: this._source ?? { uri: "" },
uri: this._source?.uri ?? '',
config: this._source ?? { uri: '' },
getAssetInformationAsync: async () => ({
bitrate: NaN,
width: this.video.videoWidth,
@@ -155,32 +165,49 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
fileSize: -1n,
isHDR: false,
isLive: false,
orientation: "landscape" as const,
orientation: 'landscape' as const,
}),
};
}
get status(): VideoPlayerStatus {
if (this.media.error) return "error";
if (this.video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA) return "readyToPlay";
if (this.video.readyState > HTMLMediaElement.HAVE_NOTHING) return "loading";
if (this.video.src) return "loading";
return "idle";
if (this.media.error) return 'error';
if (this.video.readyState >= HTMLMediaElement.HAVE_FUTURE_DATA)
return 'readyToPlay';
if (this.video.readyState > HTMLMediaElement.HAVE_NOTHING) return 'loading';
if (this.video.src) return 'loading';
return 'idle';
}
get duration(): number { return this.media.duration || NaN; }
get currentTime(): number { return this.media.currentTime; }
get volume(): number { return this.media.volume; }
get muted(): boolean { return this.media.muted; }
get loop(): boolean { return this.video.loop; }
get rate(): number { return this.media.playbackRate; }
get isPlaying(): boolean { return !this.media.paused; }
get duration(): number {
return this.media.duration || NaN;
}
get currentTime(): number {
return this.media.currentTime;
}
get volume(): number {
return this.media.volume;
}
get muted(): boolean {
return this.media.muted;
}
get loop(): boolean {
return this.video.loop;
}
get rate(): number {
return this.media.playbackRate;
}
get isPlaying(): boolean {
return !this.media.paused;
}
// --- Playback state (write through store or video element) ---
set volume(v: number) {
const clamped = Math.max(0, Math.min(1, v));
this._store ? this._store.setVolume(clamped) : (this.video.volume = clamped);
this._store
? this._store.setVolume(clamped)
: (this.video.volume = clamped);
}
set currentTime(v: number) {
@@ -188,22 +215,36 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
}
// video.js store has toggleMuted() but no direct setter
set muted(v: boolean) { this.video.muted = v; }
set loop(v: boolean) { this.video.loop = v; }
set muted(v: boolean) {
this.video.muted = v;
}
set loop(v: boolean) {
this.video.loop = v;
}
set rate(v: number) {
this._store ? this._store.setPlaybackRate(v) : (this.video.playbackRate = v);
this._store
? this._store.setPlaybackRate(v)
: (this.video.playbackRate = v);
}
// --- Unsupported on web (no-op) ---
get mixAudioMode(): MixAudioMode { return "auto"; }
get mixAudioMode(): MixAudioMode {
return 'auto';
}
set mixAudioMode(_: MixAudioMode) {}
get ignoreSilentSwitchMode(): IgnoreSilentSwitchMode { return "auto"; }
get ignoreSilentSwitchMode(): IgnoreSilentSwitchMode {
return 'auto';
}
set ignoreSilentSwitchMode(_: IgnoreSilentSwitchMode) {}
get playInBackground(): boolean { return true; }
get playInBackground(): boolean {
return true;
}
set playInBackground(_: boolean) {}
get playWhenInactive(): boolean { return true; }
get playWhenInactive(): boolean {
return true;
}
set playWhenInactive(_: boolean) {}
// --- Media Session ---
@@ -214,7 +255,10 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
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);
}
@@ -224,13 +268,19 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
async initialize(): Promise<void> {}
async preload(): Promise<void> {
this.video.preload = "auto";
this.video.preload = 'auto';
this.video.load();
}
release(): void { this.__destroy(); }
play(): void { this.media.play()?.catch(() => {}); }
pause(): void { this.media.pause(); }
release(): void {
this.__destroy();
}
play(): void {
this.media.play()?.catch(() => {});
}
pause(): void {
this.media.pause();
}
seekTo(time: number): void {
this._store ? this._store.seek(time) : (this.video.currentTime = time);
@@ -247,26 +297,30 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
| VideoSource
| VideoConfig
| NoAutocomplete<VideoPlayerSourceBase>
| null,
| null
): Promise<void> {
if (!source) {
this.video.removeAttribute("src");
this.video.removeAttribute('src');
this.video.load();
this._source = undefined;
return;
}
if (typeof source === "string") {
if (typeof source === 'string') {
source = { uri: source };
}
if (typeof source === "number" || typeof source.uri === "number") {
console.error("A source uri must be a string. Numbers are only supported on native.");
if (typeof source === 'number' || typeof source.uri === 'number') {
console.error(
'A source uri must be a string. Numbers are only supported on native.'
);
return;
}
this._source = source as NativeVideoConfig;
this._store ? this._store.loadSource(source.uri) : (this.video.src = source.uri);
this._store
? this._store.loadSource(source.uri)
: (this.video.src = source.uri);
if (this.mediaSession?.enabled) {
this.mediaSession.updateMediaSession(source.metadata);
@@ -279,18 +333,36 @@ class VideoPlayer extends VideoPlayerEvents implements WebVideoPlayer {
// --- Tracks ---
getAvailableTextTracks(): TextTrack[] { return getTracks(this.video, "textTracks"); }
selectTextTrack(t: TextTrack | null): void { selectTrack(this.video, "textTracks", t?.id ?? null); }
get selectedTrack(): TextTrack | undefined { return this.getAvailableTextTracks().find((x) => x.selected); }
getAvailableTextTracks(): TextTrack[] {
return getTracks(this.video, 'textTracks');
}
selectTextTrack(t: TextTrack | null): void {
selectTrack(this.video, 'textTracks', t?.id ?? null);
}
get selectedTrack(): TextTrack | undefined {
return this.getAvailableTextTracks().find((x) => x.selected);
}
// Audio/video tracks: web-only, ~16% browser support (Safari only, flags in Chrome/Firefox)
getAvailableAudioTracks(): AudioTrack[] { return getTracks(this.video, "audioTracks"); }
selectAudioTrack(t: AudioTrack | null): void { selectTrack(this.video, "audioTracks", t?.id ?? null); }
get selectedAudioTrack(): AudioTrack | undefined { return this.getAvailableAudioTracks().find((x) => x.selected); }
getAvailableAudioTracks(): AudioTrack[] {
return getTracks(this.video, 'audioTracks');
}
selectAudioTrack(t: AudioTrack | null): void {
selectTrack(this.video, 'audioTracks', t?.id ?? null);
}
get selectedAudioTrack(): AudioTrack | undefined {
return this.getAvailableAudioTracks().find((x) => x.selected);
}
getAvailableVideoTracks(): VideoTrack[] { return getTracks(this.video, "videoTracks"); }
selectVideoTrack(t: VideoTrack | null): void { selectTrack(this.video, "videoTracks", t?.id ?? null); }
get selectedVideoTrack(): VideoTrack | undefined { return this.getAvailableVideoTracks().find((x) => x.selected); }
getAvailableVideoTracks(): VideoTrack[] {
return getTracks(this.video, 'videoTracks');
}
selectVideoTrack(t: VideoTrack | null): void {
selectTrack(this.video, 'videoTracks', t?.id ?? null);
}
get selectedVideoTrack(): VideoTrack | undefined {
return this.getAvailableVideoTracks().find((x) => x.selected);
}
}
export { VideoPlayer };

View File

@@ -5,7 +5,7 @@ import { VideoPlayerEventsBase } from './VideoPlayerEventsBase';
export class VideoPlayerEvents extends VideoPlayerEventsBase {
addEventListener<Event extends keyof PlayerEvents>(
event: Event,
callback: PlayerEvents[Event],
callback: PlayerEvents[Event]
): ListenerSubscription {
switch (event) {
// ----------------- Native-only Events -----------------

View File

@@ -1,11 +1,11 @@
import type { AllPlayerEvents as PlayerEvents } from "../types/Events";
import type { ListenerSubscription } from "../types/EventEmitter";
import { VideoPlayerEventsBase } from "./VideoPlayerEventsBase";
import type { AllPlayerEvents as PlayerEvents } from '../types/Events';
import type { ListenerSubscription } from '../types/EventEmitter';
import { VideoPlayerEventsBase } from './VideoPlayerEventsBase';
export class VideoPlayerEvents extends VideoPlayerEventsBase {
addEventListener<Event extends keyof PlayerEvents>(
event: Event,
callback: PlayerEvents[Event],
callback: PlayerEvents[Event]
): ListenerSubscription {
switch (event) {
// Web-only events will be added here

View File

@@ -24,50 +24,50 @@ export interface ListenerSubscription {
export interface VideoPlayerEventEmitterBase {
addOnAudioBecomingNoisyListener(listener: () => void): ListenerSubscription;
addOnAudioFocusChangeListener(
listener: (hasAudioFocus: boolean) => void,
listener: (hasAudioFocus: boolean) => void
): ListenerSubscription;
addOnBandwidthUpdateListener(
listener: (data: BandwidthData) => void,
listener: (data: BandwidthData) => void
): ListenerSubscription;
addOnBufferListener(
listener: (buffering: boolean) => void,
listener: (buffering: boolean) => void
): ListenerSubscription;
addOnControlsVisibleChangeListener(
listener: (visible: boolean) => void,
listener: (visible: boolean) => void
): ListenerSubscription;
addOnEndListener(listener: () => void): ListenerSubscription;
addOnExternalPlaybackChangeListener(
listener: (externalPlaybackActive: boolean) => void,
listener: (externalPlaybackActive: boolean) => void
): ListenerSubscription;
addOnLoadListener(listener: (data: onLoadData) => void): ListenerSubscription;
addOnLoadStartListener(
listener: (data: onLoadStartData) => void,
listener: (data: onLoadStartData) => void
): ListenerSubscription;
addOnPlaybackStateChangeListener(
listener: (data: onPlaybackStateChangeData) => void,
listener: (data: onPlaybackStateChangeData) => void
): ListenerSubscription;
addOnPlaybackRateChangeListener(
listener: (rate: number) => void,
listener: (rate: number) => void
): ListenerSubscription;
addOnProgressListener(
listener: (data: onProgressData) => void,
listener: (data: onProgressData) => void
): ListenerSubscription;
addOnReadyToDisplayListener(listener: () => void): ListenerSubscription;
addOnSeekListener(listener: (position: number) => void): ListenerSubscription;
addOnStatusChangeListener(
listener: (status: VideoPlayerStatus) => void,
listener: (status: VideoPlayerStatus) => void
): ListenerSubscription;
addOnTimedMetadataListener(
listener: (data: TimedMetadata) => void,
listener: (data: TimedMetadata) => void
): ListenerSubscription;
addOnTextTrackDataChangedListener(
listener: (data: string[]) => void,
listener: (data: string[]) => void
): ListenerSubscription;
addOnTrackChangeListener(
listener: (track: TextTrack | null) => void,
listener: (track: TextTrack | null) => void
): ListenerSubscription;
addOnVolumeChangeListener(
listener: (data: onVolumeChangeData) => void,
listener: (data: onVolumeChangeData) => void
): ListenerSubscription;
clearAllListeners(): void;
}

View File

@@ -8,4 +8,3 @@ export const isVideoPlayerSource = (obj: any): obj is VideoPlayerSource => {
obj.name === 'VideoPlayerSource' // obj.name is 'VideoPlayerSource'
);
};

View File

@@ -1,5 +1,4 @@
import * as React from 'react';
import type { ViewStyle } from 'react-native';
import { NitroModules } from 'react-native-nitro-modules';
import type { ListenerSubscription } from '../../spec/nitro/VideoPlayerEventEmitter.nitro';
import type {

View File

@@ -6,16 +6,21 @@ import {
useImperativeHandle,
useRef,
type CSSProperties,
} from "react";
import { View } from "react-native";
import type { VideoPlayer } from "../VideoPlayer.web";
import type { VideoViewEvents } from "../types/Events";
import type { ListenerSubscription } from "../types/EventEmitter";
import type { VideoViewProps, VideoViewRef } from "./VideoViewProps";
import { createPlayer, videoFeatures, usePlayerContext, useMediaAttach } from "@videojs/react";
import { VideoSkin } from "@videojs/react/video";
import "@videojs/react/video/skin.css";
import type { VideoStore } from "../web/VideoStore";
} from 'react';
import { View } from 'react-native';
import type { VideoPlayer } from '../VideoPlayer.web';
import type { VideoViewEvents } from '../types/Events';
import type { ListenerSubscription } from '../types/EventEmitter';
import type { VideoViewProps, VideoViewRef } from './VideoViewProps';
import {
createPlayer,
videoFeatures,
usePlayerContext,
useMediaAttach,
} from '@videojs/react';
import { VideoSkin } from '@videojs/react/video';
import '@videojs/react/video/skin.css';
import type { VideoStore } from '../web/VideoStore';
const Player = createPlayer({ features: videoFeatures });
@@ -38,7 +43,11 @@ function PlayerBridge({ player }: { player: VideoPlayer }) {
return () => {
player.__setStore(null);
try { detach?.(); } catch { /* store may already be destroyed by Provider */ }
try {
detach?.();
} catch {
/* store may already be destroyed by Provider */
}
setMedia?.(null);
};
}, [store, player, setMedia, container]);
@@ -51,20 +60,26 @@ function PlayerBridge({ player }: { player: VideoPlayer }) {
* 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 mountRef = useCallback(
(container: HTMLDivElement | null) => {
if (!container) return;
const video = player.__getMedia();
Object.assign(video.style, { width: "100%", height: "100%", objectFit });
Object.assign(video.style, { width: '100%', height: '100%', objectFit });
if (video.parentNode !== container) {
container.appendChild(video);
}
},
[player, objectFit],
[player, objectFit]
);
return <div ref={mountRef} style={{ width: "100%", height: "100%" }} />;
return <div ref={mountRef} style={{ width: '100%', height: '100%' }} />;
}
const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
@@ -72,24 +87,25 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
{
player: nPlayer,
controls = false,
resizeMode = "none",
pictureInPicture = false,
autoEnterPictureInPicture = false,
keepScreenAwake = true,
resizeMode = 'none',
// Destructured to exclude from ...props (not used on web)
pictureInPicture: _pip = false,
autoEnterPictureInPicture: _autoPip = false,
keepScreenAwake: _keepAwake = true,
...props
},
ref,
ref
) => {
const player = nPlayer as unknown as VideoPlayer;
const containerRef = useRef<HTMLDivElement>(null);
const objectFitMap: Record<string, CSSProperties["objectFit"]> = {
contain: "contain",
cover: "cover",
stretch: "fill",
none: "contain",
const objectFitMap: Record<string, CSSProperties['objectFit']> = {
contain: 'contain',
cover: 'cover',
stretch: 'fill',
none: 'contain',
};
const objectFit = objectFitMap[resizeMode] ?? "contain";
const objectFit = objectFitMap[resizeMode] ?? 'contain';
useImperativeHandle(
ref,
@@ -110,12 +126,12 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
document.pictureInPictureEnabled ?? false,
addEventListener: <Event extends keyof VideoViewEvents>(
_event: Event,
_callback: VideoViewEvents[Event],
_callback: VideoViewEvents[Event]
): ListenerSubscription => {
return { remove: () => {} };
},
}),
[player],
[player]
);
const videoContent = <VideoElement player={player} objectFit={objectFit} />;
@@ -126,22 +142,24 @@ const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
<PlayerBridge player={player} />
<Player.Container
ref={containerRef}
style={{
position: "absolute",
inset: "0",
width: "100%",
height: "100%",
"--media-border-radius": "0",
} as CSSProperties}
style={
{
'position': 'absolute',
'inset': '0',
'width': '100%',
'height': '100%',
'--media-border-radius': '0',
} as CSSProperties
}
>
{controls ? <VideoSkin>{videoContent}</VideoSkin> : videoContent}
</Player.Container>
</Player.Provider>
</View>
);
},
}
);
VideoView.displayName = "VideoView";
VideoView.displayName = 'VideoView';
export default memo(VideoView);

View File

@@ -1,8 +1,8 @@
import type { CustomVideoMetadata } from "../types/VideoConfig";
import type { MediaSessionStore } from "./VideoStore";
import type { CustomVideoMetadata } from '../types/VideoConfig';
import type { MediaSessionStore } from './VideoStore';
function getMediaSession(): MediaSession | undefined {
if (typeof window === "undefined") return undefined;
if (typeof window === 'undefined') return undefined;
return window.navigator?.mediaSession;
}
@@ -20,25 +20,55 @@ export class MediaSessionHandler {
const defaultSkipTime = 15;
const actionHandlers: Array<[MediaSessionAction, MediaSessionActionHandler]> = [
["play", () => { this.store.play(); }],
["pause", () => { this.store.pause(); }],
["stop", () => {
this.store.pause();
this.store.seek(0);
}],
["seekbackward", (details) => {
const offset = (details as MediaSessionActionDetails).seekOffset || defaultSkipTime;
this.store.seek(Math.max(0, this.store.currentTime - offset));
}],
["seekforward", (details) => {
const offset = (details as MediaSessionActionDetails).seekOffset || defaultSkipTime;
this.store.seek(Math.min(this.store.duration, this.store.currentTime + offset));
}],
["seekto", (details) => {
const seekTime = (details as MediaSessionActionDetails).seekTime;
if (seekTime != null) this.store.seek(seekTime);
}],
const actionHandlers: Array<
[MediaSessionAction, MediaSessionActionHandler]
> = [
[
'play',
() => {
this.store.play();
},
],
[
'pause',
() => {
this.store.pause();
},
],
[
'stop',
() => {
this.store.pause();
this.store.seek(0);
},
],
[
'seekbackward',
(details) => {
const offset =
(details as MediaSessionActionDetails).seekOffset ||
defaultSkipTime;
this.store.seek(Math.max(0, this.store.currentTime - offset));
},
],
[
'seekforward',
(details) => {
const offset =
(details as MediaSessionActionDetails).seekOffset ||
defaultSkipTime;
this.store.seek(
Math.min(this.store.duration, this.store.currentTime + offset)
);
},
],
[
'seekto',
(details) => {
const seekTime = (details as MediaSessionActionDetails).seekTime;
if (seekTime != null) this.store.seek(seekTime);
},
],
];
for (const [action, handler] of actionHandlers) {
@@ -51,9 +81,12 @@ export class MediaSessionHandler {
// Subscribe to store for playback state and position updates
const unsubscribe = this.store.subscribe(() => {
mediaSession.playbackState = this.store.paused ? "paused" : "playing";
mediaSession.playbackState = this.store.paused ? 'paused' : 'playing';
if ("setPositionState" in mediaSession && Number.isFinite(this.store.duration)) {
if (
'setPositionState' in mediaSession &&
Number.isFinite(this.store.duration)
) {
try {
mediaSession.setPositionState({
duration: this.store.duration,

View File

@@ -19,7 +19,12 @@ export interface VideoStore {
readonly source: string | null;
readonly buffered: [number, number][];
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;
}>;
readonly destroyed: boolean;
readonly target: unknown;
readonly pipAvailability: string;
@@ -38,7 +43,10 @@ export interface VideoStore {
// Lifecycle
subscribe(callback: () => void): () => void;
attach(target: { media: HTMLVideoElement; container: HTMLElement | null }): () => void;
attach(target: {
media: HTMLVideoElement;
container: HTMLElement | null;
}): () => void;
destroy(): void;
}
@@ -47,5 +55,12 @@ export interface VideoStore {
*/
export type MediaSessionStore = Pick<
VideoStore,
"paused" | "currentTime" | "duration" | "playbackRate" | "play" | "pause" | "seek" | "subscribe"
| 'paused'
| 'currentTime'
| 'duration'
| 'playbackRate'
| 'play'
| 'pause'
| 'seek'
| 'subscribe'
>;

View File

@@ -6,8 +6,8 @@ import type {
onProgressData,
onVolumeChangeData,
TimedMetadata,
} from "../types/Events";
import type { TextTrack } from "../types/TextTrack";
} from '../types/Events';
import type { TextTrack } from '../types/TextTrack';
import {
type LibraryError,
type PlayerError,
@@ -15,14 +15,14 @@ import {
type UnknownError,
VideoError,
type VideoRuntimeError,
} from "../types/VideoError";
import type { VideoPlayerStatus } from "../types/VideoPlayerStatus";
} from '../types/VideoError';
import type { VideoPlayerStatus } from '../types/VideoPlayerStatus';
import type {
ListenerSubscription,
VideoPlayerEventEmitterBase,
} from "../types/EventEmitter";
} from '../types/EventEmitter';
import type { VideoStore } from "./VideoStore";
import type { VideoStore } from './VideoStore';
/**
* WebEventEmitter bridges HTML5 media events to our event system.
@@ -38,7 +38,7 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
constructor(
store: VideoStore | null,
private getMedia: () => HTMLVideoElement | null,
private getMedia: () => HTMLVideoElement | null
) {
// Attach to video element immediately if available
this._attachMediaListeners();
@@ -72,124 +72,159 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
const cleanups: Array<() => void> = [];
cleanups.push(on("play", () => {
this._emit("onPlaybackStateChange", {
isPlaying: true,
isBuffering: this._isBuffering,
});
}));
cleanups.push(on("pause", () => {
this._emit("onPlaybackStateChange", {
isPlaying: false,
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", () => {
const buffered = video.buffered;
const lastBuffered = buffered.length > 0 ? buffered.end(buffered.length - 1) : 0;
this._emit("onProgress", {
currentTime: video.currentTime,
bufferDuration: lastBuffered,
});
}));
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",
cleanups.push(
on('play', () => {
this._emit('onPlaybackStateChange', {
isPlaying: true,
isBuffering: this._isBuffering,
});
}
}));
})
);
cleanups.push(on("ended", () => {
this._emit("onEnd");
this._emit("onStatusChange", "idle");
}));
cleanups.push(
on('pause', () => {
this._emit('onPlaybackStateChange', {
isPlaying: false,
isBuffering: this._isBuffering,
});
})
);
cleanups.push(on("ratechange", () => {
this._emit("onPlaybackRateChange", video.playbackRate);
}));
cleanups.push(
on('waiting', () => {
this._isBuffering = true;
this._emit('onBuffer', true);
this._emit('onStatusChange', 'loading');
})
);
cleanups.push(on("loadeddata", () => {
this._emit("onReadyToDisplay");
}));
cleanups.push(
on('canplay', () => {
this._isBuffering = false;
this._emit('onBuffer', false);
this._emit('onStatusChange', 'readyToPlay');
})
);
cleanups.push(on("seeked", () => {
this._emit("onSeek", video.currentTime);
}));
cleanups.push(
on('timeupdate', () => {
const buffered = video.buffered;
const lastBuffered =
buffered.length > 0 ? buffered.end(buffered.length - 1) : 0;
this._emit('onProgress', {
currentTime: video.currentTime,
bufferDuration: lastBuffered,
});
})
);
cleanups.push(on("volumechange", () => {
this._emit("onVolumeChange", {
volume: video.volume,
muted: video.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: video.duration || NaN,
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",
bitrate: NaN,
fileSize: -1n,
isHDR: false,
isLive: false,
}),
},
});
}));
orientation: 'unknown',
});
}
})
);
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/not-initialized",
3: "player/invalid-source",
4: "source/unsupported-content-type",
};
this._emit("onError", new VideoError(codeMap[err.code] ?? "unknown/unknown", err.message));
}));
cleanups.push(
on('ended', () => {
this._emit('onEnd');
this._emit('onStatusChange', 'idle');
})
);
this._mediaCleanup = () => { cleanups.forEach((fn) => fn()); };
cleanups.push(
on('ratechange', () => {
this._emit('onPlaybackRateChange', video.playbackRate);
})
);
cleanups.push(
on('loadeddata', () => {
this._emit('onReadyToDisplay');
})
);
cleanups.push(
on('seeked', () => {
this._emit('onSeek', video.currentTime);
})
);
cleanups.push(
on('volumechange', () => {
this._emit('onVolumeChange', {
volume: video.volume,
muted: video.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: video.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,
LibraryError | PlayerError | SourceError | UnknownError
> = {
1: 'player/asset-not-initialized',
2: 'player/not-initialized',
3: 'player/invalid-source',
4: 'source/unsupported-content-type',
};
this._emit(
'onError',
new VideoError(codeMap[err.code] ?? 'unknown/unknown', err.message)
);
})
);
this._mediaCleanup = () => {
cleanups.forEach((fn) => fn());
};
}
// --- Listener infrastructure ---
private _addListener(
event: string,
listener: (...args: any[]) => void,
listener: (...args: any[]) => void
): ListenerSubscription {
if (!this._listeners.has(event)) {
this._listeners.set(event, new Set());
@@ -209,111 +244,111 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
// --- Listener registration (implements VideoPlayerEventEmitterBase) ---
addOnAudioBecomingNoisyListener(listener: () => void): ListenerSubscription {
return this._addListener("onAudioBecomingNoisy", listener);
return this._addListener('onAudioBecomingNoisy', listener);
}
addOnAudioFocusChangeListener(
listener: (hasAudioFocus: boolean) => void,
listener: (hasAudioFocus: boolean) => void
): ListenerSubscription {
return this._addListener("onAudioFocusChange", listener);
return this._addListener('onAudioFocusChange', listener);
}
addOnBandwidthUpdateListener(
listener: (data: BandwidthData) => void,
listener: (data: BandwidthData) => void
): ListenerSubscription {
return this._addListener("onBandwidthUpdate", listener);
return this._addListener('onBandwidthUpdate', listener);
}
addOnBufferListener(
listener: (buffering: boolean) => void,
listener: (buffering: boolean) => void
): ListenerSubscription {
return this._addListener("onBuffer", listener);
return this._addListener('onBuffer', listener);
}
addOnControlsVisibleChangeListener(
listener: (visible: boolean) => void,
listener: (visible: boolean) => void
): ListenerSubscription {
return this._addListener("onControlsVisibleChange", listener);
return this._addListener('onControlsVisibleChange', listener);
}
addOnEndListener(listener: () => void): ListenerSubscription {
return this._addListener("onEnd", listener);
return this._addListener('onEnd', listener);
}
addOnExternalPlaybackChangeListener(
listener: (externalPlaybackActive: boolean) => void,
listener: (externalPlaybackActive: boolean) => void
): ListenerSubscription {
return this._addListener("onExternalPlaybackChange", listener);
return this._addListener('onExternalPlaybackChange', listener);
}
addOnLoadListener(
listener: (data: onLoadData) => void,
listener: (data: onLoadData) => void
): ListenerSubscription {
return this._addListener("onLoad", listener);
return this._addListener('onLoad', listener);
}
addOnLoadStartListener(
listener: (data: onLoadStartData) => void,
listener: (data: onLoadStartData) => void
): ListenerSubscription {
return this._addListener("onLoadStart", listener);
return this._addListener('onLoadStart', listener);
}
addOnPlaybackStateChangeListener(
listener: (data: onPlaybackStateChangeData) => void,
listener: (data: onPlaybackStateChangeData) => void
): ListenerSubscription {
return this._addListener("onPlaybackStateChange", listener);
return this._addListener('onPlaybackStateChange', listener);
}
addOnPlaybackRateChangeListener(
listener: (rate: number) => void,
listener: (rate: number) => void
): ListenerSubscription {
return this._addListener("onPlaybackRateChange", listener);
return this._addListener('onPlaybackRateChange', listener);
}
addOnProgressListener(
listener: (data: onProgressData) => void,
listener: (data: onProgressData) => void
): ListenerSubscription {
return this._addListener("onProgress", listener);
return this._addListener('onProgress', listener);
}
addOnReadyToDisplayListener(listener: () => void): ListenerSubscription {
return this._addListener("onReadyToDisplay", listener);
return this._addListener('onReadyToDisplay', listener);
}
addOnSeekListener(
listener: (position: number) => void,
listener: (position: number) => void
): ListenerSubscription {
return this._addListener("onSeek", listener);
return this._addListener('onSeek', listener);
}
addOnStatusChangeListener(
listener: (status: VideoPlayerStatus) => void,
listener: (status: VideoPlayerStatus) => void
): ListenerSubscription {
return this._addListener("onStatusChange", listener);
return this._addListener('onStatusChange', listener);
}
addOnTimedMetadataListener(
listener: (data: TimedMetadata) => void,
listener: (data: TimedMetadata) => void
): ListenerSubscription {
return this._addListener("onTimedMetadata", listener);
return this._addListener('onTimedMetadata', listener);
}
addOnTextTrackDataChangedListener(
listener: (data: string[]) => void,
listener: (data: string[]) => void
): ListenerSubscription {
return this._addListener("onTextTrackDataChanged", listener);
return this._addListener('onTextTrackDataChanged', listener);
}
addOnTrackChangeListener(
listener: (track: TextTrack | null) => void,
listener: (track: TextTrack | null) => void
): ListenerSubscription {
return this._addListener("onTrackChange", listener);
return this._addListener('onTrackChange', listener);
}
addOnVolumeChangeListener(
listener: (data: onVolumeChangeData) => void,
listener: (data: onVolumeChangeData) => void
): ListenerSubscription {
return this._addListener("onVolumeChange", listener);
return this._addListener('onVolumeChange', listener);
}
clearAllListeners(): void {
@@ -321,8 +356,8 @@ export class WebEventEmitter implements VideoPlayerEventEmitterBase {
}
addOnErrorListener(
listener: (error: VideoRuntimeError) => void,
listener: (error: VideoRuntimeError) => void
): ListenerSubscription {
return this._addListener("onError", listener);
return this._addListener('onError', listener);
}
}

View File

@@ -5,8 +5,7 @@ import type { VideoPlayerEventEmitter } from './VideoPlayerEventEmitter.nitro';
import type { VideoPlayerSource } from './VideoPlayerSource.nitro';
export interface VideoPlayer
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }>,
VideoPlayerBase {
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }>, VideoPlayerBase {
// Override with (hybrid) VideoPlayerSource
readonly source: VideoPlayerSource;
@@ -49,7 +48,9 @@ export interface VideoPlayer
release(): void;
}
export interface VideoPlayerFactory
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
export interface VideoPlayerFactory extends HybridObject<{
ios: 'swift';
android: 'kotlin';
}> {
createPlayer(source: VideoPlayerSource): VideoPlayer;
}

View File

@@ -18,8 +18,10 @@ export interface ListenerSubscription {
remove(): void;
}
export interface VideoPlayerEventEmitter
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
export interface VideoPlayerEventEmitter extends HybridObject<{
ios: 'swift';
android: 'kotlin';
}> {
/**
* Adds a listener for the `onAudioBecomingNoisy` event.
* @see {@link VideoPlayerEvents.onAudioBecomingNoisy}

View File

@@ -8,11 +8,14 @@ import type { VideoPlayerSourceBase } from '../../core/types/VideoPlayerSourceBa
* It provides functions to get information about the asset.
*/
export interface VideoPlayerSource
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }>,
extends
HybridObject<{ ios: 'swift'; android: 'kotlin' }>,
VideoPlayerSourceBase {}
export interface VideoPlayerSourceFactory
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
export interface VideoPlayerSourceFactory extends HybridObject<{
ios: 'swift';
android: 'kotlin';
}> {
fromUri(uri: string): VideoPlayerSource;
fromVideoConfig(config: NativeVideoConfig): VideoPlayerSource;
}

View File

@@ -6,8 +6,10 @@ import type { ListenerSubscription } from './VideoPlayerEventEmitter.nitro';
export type SurfaceType = 'surface' | 'texture';
// @internal
export interface VideoViewViewManager
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
export interface VideoViewViewManager extends HybridObject<{
ios: 'swift';
android: 'kotlin';
}> {
player?: VideoPlayer;
controls: boolean;
pictureInPicture: boolean;
@@ -86,7 +88,9 @@ export interface VideoViewViewManager
}
// @internal
export interface VideoViewViewManagerFactory
extends HybridObject<{ ios: 'swift'; android: 'kotlin' }> {
export interface VideoViewViewManagerFactory extends HybridObject<{
ios: 'swift';
android: 'kotlin';
}> {
createViewManager(nitroId: number): VideoViewViewManager;
}

View File

@@ -3,5 +3,6 @@
"compilerOptions": {
"lib": ["ESNext", "dom"]
},
"include": ["src/**/*.ts", "src/**/*.tsx"]
"include": ["src/**/*.ts", "src/**/*.tsx"],
"exclude": []
}