Scaffold web

This commit is contained in:
2025-09-28 23:05:02 +02:00
parent 680453567c
commit 963d3d4003
5 changed files with 588 additions and 14 deletions

View File

@@ -0,0 +1,278 @@
import { Platform } from 'react-native';
import { NitroModules } from 'react-native-nitro-modules';
import { type VideoPlayer as VideoPlayerImpl } from '../spec/nitro/VideoPlayer.nitro';
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 { createPlayer } from './utils/playerFactory';
import { createSource } from './utils/sourceFactory';
import { VideoPlayerEvents } from './VideoPlayerEvents';
class VideoPlayer extends VideoPlayerEvents implements VideoPlayerBase {
protected player: VideoPlayerImpl;
constructor(source: VideoSource | VideoConfig | VideoPlayerSource) {
const hybridSource = createSource(source);
const player = createPlayer(hybridSource);
// Initialize events
super(player.eventEmitter);
this.player = player;
}
/**
* Cleans up player's native resources and releases native state.
* After calling this method, the player is no longer usable.
* @internal
*/
__destroy() {
this.clearAllEvents();
this.player.dispose();
}
/**
* Returns the native (hybrid) player instance.
* Should not be used outside of the module.
* @internal
*/
__getNativePlayer() {
return this.player;
}
/**
* Handles parsing native errors to VideoRuntimeError and calling onError if provided
* @internal
*/
private throwError(error: unknown) {
const parsedError = tryParseNativeVideoError(error);
if (
parsedError instanceof VideoRuntimeError
&& this.triggerEvent('onError', parsedError)
) {
// We don't throw errors if onError is provided
return;
}
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;
}
// Status
get status(): VideoPlayerStatus {
return this.player.status;
}
// Duration
get duration(): number {
return this.player.duration;
}
// Volume
get volume(): number {
return this.player.volume;
}
set volume(value: number) {
this.player.volume = value;
}
// Current Time
get currentTime(): number {
return this.player.currentTime;
}
set currentTime(value: number) {
this.player.currentTime = value;
}
// Muted
get muted(): boolean {
return this.player.muted;
}
set muted(value: boolean) {
this.player.muted = value;
}
// Loop
get loop(): boolean {
return this.player.loop;
}
set loop(value: boolean) {
this.player.loop = value;
}
// Rate
get rate(): number {
return this.player.rate;
}
set rate(value: number) {
this.player.rate = value;
}
// Mix Audio Mode
get mixAudioMode(): MixAudioMode {
return this.player.mixAudioMode;
}
set mixAudioMode(value: MixAudioMode) {
this.player.mixAudioMode = value;
}
// Ignore Silent Switch Mode
get ignoreSilentSwitchMode(): IgnoreSilentSwitchMode {
return this.player.ignoreSilentSwitchMode;
}
set ignoreSilentSwitchMode(value: IgnoreSilentSwitchMode) {
if (__DEV__ && !['ios'].includes(Platform.OS)) {
console.warn(
'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;
}
set playInBackground(value: boolean) {
this.player.playInBackground = value;
}
// Play When Inactive
get playWhenInactive(): boolean {
return this.player.playWhenInactive;
}
set playWhenInactive(value: boolean) {
this.player.playWhenInactive = value;
}
// Is Playing
get isPlaying(): boolean {
return this.player.isPlaying;
}
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);
}
/**
* Releases the player's native resources and releases native state.
* After calling this method, the player is no longer usable.
* Accessing any properties or methods of the player after calling this method will throw an error.
* If you want to clean player resource use `replaceSourceAsync` with `null` instead.
*/
release(): void {
this.__destroy();
}
play(): void {
try {
this.player.play();
} catch (error) {
this.throwError(error);
}
}
pause(): void {
try {
this.player.pause();
} catch (error) {
this.throwError(error);
}
}
seekBy(time: number): void {
try {
this.player.seekBy(time);
} catch (error) {
this.throwError(error);
}
}
seekTo(time: number): void {
try {
this.player.seekTo(time);
} catch (error) {
this.throwError(error);
}
}
async replaceSourceAsync(
source: VideoSource | VideoConfig | NoAutocomplete<VideoPlayerSource> | null
): Promise<void> {
await this.wrapPromise(
this.player.replaceSourceAsync(
source === null ? null : createSource(source)
)
);
NitroModules.updateMemorySize(this.player);
}
// Text Track Management
getAvailableTextTracks(): TextTrack[] {
try {
return this.player.getAvailableTextTracks();
} catch (error) {
this.throwError(error);
return [];
}
}
selectTextTrack(textTrack: TextTrack | null): void {
try {
this.player.selectTextTrack(textTrack);
} catch (error) {
this.throwError(error);
}
}
// Selected Text Track
get selectedTrack(): TextTrack | undefined {
return this.player.selectedTrack;
}
}
export { VideoPlayer };

View File

