220 lines
6.6 KiB
TypeScript
220 lines
6.6 KiB
TypeScript
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 为 null,hooks 会返回 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 为 null,videoFrame 也会是 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)
|