import { memo, useEffect, useState } from 'react' import { type ViewStyle } from 'react-native' import { useSharedValue } from 'react-native-reanimated' import { Canvas, Image, useImage, useVideo } from "@shopify/react-native-skia"; import { videoUrlCache } from '@/utils/storage' type Props = { url?: string needWeb?: boolean style?: ViewStyle width?: number paused?: boolean looping?: boolean } // 默认宽度256半屏宽度 const VideoBox = ({ url, needWeb = true, width = 256, style, paused = false, looping = true }: Props) => { const [urlFinal, setUrlFinal] = useState('') const pausedValue = useSharedValue(paused) const createUrl = (url: string) => { return `https://modal-dev.bowong.cc/api/custom/video/converter/v2?media_url=${encodeURI(url)}&options=compression_level=3,quality=70,loop=true,resolution=${width}x${width},fps=24` } const isImg = (url: any) => { if (!url) return false const lowerUrl = url.toLowerCase() return lowerUrl?.match(/\.(jpg|jpeg|png|gif|webp|bmp|tiff|svg)(\?.*)?$/i) } async function resolveRedirect(url: string) { const res = await fetch(url, { method: 'GET', headers: { Range: 'bytes=0-0', }, }) return res.url } const setRedirectUrl = async (url?: string) => { const isImg2 = isImg(url) if (isImg2) { setUrlFinal(url!) return } try { // 先尝试从缓存获取 const cachedUrl = await videoUrlCache.get(url!, width) // console.log('getRedirectUrl cachedUrl-----------', url, cachedUrl, width) if (cachedUrl) { setUrlFinal(cachedUrl) return } // 缓存未命中,进行网络请求 const webpUrl = createUrl(url!) const finalUrl = await resolveRedirect(webpUrl) // 缓存结果 await videoUrlCache.set(url!, finalUrl, width) // console.log('setRedirectUrl finalUrl-----------', finalUrl) setUrlFinal(finalUrl!) } catch (error) { console.warn('获取视频URL失败:', error) // 错误时尝试使用原始URL setUrlFinal(url!) } } useEffect(() => { if (!url) return setRedirectUrl(url!) // const finalUrl = createUrl(url) return }, [url]) useEffect(() => { pausedValue.value = paused }, [paused, pausedValue]) // 本地文件全部用 video 组件播放 const isLocal = url ? !url.startsWith('http://') && !url.startsWith('https://') : false const isImageFile = url ? isImg(url) : false // 仅当明显是视频地址时才交给 Skia Video 处理,防止非视频资源导致原生 HostFunction 崩溃 const isVideoUrl = (u?: string | null) => { if (!u) return false const lowerUrl = u.toLowerCase() // 目前 Skia Video 只对常见视频格式稳定,这里做一次白名单过滤 return lowerUrl.includes('.mp4') || lowerUrl.includes('.mov') || lowerUrl.includes('.m4v') || lowerUrl.includes('.webm') } // 获取实际使用的URL // 对于本地文件,直接使用 url // 对于远程文件,需要等待 urlFinal 准备好(图片文件除外,因为图片不需要转换) const displayUrl = url ? isLocal ? url : isImageFile ? url // 图片文件直接使用原始 URL : urlFinal || null // 视频文件需要等待转换后的 URL : null // 判断是否为图片(需要检查原始 URL 和最终 URL) const shouldUseImage = isImageFile || (displayUrl ? isImg(displayUrl) : false) // 只有在明确是视频链接且不是图片时才调用 useVideo,避免触发 Skia.Video 原生异常 const shouldUseVideo = !shouldUseImage && isVideoUrl(displayUrl) // Hooks 必须在顶层调用,不能条件调用 // 对于图片,使用 useImage;对于视频,使用 useVideo // 如果 displayUrl 为 null,hooks 会返回 null const image = useImage(shouldUseImage ? displayUrl : null) const videoResult = useVideo( shouldUseVideo ? displayUrl : null, { paused: pausedValue, looping, } ) if (!url || !displayUrl) return null const canvasStyle = style || {} const canvasWidth = (canvasStyle.width as number) || width const canvasHeight = (canvasStyle.height as number) || width // 如果是图片且已加载 if (shouldUseImage && image) { return ( ) } // 如果是视频且已加载 if (shouldUseVideo && videoResult.currentFrame) { return ( ) } // 加载中或错误时返回 null return null } export default memo(VideoBox)