From 963d3d4003dd3b9433c46b7b2b537b95ac17796c Mon Sep 17 00:00:00 2001 From: Zoe Roux Date: Sun, 28 Sep 2025 23:05:02 +0200 Subject: [PATCH] Scaffold web --- .../src/core/VideoPlayer.web.ts | 278 ++++++++++++++++++ .../src/core/hooks/useEvent.ts | 4 +- .../src/core/hooks/useVideoPlayer.web.ts | 44 +++ .../src/core/video-view/VideoView.web.tsx | 251 ++++++++++++++++ packages/react-native-video/src/index.tsx | 25 +- 5 files changed, 588 insertions(+), 14 deletions(-) create mode 100644 packages/react-native-video/src/core/VideoPlayer.web.ts create mode 100644 packages/react-native-video/src/core/hooks/useVideoPlayer.web.ts create mode 100644 packages/react-native-video/src/core/video-view/VideoView.web.tsx diff --git a/packages/react-native-video/src/core/VideoPlayer.web.ts b/packages/react-native-video/src/core/VideoPlayer.web.ts new file mode 100644 index 00000000..f77d23a5 --- /dev/null +++ b/packages/react-native-video/src/core/VideoPlayer.web.ts @@ -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(promise: Promise) { + return new Promise((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 { + await this.wrapPromise(this.player.initialize()); + + NitroModules.updateMemorySize(this.player); + } + + async preload(): Promise { + 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 | null + ): Promise { + 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 }; diff --git a/packages/react-native-video/src/core/hooks/useEvent.ts b/packages/react-native-video/src/core/hooks/useEvent.ts index fd970965..10bb5e03 100644 --- a/packages/react-native-video/src/core/hooks/useEvent.ts +++ b/packages/react-native-video/src/core/hooks/useEvent.ts @@ -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. diff --git a/packages/react-native-video/src/core/hooks/useVideoPlayer.web.ts b/packages/react-native-video/src/core/hooks/useVideoPlayer.web.ts new file mode 100644 index 00000000..747cfda1 --- /dev/null +++ b/packages/react-native-video/src/core/hooks/useVideoPlayer.web.ts @@ -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 = ( + 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, + 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)] + ); +}; diff --git a/packages/react-native-video/src/core/video-view/VideoView.web.tsx b/packages/react-native-video/src/core/video-view/VideoView.web.tsx new file mode 100644 index 00000000..8a78e8d8 --- /dev/null +++ b/packages/react-native-video/src/core/video-view/VideoView.web.tsx @@ -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, 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' + ); + +const wrapNativeViewManagerFunction = ( + 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( + ( + { + player, + controls = false, + pictureInPicture = false, + autoEnterPictureInPicture = false, + resizeMode = 'none', + ...props + }, + ref + ) => { + const nitroId = React.useMemo(() => nitroIdCounter++, []); + const nitroViewManager = React.useRef(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 ( + + ); + } +); + +VideoView.displayName = 'VideoView'; + +export default React.memo(VideoView); diff --git a/packages/react-native-video/src/index.tsx b/packages/react-native-video/src/index.tsx index cdaaa843..72e1259a 100644 --- a/packages/react-native-video/src/index.tsx +++ b/packages/react-native-video/src/index.tsx @@ -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';