@@ -1,6 +1,6 @@
import { useEffect } from 'react';
import { VideoPlayer } from '../VideoPlayer';
import { type AllPlayerEvents } from '../types/Events';
import type { AllPlayerEvents } from '../types/Events';
import type { VideoPlayer } from '../VideoPlayer';
/**
* Attaches an event listener to a `VideoPlayer` instance for a specified event.

View File

@@ -0,0 +1,44 @@
import type { VideoPlayerSource } from '../../spec/nitro/VideoPlayerSource.nitro';
import type { NoAutocomplete } from '../types/Utils';
import type { VideoConfig, VideoSource } from '../types/VideoConfig';
import { isVideoPlayerSource } from '../utils/sourceFactory';
import { VideoPlayer } from '../VideoPlayer';
import { useManagedInstance } from './useManagedInstance';
const sourceEqual = <T extends VideoConfig | VideoSource | VideoPlayerSource>(
a: T,
b?: T
) => {
if (isVideoPlayerSource(a) && isVideoPlayerSource(b)) {
return a.equals(b);
}
return JSON.stringify(a) === JSON.stringify(b);
};
/**
* Creates a `VideoPlayer` instance and manages its lifecycle.
*
* @param source - The source of the video to play
* @param setup - A function to setup the player
* @returns The `VideoPlayer` instance
*/
export const useVideoPlayer = (
source: VideoConfig | VideoSource | NoAutocomplete<VideoPlayerSource>,
setup?: (player: VideoPlayer) => void
) => {
return useManagedInstance(
{
factory: () => {
const player = new VideoPlayer(source);
setup?.(player);
return player;
},
cleanup: (player) => {
player.__destroy();
},
dependenciesEqualFn: sourceEqual,
},
[JSON.stringify(source)]
);
};

View File

