import type * as ExpoAV from 'expo-av'; import type * as ExpoFS from 'expo-file-system'; import type * as ExpoImageManipulator from 'expo-image-manipulator'; import type { EventSubscription } from 'expo-modules-core'; import type * as ExpoVideo from 'expo-video'; import type { StatusChangeEventPayload } from 'expo-video'; import type * as ExpoVideoThumbnail from 'expo-video-thumbnails'; import React, { useEffect } from 'react'; import { Logger, getDownscaleSize } from '@sendbird/uikit-utils'; import SBUUtils from '../libs/SBUUtils'; import expoBackwardUtils from '../utils/expoBackwardUtils'; import type { ExpoVideoModule } from '../utils/expoBackwardUtils'; import type { MediaServiceInterface, VideoProps } from './types'; type Modules = { avModule: ExpoVideoModule; thumbnailModule: typeof ExpoVideoThumbnail; imageManipulator: typeof ExpoImageManipulator; fsModule: typeof ExpoFS; }; interface VideoModuleAdapter { VideoComponent: React.ComponentType; } class LegacyExpoAVVideoAdapter implements VideoModuleAdapter { private readonly avModule: typeof ExpoAV; constructor(avModule: typeof ExpoAV) { this.avModule = avModule; } VideoComponent = ({ source, resizeMode, onLoad, ...props }: VideoProps) => { // FIXME: type error https://github.com/expo/expo/issues/17101 // @ts-ignore return ; }; } class ExpoVideoAdapter implements VideoModuleAdapter { constructor(private readonly _videoModule: typeof ExpoVideo) {} VideoComponent = ({ source, resizeMode, onLoad, ...props }: VideoProps) => { const player = this._videoModule.useVideoPlayer(source); useEffect(() => { if (onLoad && player) { let subscription: EventSubscription | null = null; try { subscription = player.addListener('statusChange', (eventData: StatusChangeEventPayload) => { const { status, error } = eventData; if (status === 'readyToPlay' && !error) { onLoad(); } }); } catch (error) { const timeout = setTimeout(() => onLoad(), 300); return () => clearTimeout(timeout); } return () => { if (subscription) { subscription.remove(); } }; } return undefined; }, [onLoad, player]); const getContentFit = (mode: typeof resizeMode): 'cover' | 'contain' | 'fill' => { switch (mode) { case 'cover': return 'cover'; case 'contain': return 'contain'; case 'stretch': return 'fill'; default: return 'contain'; } }; return React.createElement(this._videoModule.VideoView, { ...props, player, contentFit: getContentFit(resizeMode), }); }; } const createExpoMediaService = ({ avModule, thumbnailModule, imageManipulator, fsModule, }: Modules): MediaServiceInterface => { if (expoBackwardUtils.expoAV.isLegacyAVModule(avModule)) { Logger.warn( '[MediaService.Expo] expo-av is deprecated and will be removed in Expo 54. Please migrate to expo-video.', ); } const videoAdapter = expoBackwardUtils.expoAV.isVideoModule(avModule) ? new ExpoVideoAdapter(avModule) : new LegacyExpoAVVideoAdapter(avModule); return { VideoComponent: videoAdapter.VideoComponent, async getVideoThumbnail({ url, quality, timeMills }) { try { const { uri } = await thumbnailModule.getThumbnailAsync(url, { quality, time: timeMills }); return { path: uri }; } catch { return null; } }, async compressImage({ maxWidth, maxHeight, compressionRate = 1, uri }) { const originSize = await SBUUtils.getImageSize(uri); const resizingSize = getDownscaleSize(originSize, { width: maxWidth, height: maxHeight }); const { uri: compressedURI } = await imageManipulator.manipulateAsync(uri, [{ resize: resizingSize }], { compress: Math.min(Math.max(0, compressionRate), 1), }); const fileInfo = await expoBackwardUtils.fileSystem.getFileInfo(fsModule, uri); return { uri: compressedURI, size: expoBackwardUtils.toFileSize(fileInfo) }; }, }; }; export default createExpoMediaService;