2 Commits

Author SHA1 Message Date
d52903709b wip: Implement event handlers for web 2025-10-03 23:17:15 +02:00
3a2ec54eaa Implement html video properties using headless video 2025-10-03 22:09:55 +02:00
4 changed files with 199 additions and 134 deletions

View File

@@ -1,24 +1,23 @@
import shaka from 'shaka-player';
import type { VideoPlayerSource } from '../spec/nitro/VideoPlayerSource.nitro';
import type { IgnoreSilentSwitchMode } from './types/IgnoreSilentSwitchMode';
import type { MixAudioMode } from './types/MixAudioMode';
import type { TextTrack } from './types/TextTrack';
import type { NoAutocomplete } from './types/Utils';
import type { VideoConfig, VideoSource } from './types/VideoConfig';
import shaka from "shaka-player";
import type { VideoPlayerSource } from "../spec/nitro/VideoPlayerSource.nitro";
import type { IgnoreSilentSwitchMode } from "./types/IgnoreSilentSwitchMode";
import type { MixAudioMode } from "./types/MixAudioMode";
import type { TextTrack } from "./types/TextTrack";
import type { NoAutocomplete } from "./types/Utils";
import type { VideoConfig, VideoSource } from "./types/VideoConfig";
import {
tryParseNativeVideoError,
VideoRuntimeError,
} from './types/VideoError';
import type { VideoPlayerBase } from './types/VideoPlayerBase';
import type { VideoPlayerStatus } from './types/VideoPlayerStatus';
import { VideoPlayerEvents } from './VideoPlayerEvents';
} from "./types/VideoError";
import type { VideoPlayerBase } from "./types/VideoPlayerBase";
import type { VideoPlayerStatus } from "./types/VideoPlayerStatus";
class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
class VideoPlayer extends VideoPlayerEventsWeb implements VideoPlayerBase {
protected player = new shaka.Player();
protected video = document.createElement("video");
constructor(source: VideoSource | VideoConfig | VideoPlayerSource) {
// Initialize events
super(player.eventEmitter);
this.player.attach(this.video);
}
/**
@@ -30,13 +29,8 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
this.player.destroy();
}
/**
* Returns the native (hybrid) player instance.
* Should not be used outside of the module.
* @internal
*/
__getNativePlayer() {
return this.player;
__getNativeRef() {
return this.video;
}
/**
@@ -47,8 +41,8 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
const parsedError = tryParseNativeVideoError(error);
if (
parsedError instanceof VideoRuntimeError
&& this.triggerEvent('onError', parsedError)
parsedError instanceof VideoRuntimeError &&
this.triggerEvent("onError", parsedError)
) {
// We don't throw errors if onError is provided
return;
@@ -57,18 +51,6 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
throw parsedError;
}
/**
* Wraps a promise to try parsing native errors to VideoRuntimeError
* @internal
*/
private wrapPromise<T>(promise: Promise<T>) {
return new Promise<T>((resolve, reject) => {
promise.then(resolve).catch((error) => {
reject(this.throwError(error));
});
});
}
// Source
get source(): VideoPlayerSource {
return this.player.source;
@@ -76,85 +58,95 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
// Status
get status(): VideoPlayerStatus {
return this.player.status;
if (this.video.error) return "error";
if (this.video.readyState === HTMLMediaElement.HAVE_NOTHING) return "idle";
if (
this.video.readyState === HTMLMediaElement.HAVE_ENOUGH_DATA ||
this.video.readyState === HTMLMediaElement.HAVE_FUTURE_DATA
)
return "readyToPlay";
return "loading";
}
// Duration
get duration(): number {
return this.player.duration;
return this.video.duration;
}
// Volume
get volume(): number {
return this.player.volume;
return this.video.volume;
}
set volume(value: number) {
this.player.volume = value;
this.video.volume = value;
}
// Current Time
get currentTime(): number {
return this.player.currentTime;
return this.video.currentTime;
}
set currentTime(value: number) {
this.player.currentTime = value;
this.video.currentTime = value;
}
// Muted
get muted(): boolean {
return this.player.muted;
return this.video.muted;
}
set muted(value: boolean) {
this.player.muted = value;
this.video.muted = value;
}
// Loop
get loop(): boolean {
return this.player.loop;
return this.video.loop;
}
set loop(value: boolean) {
this.player.loop = value;
this.video.loop = value;
}
// Rate
get rate(): number {
return this.player.rate;
return this.video.playbackRate;
}
set rate(value: number) {
this.player.rate = value;
this.video.playbackRate = value;
}
// Mix Audio Mode
get mixAudioMode(): MixAudioMode {
return this.player.mixAudioMode;
return "auto";
}
set mixAudioMode(value: MixAudioMode) {
this.player.mixAudioMode = value;
set mixAudioMode(_: MixAudioMode) {
if (__DEV__) {
console.warn(
"mixAudioMode is not supported on this platform, it wont have any effect",
);
}
}
// Ignore Silent Switch Mode
get ignoreSilentSwitchMode(): IgnoreSilentSwitchMode {
return this.player.ignoreSilentSwitchMode;
return "auto";
}
set ignoreSilentSwitchMode(value: IgnoreSilentSwitchMode) {
set ignoreSilentSwitchMode(_: IgnoreSilentSwitchMode) {
if (__DEV__) {
console.warn(
'ignoreSilentSwitchMode is not supported on this platform, it wont have any effect'
"ignoreSilentSwitchMode is not supported on this platform, it wont have any effect",
);
}
this.player.ignoreSilentSwitchMode = value;
}
// Play In Background
get playInBackground(): boolean {
return this.player.playInBackground;
return true;
}
set playInBackground(value: boolean) {
@@ -177,14 +169,10 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
async initialize(): Promise<void> {
await this.wrapPromise(this.player.initialize());
NitroModules.updateMemorySize(this.player);
}
async preload(): Promise<void> {
await this.wrapPromise(this.player.preload());
NitroModules.updateMemorySize(this.player);
this.player.load(this.media, this.startTime);
}
/**
@@ -199,7 +187,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
play(): void {
try {
this.player.play();
this.video.play();
} catch (error) {
this.throwError(error);
}
@@ -207,7 +195,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
pause(): void {
try {
this.player.pause();
this.video.pause();
} catch (error) {
this.throwError(error);
}
@@ -215,7 +203,7 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
seekBy(time: number): void {
try {
this.player.seekBy(time);
this.video.currentTime += time;
} catch (error) {
this.throwError(error);
}
@@ -223,22 +211,24 @@ class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
seekTo(time: number): void {
try {
this.player.seekTo(time);
this.video.currentTime = time;
} catch (error) {
this.throwError(error);
}
}
async replaceSourceAsync(
source: VideoSource | VideoConfig | NoAutocomplete<VideoPlayerSource> | null
source:
| VideoSource
| VideoConfig
| NoAutocomplete<VideoPlayerSource>
| null,
): Promise<void> {
await this.wrapPromise(
this.player.replaceSourceAsync(
source === null ? null : createSource(source)
)
source === null ? null : createSource(source),
),
);
NitroModules.updateMemorySize(this.player);
}
// Text Track Management

View File

@@ -29,7 +29,7 @@ export class VideoPlayerEvents {
constructor(eventEmitter: VideoPlayerEventEmitter) {
this.eventEmitter = eventEmitter;
for (let event of this.supportedEvents){
for (const event of this.supportedEvents){
// @ts-expect-error we narrow the type of the event
this.eventEmitter[event] = this.triggerEvent.bind(this, event);
}
@@ -41,7 +41,7 @@ export class VideoPlayerEvents {
): boolean {
if (!this.eventListeners[event]?.size)
return false;
for (let fn of this.eventListeners[event]) {
for (const fn of this.eventListeners[event]) {
fn(...params);
}
return true;
@@ -59,7 +59,7 @@ export class VideoPlayerEvents {
event: Event,
callback: PlayerEvents[Event]
) {
this.eventListeners[event]!.delete(callback);
this.eventListeners[event]?.delete(callback);
}
/**

View File

@@ -0,0 +1,77 @@
import { VideoPlayerEvents } from "./VideoPlayerEvents";
import type { AllPlayerEvents as PlayerEvents } from "./types/Events";
class VideoPlayerEventsWeb {
protected eventListeners: Partial<
Record<keyof PlayerEvents, Set<(...params: any[]) => void>>
> = {};
constructor(private video: HTMLVideoElement) {}
private eventMap: Record<
keyof PlayerEvents,
keyof HTMLVideoElementEventMap | null
> = {
onAudioBecomingNoisy: null,
onAudioFocusChange: null,
onBandwidthUpdate: null,
onBuffer: "waiting",
onControlsVisibleChange: null,
onEnd: "ended",
onExternalPlaybackChange: null,
onLoad: "loadeddata",
onLoadStart: "loadstart",
onPlaybackRateChange: "ratechange",
onPlaybackStateChange: null,
onProgress: "timeupdate",
onReadyToDisplay: "canplay",
onSeek: "seeked",
onStatusChange: null,
onTextTrackDataChanged: null,
onTimedMetadata: null,
onTrackChange: null,
onVolumeChange: "volumechange",
onError: "error",
};
addEventListener<Event extends keyof PlayerEvents>(
event: Event,
callback: PlayerEvents[Event],
) {
if (!this.eventMap[event]) return;
this.eventListeners[event] ??= new Set();
this.eventListeners[event].add(callback);
this.video.addEventListener(this.eventMap[event], callback);
}
removeEventListener<Event extends keyof PlayerEvents>(
event: Event,
callback: PlayerEvents[Event],
) {
if (!this.eventMap[event]) return;
this.eventListeners[event]?.delete(callback);
this.video.removeEventListener(this.eventMap[event], callback);
}
/**
* Clears all events from the event emitter.
*/
clearAllEvents() {
for (const [event, callbacks] of Object.entries(this.eventListeners)) {
for (const cb of callbacks) {
this.removeEventListener(event as keyof PlayerEvents, cb);
}
}
}
/**
* Clears a specific event from the event emitter.
* @param event - The name of the event to clear.
*/
clearEvent(event: keyof PlayerEvents) {
if (!this.eventListeners[event]) return;
for (const cb of this.eventListeners[event]) {
this.removeEventListener(event, cb);
}
}
}

View File

@@ -1,21 +1,15 @@
import {
forwardRef,
type HTMLProps,
memo,
useEffect,
useImperativeHandle,
useRef,
forwardRef,
memo,
useEffect,
useImperativeHandle,
useRef,
} from "react";
import type { ViewProps, ViewStyle } from "react-native";
import { unstable_createElement } from "react-native-web";
import { View, type ViewStyle } from "react-native";
import { VideoError } from "../types/VideoError";
import type { VideoPlayer } from "../VideoPlayer.web";
import type { VideoViewProps, VideoViewRef } from "./ViewViewProps";
const Video = (
props: Omit<HTMLProps<HTMLVideoElement>, keyof ViewProps> & ViewProps,
) => unstable_createElement("video", props);
/**
* VideoView is a component that allows you to display a video from a {@link VideoPlayer}.
*
@@ -28,58 +22,62 @@ const Video = (
* @param resizeMode - How the video should be resized to fit the view. Defaults to 'none'.
*/
const VideoView = forwardRef<VideoViewRef, VideoViewProps>(
(
{
player,
controls = false,
resizeMode = "none",
style,
// auto pip is unsupported
pictureInPicture = false,
autoEnterPictureInPicture = false,
keepScreenAwake = true,
...props
},
ref,
) => {
const vRef = useRef<HTMLVideoElement>(null);
useEffect(() => {
const webPlayer = player as unknown as VideoPlayer;
if (vRef.current) webPlayer.__getNativePlayer().attach(vRef.current);
}, [player]);
(
{
player: nPlayer,
controls = false,
resizeMode = "none",
// auto pip is unsupported
pictureInPicture = false,
autoEnterPictureInPicture = false,
keepScreenAwake = true,
...props
},
ref,
) => {
const player = nPlayer as unknown as VideoPlayer;
const vRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const videoElement = player.__getNativeRef();
vRef.current?.appendChild(videoElement);
return () => {
vRef.current?.removeChild(videoElement);
};
}, [player]);
useImperativeHandle(
ref,
() => ({
enterFullscreen: () => {
vRef.current?.requestFullscreen({ navigationUI: "hide" });
},
exitFullscreen: () => {
document.exitFullscreen();
},
enterPictureInPicture: () => {
vRef.current?.requestPictureInPicture();
},
exitPictureInPicture: () => {
document.exitPictureInPicture();
},
canEnterPictureInPicture: () => document.pictureInPictureEnabled,
}),
[],
);
useImperativeHandle(
ref,
() => ({
enterFullscreen: () => {
player.__getNativeRef().requestFullscreen({ navigationUI: "hide" });
},
exitFullscreen: () => {
document.exitFullscreen();
},
enterPictureInPicture: () => {
player.__getNativeRef().requestPictureInPicture();
},
exitPictureInPicture: () => {
document.exitPictureInPicture();
},
canEnterPictureInPicture: () => document.pictureInPictureEnabled,
}),
[player],
);
return (
<Video
ref={vRef}
controls={controls}
style={[
style,
{ objectFit: resizeMode === "stretch" ? "fill" : resizeMode },
]}
{...props}
/>
);
},
useEffect(() => {
player.__getNativeRef().controls = controls;
}, [player, controls]);
return (
<View {...props}>
<div
ref={vRef}
style={{ objectFit: resizeMode === "stretch" ? "fill" : resizeMode }}
/>
</View>
);
},
);
VideoView.displayName = "VideoView";