import { createElement, useEffect, useState, type RefObject } from 'react'; import type { ViewProps } from 'react-native'; type ReactNative = typeof import('react-native'); // react-native and react-native-nitro-modules are required lazily so the // package barrel stays importable in non-RN test environments. function rn(): ReactNative { // biome-ignore lint: no ESM interop for these packages under bun test return require('react-native') as ReactNative; } import { devWarn } from '../../logger'; import { useAviationPlayer } from '../../AviationContext'; import type { VideoTrackSelection, SubtitleTrackSelection, AudioTrackSelection, } from '../../specs/types.nitro'; import type { AviationPlayer } from '../../Aviation'; import type { ResizeMode, SurfaceType, FullscreenConfig, VideoSource, } from '../types'; import type { VideoViewProps as NitroVideoViewProps, VideoViewMethods, } from '../../specs/VideoView.nitro'; import { VideoViewConfig } from '../../nitrogen-configs'; import { useVideoViewCallbacks } from './useVideoViewCallbacks'; type NativeVideoHost = ReturnType< typeof import('react-native-nitro-modules').getHostComponent< NitroVideoViewProps, VideoViewMethods > >; let nativeVideoViewHost: NativeVideoHost | null = null; function nativeVideoView(): NativeVideoHost { nativeVideoViewHost ??= ( require('react-native-nitro-modules') as typeof import('react-native-nitro-modules') ).getHostComponent( 'VideoView', () => VideoViewConfig ); return nativeVideoViewHost; } export interface VideoViewRef { startPictureInPicture(): void; stopPictureInPicture(): void; /** Non-native fullscreen (window reparenting). No pause. Custom controls via JS. */ enterFullscreen(config?: FullscreenConfig): void; exitFullscreen(): void; isFullscreen(): boolean; /** Native fullscreen (AVPlayerViewController on iOS). Momentary pause on exit is auto-resumed. */ enterNativeFullscreen(): void; /** iOS: always true (iOS 14+). Android: checks PackageManager.FEATURE_PICTURE_IN_PICTURE. */ isPipSupported(): boolean; } export interface VideoViewProps extends ViewProps { /** Media source. Changing this prop loads the new source automatically. */ source?: VideoSource; /** When true (default), `source` loads and plays. When false, loads only. */ autoPlay?: boolean; resizeMode?: ResizeMode; pipEnabled?: boolean; /** * Keep the video's audio playing while the app is backgrounded. Default false. * iOS requires the `audio` UIBackgroundMode in the host app's Info.plist. */ playInBackground?: boolean; onPipStart?: () => void; onPipStop?: () => void; onFullscreenEnter?: () => void; onFullscreenExit?: () => void; /** * Android rendering surface. * - `surfaceView` (default): best performance, no View transforms. * - `textureView`: supports transforms/animations, slightly slower. */ surfaceType?: SurfaceType; /** `auto` (default) | `disabled` | `{ type: 'resolution', value }` | `{ type: 'index', value }` (Android). */ selectedVideoTrack?: VideoTrackSelection; /** `auto` (default) | `disabled` | `{ type: 'language', value: 'en' }` | `{ type: 'index', value }`. */ selectedSubtitleTrack?: SubtitleTrackSelection; /** `auto` (default) | `{ type: 'language', value: 'en' }` | `{ type: 'index', value }`. */ selectedAudioTrack?: AudioTrackSelection; /** * Subtree rendered above the video while `enterFullscreen()` is active. * Unmounted on exit. Not used by `enterNativeFullscreen()`. */ fullscreenControls?: React.ReactNode; /** * VAST/VMAP ad tag URL for pre-roll ads. Requires `enableIMAds()` first. * Updating triggers a new ad request on the next content load. */ adTagUrl?: string; /** Player whose engine this view should render. Defaults to nearest AviationProvider. */ player?: AviationPlayer; videoRef?: RefObject; } const DEFAULT_VIDEO_TRACK: VideoTrackSelection = { type: 'auto' }; const DEFAULT_SUBTITLE_TRACK: SubtitleTrackSelection = { type: 'auto' }; const DEFAULT_AUDIO_TRACK: AudioTrackSelection = { type: 'auto' }; const EMPTY_SOURCE: VideoSource = { uri: '', mediaType: 'videoOnDemand' }; export function VideoView({ source, autoPlay = true, resizeMode = 'contain', pipEnabled = false, playInBackground = false, onPipStart, onPipStop, onFullscreenEnter, onFullscreenExit, surfaceType = 'surfaceView', selectedVideoTrack = DEFAULT_VIDEO_TRACK, selectedSubtitleTrack = DEFAULT_SUBTITLE_TRACK, selectedAudioTrack = DEFAULT_AUDIO_TRACK, fullscreenControls, adTagUrl, player: playerProp, videoRef, ...viewProps }: VideoViewProps): React.JSX.Element { const player = useAviationPlayer(playerProp); const engine = player.engine; const [isFullscreen, setIsFullscreen] = useState(false); useEffect(() => { if (!player.hasPlugin('video')) { devWarn( 'video', `VideoView attached to player '${player.id}' without VideoPlugin.`, { hint: "Add VideoPlugin from '@react-native-aviation/core' to createPlayer({ plugins }) for video-capable players.", } ); } }, [player]); const handleFullscreenEnter = () => { setIsFullscreen(true); onFullscreenEnter?.(); }; const handleFullscreenExit = () => { setIsFullscreen(false); onFullscreenExit?.(); }; const RN = rn(); const { pipStartCb, pipStopCb, fullscreenEnterCb, fullscreenExitCb, hybridRefCb, } = useVideoViewCallbacks({ engine, onPipStart, onPipStop, onFullscreenEnter: handleFullscreenEnter, onFullscreenExit: handleFullscreenExit, videoRef, }); return ( <> {createElement(nativeVideoView(), { ...viewProps, resizeMode, pipEnabled, playInBackground, surfaceType, selectedVideoTrack, selectedSubtitleTrack, selectedAudioTrack, adTagUrl: adTagUrl ?? '', source: source ?? EMPTY_SOURCE, autoPlay, onPipStart: pipStartCb, onPipStop: pipStopCb, onFullscreenEnter: fullscreenEnterCb, onFullscreenExit: fullscreenExitCb, hybridRef: hybridRefCb, })} {fullscreenControls != null && ( {}} > {fullscreenControls} )} ); } type FullscreenStyles = ReturnType; let fullscreenStyles: FullscreenStyles | null = null; function styles(rnModule: ReactNative): FullscreenStyles { fullscreenStyles ??= rnModule.StyleSheet.create({ fullscreenOverlay: { flex: 1 }, }); return fullscreenStyles; }