@@ -0,0 +1,251 @@
import * as React from 'react';
import type { ViewProps, ViewStyle } from 'react-native';
import { NitroModules } from 'react-native-nitro-modules';
import type {
VideoViewViewManager,
VideoViewViewManagerFactory,
} from '../../spec/nitro/VideoViewViewManager.nitro';
import type { VideoViewEvents } from '../types/Events';
import type { ResizeMode } from '../types/ResizeMode';
import { tryParseNativeVideoError, VideoError } from '../types/VideoError';
import type { VideoPlayer } from '../VideoPlayer';
import { NativeVideoView } from './NativeVideoView';
export interface VideoViewProps extends Partial<VideoViewEvents>, ViewProps {
/**
* The player to play the video - {@link VideoPlayer}
*/
player: VideoPlayer;
/**
* The style of the video view - {@link ViewStyle}
*/
style?: ViewStyle;
/**
* Whether to show the controls. Defaults to false.
*/
controls?: boolean;
/**
* Whether to enable & show the picture in picture button in native controls. Defaults to false.
*/
pictureInPicture?: boolean;
/**
* Whether to automatically enter picture in picture mode when the video is playing. Defaults to false.
*/
autoEnterPictureInPicture?: boolean;
/**
* How the video should be resized to fit the view. Defaults to 'none'.
* - 'contain': Scale the video uniformly (maintain aspect ratio) so that it fits entirely within the view
* - 'cover': Scale the video uniformly (maintain aspect ratio) so that it fills the entire view (may crop)
* - 'stretch': Scale the video to fill the entire view without maintaining aspect ratio
* - 'none': Do not resize the video
*/
resizeMode?: ResizeMode;
/**
* Whether to keep the screen awake while the video view is mounted. Defaults to true.
*/
keepScreenAwake?: boolean;
}
export interface VideoViewRef {
/**
* Enter fullscreen mode
*/
enterFullscreen: () => void;
/**
* Exit fullscreen mode
*/
exitFullscreen: () => void;
/**
* Enter picture in picture mode
*/
enterPictureInPicture: () => void;
/**
* Exit picture in picture mode
*/
exitPictureInPicture: () => void;
/**
* Check if picture in picture mode is supported
* @returns true if picture in picture mode is supported, false otherwise
*/
canEnterPictureInPicture: () => boolean;
}
let nitroIdCounter = 1;
const VideoViewViewManagerFactory =
NitroModules.createHybridObject<VideoViewViewManagerFactory>(
'VideoViewViewManagerFactory'
);
const wrapNativeViewManagerFunction = <T,>(
manager: VideoViewViewManager | null,
func: (manager: VideoViewViewManager) => T
) => {
try {
if (manager === null) {
throw new VideoError('view/not-found', 'View manager not found');
}
return func(manager);
} catch (error) {
throw tryParseNativeVideoError(error);
}
};
const updateProps = (manager: VideoViewViewManager, props: VideoViewProps) => {
manager.player = props.player.__getNativePlayer();
manager.controls = props.controls ?? false;
manager.pictureInPicture = props.pictureInPicture ?? false;
manager.autoEnterPictureInPicture = props.autoEnterPictureInPicture ?? false;
manager.resizeMode = props.resizeMode ?? 'none';
manager.onPictureInPictureChange = props.onPictureInPictureChange;
manager.onFullscreenChange = props.onFullscreenChange;
manager.willEnterFullscreen = props.willEnterFullscreen;
manager.willExitFullscreen = props.willExitFullscreen;
manager.willEnterPictureInPicture = props.willEnterPictureInPicture;
manager.willExitPictureInPicture = props.willExitPictureInPicture;
manager.keepScreenAwake = props.keepScreenAwake ?? true;
};
/**
* VideoView is a component that allows you to display a video from a {@link VideoPlayer}.
*
* @param player - The player to play the video - {@link VideoPlayer}
* @param controls - Whether to show the controls. Defaults to false.
* @param style - The style of the video view - {@link ViewStyle}
* @param pictureInPicture - Whether to show the picture in picture button. Defaults to false.
* @param autoEnterPictureInPicture - Whether to automatically enter picture in picture mode
* when the video is playing. Defaults to false.
* @param resizeMode - How the video should be resized to fit the view. Defaults to 'none'.
*/
const VideoView = React.forwardRef<VideoViewRef, VideoViewProps>(
(
{
player,
controls = false,
pictureInPicture = false,
autoEnterPictureInPicture = false,
resizeMode = 'none',
...props
},
ref
) => {
const nitroId = React.useMemo(() => nitroIdCounter++, []);
const nitroViewManager = React.useRef<VideoViewViewManager | null>(null);
const setupViewManager = React.useCallback(
(id: number) => {
try {
if (nitroViewManager.current === null) {
nitroViewManager.current =
VideoViewViewManagerFactory.createViewManager(id);
// Should never happen
if (!nitroViewManager.current) {
throw new VideoError(
'view/not-found',
'Failed to create View Manager'
);
}
}
// Updates props to native view
updateProps(nitroViewManager.current, {
...props,
player: player,
controls: controls,
pictureInPicture: pictureInPicture,
autoEnterPictureInPicture: autoEnterPictureInPicture,
resizeMode: resizeMode,
});
} catch (error) {
throw tryParseNativeVideoError(error);
}
},
[
props,
player,
controls,
pictureInPicture,
autoEnterPictureInPicture,
resizeMode,
]
);
const onNitroIdChange = React.useCallback(
(event: { nativeEvent: { nitroId: number } }) => {
setupViewManager(event.nativeEvent.nitroId);
},
[setupViewManager]
);
React.useImperativeHandle(
ref,
() => ({
enterFullscreen: () => {
wrapNativeViewManagerFunction(nitroViewManager.current, (manager) => {
manager.enterFullscreen();
});
},
exitFullscreen: () => {
wrapNativeViewManagerFunction(nitroViewManager.current, (manager) => {
manager.exitFullscreen();
});
},
enterPictureInPicture: () => {
wrapNativeViewManagerFunction(nitroViewManager.current, (manager) => {
manager.enterPictureInPicture();
});
},
exitPictureInPicture: () => {
wrapNativeViewManagerFunction(nitroViewManager.current, (manager) => {
manager.exitPictureInPicture();
});
},
canEnterPictureInPicture: () => {
return wrapNativeViewManagerFunction(
nitroViewManager.current,
(manager) => {
return manager.canEnterPictureInPicture();
}
);
},
}),
[]
);
React.useEffect(() => {
if (!nitroViewManager.current) {
return;
}
// Updates props to native view
updateProps(nitroViewManager.current, {
...props,
player: player,
controls: controls,
pictureInPicture: pictureInPicture,
autoEnterPictureInPicture: autoEnterPictureInPicture,
resizeMode: resizeMode,
});
}, [
player,
controls,
pictureInPicture,
autoEnterPictureInPicture,
resizeMode,
props,
]);
return (
<NativeVideoView
nitroId={nitroId}
onNitroIdChange={onNitroIdChange}
{...props}
/>
);
}
);
VideoView.displayName = 'VideoView';
export default React.memo(VideoView);

View File

@@ -1,26 +1,27 @@
export { useEvent } from './core/hooks/useEvent';
export { useVideoPlayer } from './core/hooks/useVideoPlayer';
export * from './core/types/Events';
export type * from './core/types/Events';
export type { IgnoreSilentSwitchMode } from './core/types/IgnoreSilentSwitchMode';
export type { MixAudioMode } from './core/types/MixAudioMode';
export type { ResizeMode } from './core/types/ResizeMode';
export type { TextTrack } from './core/types/TextTrack';
export type { VideoConfig, VideoSource } from './core/types/VideoConfig';
export {
type LibraryError,
type PlayerError,
type SourceError,
type UnknownError,
type VideoComponentError,
type VideoError,
type VideoErrorCode,
type VideoRuntimeError,
type VideoViewError,
export type {
LibraryError,
PlayerError,
SourceError,
UnknownError,
VideoComponentError,
VideoError,
VideoErrorCode,
VideoRuntimeError,
VideoViewError,
} from './core/types/VideoError';
export type { VideoPlayerStatus } from './core/types/VideoPlayerStatus';
export { VideoPlayer } from './core/VideoPlayer';
export {
default as VideoView,
type VideoViewProps,
type VideoViewRef,
} from './core/video-view/VideoView';
export { VideoPlayer } from './core/VideoPlayer';