import React, { forwardRef, useRef, useImperativeHandle, useCallback } from 'react'; import { Platform, StyleSheet, View, Image, findNodeHandle, NativeModules, type StyleProp, type ViewStyle, type NativeSyntheticEvent, } from 'react-native'; import NativeGraniteVideoView, { Commands, type NativeProps, OnVideoLoadStartEvent, OnVideoLoadEvent, OnVideoErrorEvent, OnVideoProgressEvent, OnVideoSeekEvent, OnVideoBufferEvent, OnVideoBandwidthUpdateEvent, OnVideoPlaybackStateChangedEvent, OnVideoPlaybackRateChangeEvent, OnVideoVolumeChangeEvent, OnVideoAudioFocusChangedEvent, OnVideoPictureInPictureStatusChangedEvent, OnVideoControlsVisibilityChangeEvent, OnVideoExternalPlaybackChangeEvent, OnVideoAspectRatioEvent, TransferEndEvent, } from './GraniteVideoNativeComponent'; import type { VideoRef, VideoSource, VideoProps, OnLoadData, } from './types'; const { GraniteVideoModule } = NativeModules; function toNativeStringMapEntries( value?: Record ): ReadonlyArray<{ name: string; value: string }> | undefined { if (value === undefined) { return undefined; } return Object.entries(value).map(([name, value]) => ({ name, value, })); } function normalizeDrm(drm: VideoProps['drm']): NativeProps['drm'] | undefined { if (!drm) { return undefined; } return { ...drm, headers: toNativeStringMapEntries(drm.headers), }; } // For Fabric (New Architecture), the component is always available through codegenNativeComponent // We don't need to check UIManager.getViewManagerConfig which is Old Architecture only // Convert CMCD config to native format. function normalizeCmcd(cmcd: VideoSource['cmcd']) { if (cmcd === undefined || cmcd === null) { return undefined; } if (typeof cmcd === 'boolean') { if (!cmcd) { return undefined; } // When true, use MODE_QUERY_PARAMETER as default return { mode: 1 }; } return { mode: cmcd.mode ?? 1, request: toNativeStringMapEntries(cmcd.request), session: toNativeStringMapEntries(cmcd.session), object: toNativeStringMapEntries(cmcd.object), status: toNativeStringMapEntries(cmcd.status), }; } function normalizeAd(ad: VideoSource['ad']): NonNullable['ad'] | undefined { if (!ad) { return undefined; } return { ...ad, adTagParameters: ad.type === 'ssai' ? toNativeStringMapEntries(ad.adTagParameters) : undefined, }; } function normalizeSource(source: VideoSource | number): NativeProps['source'] | undefined { if (typeof source === 'number') { // require() - not yet supported in native return undefined; } const drm = normalizeDrm(source.drm); const cmcd = normalizeCmcd(source.cmcd); const ad = normalizeAd(source.ad); return { ...source, headers: toNativeStringMapEntries(source.headers), drm, cmcd, ad, }; } function normalizeSelectedTrack( track?: VideoProps['selectedAudioTrack'] ): NativeProps['selectedAudioTrack'] | undefined { if (!track) { return undefined; } return { type: track.type, value: track.value?.toString(), }; } function normalizeSelectedVideoTrack( track?: VideoProps['selectedVideoTrack'] ): NativeProps['selectedVideoTrack'] | undefined { if (!track) { return undefined; } return { type: track.type, value: track.value, }; } function getPosterUri(poster?: VideoProps['poster']): string | undefined { if (!poster) { return undefined; } if (typeof poster === 'string') { return poster; } if (typeof poster === 'object' && 'uri' in poster && poster.uri) { return poster.uri; } const resolved = Image.resolveAssetSource(poster as any); return resolved?.uri; } const VideoBase = forwardRef((props, ref) => { const { // Test ID testID, // Style style, // Progress progressUpdateInterval, // Source source, // Poster poster, posterResizeMode = 'contain', // Playback Control paused = false, muted = false, volume = 1.0, rate = 1.0, repeat = false, playInBackground = false, playWhenInactive = false, automaticallyWaitsToMinimizeStalling = true, shutterColor, // Display resizeMode = 'contain', viewType = 'surface', useTextureView = false, useSecureView = false, // Buffering bufferConfig, minLoadRetryCount = 3, maxBitRate, preferredForwardBufferDuration, // Track Selection selectedAudioTrack, selectedTextTrack, selectedVideoTrack, textTracks, // DRM drm, localSourceEncryptionKeyScheme, // Ads adTagUrl, adLanguage, // Controls controls = false, showNotificationControls = false, disableFocus = false, disableDisconnectError = false, focusable = true, hideShutterView = false, preventsDisplaySleepDuringVideoPlayback = true, // Fullscreen fullscreen = false, fullscreenAutorotate = true, fullscreenOrientation = 'all', // Picture in Picture pictureInPicture = false, // Content contentStartTime, allowsExternalPlayback = true, audioOutput = 'speaker', ignoreSilentSwitch = 'inherit', mixWithOthers = 'inherit', // Debug debug, // Events onLoadStart, onLoad, onError, onProgress, onSeek, onEnd, onBuffer, onBandwidthUpdate, onPlaybackStateChanged, onPlaybackRateChange, onVolumeChange, onIdle, onReadyForDisplay, onAudioFocusChanged, onAudioBecomingNoisy, onFullscreenPlayerWillPresent, onFullscreenPlayerDidPresent, onFullscreenPlayerWillDismiss, onFullscreenPlayerDidDismiss, onPictureInPictureStatusChanged, onRestoreUserInterfaceForPictureInPictureStop, onControlsVisibilityChange, onExternalPlaybackChange, onAspectRatio, onTransferEnd, } = props; const nativeRef = useRef>(null); // === Imperative Handle === useImperativeHandle(ref, () => ({ seek: (time: number, tolerance?: number) => { if (nativeRef.current) { Commands.seek(nativeRef.current, time, tolerance ?? 0); } }, pause: () => { if (nativeRef.current) { Commands.pause(nativeRef.current); } }, resume: () => { if (nativeRef.current) { Commands.resume(nativeRef.current); } }, setVolume: (vol: number) => { if (nativeRef.current) { Commands.adjustVolume(nativeRef.current, vol); } }, setFullScreen: (fs: boolean) => { if (nativeRef.current) { Commands.setFullScreen(nativeRef.current, fs); } }, presentFullscreenPlayer: () => { if (nativeRef.current) { Commands.setFullScreen(nativeRef.current, true); } }, dismissFullscreenPlayer: () => { if (nativeRef.current) { Commands.setFullScreen(nativeRef.current, false); } }, enterPictureInPicture: () => { if (nativeRef.current) { Commands.enterPictureInPicture(nativeRef.current); } }, exitPictureInPicture: () => { if (nativeRef.current) { Commands.exitPictureInPicture(nativeRef.current); } }, setSource: (newSource: VideoSource) => { if (nativeRef.current && newSource.uri) { Commands.loadSource(nativeRef.current, newSource.uri); } }, getCurrentPosition: async () => { const handle = findNodeHandle(nativeRef.current); if (handle && GraniteVideoModule?.getCurrentPosition) { return GraniteVideoModule.getCurrentPosition(handle); } return 0; }, save: async (options?: { type?: string }) => { const handle = findNodeHandle(nativeRef.current); if (handle && GraniteVideoModule?.save) { return GraniteVideoModule.save(handle, options ?? {}); } return { uri: '' }; }, restoreUserInterfaceForPictureInPictureStopCompleted: () => { // iOS specific - handled internally }, })); // === Event Handlers === const handleLoadStart = useCallback( (event: NativeSyntheticEvent) => { onLoadStart?.(event.nativeEvent); }, [onLoadStart] ); const handleLoad = useCallback( (event: NativeSyntheticEvent) => { const nativeEvent = event.nativeEvent; const loadData = { ...nativeEvent, naturalSize: { ...nativeEvent.naturalSize, orientation: nativeEvent.naturalSize.orientation as OnLoadData['naturalSize']['orientation'], }, } as OnLoadData; onLoad?.(loadData); }, [onLoad] ); const handleError = useCallback( (event: NativeSyntheticEvent) => { onError?.(event.nativeEvent); }, [onError] ); const handleProgress = useCallback( (event: NativeSyntheticEvent) => { onProgress?.(event.nativeEvent); }, [onProgress] ); const handleSeek = useCallback( (event: NativeSyntheticEvent) => { onSeek?.(event.nativeEvent); }, [onSeek] ); const handleEnd = useCallback(() => { onEnd?.(); }, [onEnd]); const handleBuffer = useCallback( (event: NativeSyntheticEvent) => { onBuffer?.(event.nativeEvent); }, [onBuffer] ); const handleBandwidthUpdate = useCallback( (event: NativeSyntheticEvent) => { onBandwidthUpdate?.(event.nativeEvent); }, [onBandwidthUpdate] ); const handlePlaybackStateChanged = useCallback( (event: NativeSyntheticEvent) => { onPlaybackStateChanged?.(event.nativeEvent); }, [onPlaybackStateChanged] ); const handlePlaybackRateChange = useCallback( (event: NativeSyntheticEvent) => { onPlaybackRateChange?.(event.nativeEvent); }, [onPlaybackRateChange] ); const handleVolumeChange = useCallback( (event: NativeSyntheticEvent) => { onVolumeChange?.(event.nativeEvent); }, [onVolumeChange] ); const handleIdle = useCallback(() => { onIdle?.(); }, [onIdle]); const handleReadyForDisplay = useCallback(() => { onReadyForDisplay?.(); }, [onReadyForDisplay]); const handleAudioFocusChanged = useCallback( (event: NativeSyntheticEvent) => { onAudioFocusChanged?.(event.nativeEvent); }, [onAudioFocusChanged] ); const handleAudioBecomingNoisy = useCallback(() => { onAudioBecomingNoisy?.(); }, [onAudioBecomingNoisy]); const handleFullscreenPlayerWillPresent = useCallback(() => { onFullscreenPlayerWillPresent?.(); }, [onFullscreenPlayerWillPresent]); const handleFullscreenPlayerDidPresent = useCallback(() => { onFullscreenPlayerDidPresent?.(); }, [onFullscreenPlayerDidPresent]); const handleFullscreenPlayerWillDismiss = useCallback(() => { onFullscreenPlayerWillDismiss?.(); }, [onFullscreenPlayerWillDismiss]); const handleFullscreenPlayerDidDismiss = useCallback(() => { onFullscreenPlayerDidDismiss?.(); }, [onFullscreenPlayerDidDismiss]); const handlePictureInPictureStatusChanged = useCallback( (event: NativeSyntheticEvent) => { onPictureInPictureStatusChanged?.(event.nativeEvent); }, [onPictureInPictureStatusChanged] ); const handleRestoreUserInterfaceForPictureInPictureStop = useCallback(() => { onRestoreUserInterfaceForPictureInPictureStop?.(); }, [onRestoreUserInterfaceForPictureInPictureStop]); const handleControlsVisibilityChange = useCallback( (event: NativeSyntheticEvent) => { onControlsVisibilityChange?.(event.nativeEvent); }, [onControlsVisibilityChange] ); const handleExternalPlaybackChange = useCallback( (event: NativeSyntheticEvent) => { onExternalPlaybackChange?.(event.nativeEvent); }, [onExternalPlaybackChange] ); const handleAspectRatio = useCallback( (event: NativeSyntheticEvent) => { onAspectRatio?.(event.nativeEvent); }, [onAspectRatio] ); const handleTransferEnd = useCallback( (event: NativeSyntheticEvent) => { onTransferEnd?.(event.nativeEvent); }, [onTransferEnd] ); // === Render === const containerStyle: StyleProp = [styles.container, style]; return ( ); }); VideoBase.displayName = 'Video'; // Type for GraniteVideo with static properties type VideoComponent = typeof VideoBase & { isAvailable: boolean; }; // Static property to indicate availability // For Fabric (New Architecture), the component is always available (VideoBase as VideoComponent).isAvailable = true; export const Video = VideoBase as VideoComponent; const styles = StyleSheet.create({ container: { overflow: 'hidden', }, video: { ...StyleSheet.absoluteFillObject, }, }); // === Static Methods === export async function clearCache(): Promise { if (GraniteVideoModule?.clearCache) { return GraniteVideoModule.clearCache(); } } export async function getWidevineLevel(): Promise { if (Platform.OS === 'android' && GraniteVideoModule?.getWidevineLevel) { return GraniteVideoModule.getWidevineLevel(); } return 0; } export async function isCodecSupported(mimeType: string, width: number, height: number): Promise { if (GraniteVideoModule?.isCodecSupported) { return GraniteVideoModule.isCodecSupported(mimeType, width, height); } return false; } export async function isHEVCSupported(): Promise { if (GraniteVideoModule?.isHEVCSupported) { return GraniteVideoModule.isHEVCSupported(); } return false; }