174 lines
4.9 KiB
TypeScript
174 lines
4.9 KiB
TypeScript
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 (
|
||
<Canvas style={style}>
|
||
<Image
|
||
image={image}
|
||
x={0}
|
||
y={0}
|
||
width={canvasWidth}
|
||
height={canvasHeight}
|
||
fit="cover"
|
||
/>
|
||
</Canvas>
|
||
)
|
||
}
|
||
|
||
// 如果是视频且已加载
|
||
if (shouldUseVideo && videoResult.currentFrame) {
|
||
return (
|
||
<Canvas style={style}>
|
||
<Image
|
||
image={videoResult.currentFrame}
|
||
x={0}
|
||
y={0}
|
||
width={canvasWidth}
|
||
height={canvasHeight}
|
||
fit="cover"
|
||
/>
|
||
</Canvas>
|
||
)
|
||
}
|
||
|
||
// 加载中或错误时返回 null
|
||
return null
|
||
}
|
||
|
||
export default memo(VideoBox)
|