expo-duooomi-app/@share/components/Video.tsx

220 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { memo, useEffect, useState } from 'react'
import { type ViewStyle } from 'react-native'
import { useDerivedValue, useSharedValue } from 'react-native-reanimated'
import {
Canvas,
Image as SkiaImage,
useAnimatedImageValue,
useImage,
useVideo
} from "@shopify/react-native-skia";
import { videoUrlCache } from '@/utils/storage'
type Props = {
url?: string
needWeb?: boolean
style?: ViewStyle
width?: number
autoplay?: boolean // 控制动画播放,默认 true
}
// 默认宽度256半屏宽度
const VideoBox = ({ url, needWeb = true, width = 256, style, autoplay = true }: Props) => {
const [urlFinal, setUrlFinal] = useState('')
const pausedValue = useSharedValue(!autoplay)
const looping = useSharedValue(true)
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)
}
// 检测是否为动画图片GIF 或动画 WebP
const isAnimatedImage = (u?: string | null) => {
if (!u) return false
const lowerUrl = u.toLowerCase()
// GIF 文件通常是动画的
// WebP 可能是动画的,需要通过内容判断,这里先简单判断扩展名
return lowerUrl.includes('.gif') || lowerUrl.includes('.webp')
}
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 = !autoplay
}, [autoplay, pausedValue])
if (!url) return null
// 本地文件全部用 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)
// 判断是否为动画图片
const shouldUseAnimatedImage = shouldUseImage && isAnimatedImage(displayUrl)
// 判断是否为静态图片
const shouldUseStaticImage = shouldUseImage && !shouldUseAnimatedImage
// 只有在明确是视频链接且不是图片时才调用 useVideo避免触发 Skia.Video 原生异常
const shouldUseVideo = !shouldUseImage && isVideoUrl(displayUrl)
// Hooks 必须在顶层调用,不能条件调用
// 对于静态图片,使用 useImage
// 对于动画图片,使用 useAnimatedImageValue支持 autoplay 控制)
// 对于视频,使用 useVideo
// 如果 displayUrl 为 nullhooks 会返回 null
const staticImage = useImage(shouldUseStaticImage ? displayUrl : null)
const animatedImage = useAnimatedImageValue(
shouldUseAnimatedImage ? displayUrl : null,
shouldUseAnimatedImage ? pausedValue : undefined
)
const videoResult = useVideo(
shouldUseVideo ? displayUrl : null,
{
paused: pausedValue,
looping,
}
)
// 使用 useDerivedValue 从 videoResult.currentFrame 获取当前帧
// 如果 videoResult 为 nullvideoFrame 也会是 null
const videoFrame = useDerivedValue(() => {
return videoResult?.currentFrame.value ?? null
})
if (!url || !displayUrl) return null
const canvasStyle = style || {}
const canvasWidth = (canvasStyle.width as number) || width
const canvasHeight = (canvasStyle.height as number) || width
// 如果是静态图片且已加载
if (shouldUseStaticImage && staticImage) {
return (
<Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<SkiaImage
image={staticImage}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/>
</Canvas>
)
}
// 如果是动画图片GIF 或动画 WebP
if (shouldUseAnimatedImage && animatedImage) {
return (
<Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<SkiaImage
image={animatedImage}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/>
</Canvas>
)
}
// 如果是视频,使用 currentFrame SharedValue
if (shouldUseVideo && videoResult && videoFrame) {
return (
<Canvas style={[{ width: canvasWidth, height: canvasHeight }, canvasStyle]}>
<SkiaImage
image={videoFrame}
x={0}
y={0}
width={canvasWidth}
height={canvasHeight}
fit="cover"
/>
</Canvas>
)
}
return null
}
export default memo(VideoBox)