feat(新组件): vap视频播放器

This commit is contained in:
hzj 2026-04-27 19:04:37 +08:00
parent cf2e254306
commit ba449f4681
3 changed files with 397 additions and 0 deletions

View File

@ -27,6 +27,7 @@
"pinia": "^3.0.3",
"pinia-plugin-persistedstate": "^4.7.1",
"vant": "^4.9.21",
"video-animation-player": "^1.0.5",
"vue": "^3.5.18",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"

View File

@ -0,0 +1,245 @@
<template>
<Teleport to="body">
<div v-if="modelValue" class="vap-overlay" @touchmove.prevent>
<button class="vap-close" type="button" aria-label="Close" @click="close">&times;</button>
<div ref="containerRef" class="vap-stage"></div>
<div v-if="loading" class="vap-status">Loading...</div>
<div v-else-if="errorMessage" class="vap-status vap-status-error">
{{ errorMessage }}
</div>
</div>
</Teleport>
</template>
<script setup>
import { nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Vap from 'video-animation-player'
import { getVapConfigFromMp4 } from '@/utils/vapMp4Config'
const props = defineProps({
modelValue: {
type: Boolean,
default: false,
},
src: {
type: String,
default: '',
},
loop: {
type: Boolean,
default: false,
},
mute: {
type: Boolean,
default: false,
},
accurate: {
type: Boolean,
default: true,
},
autoClose: {
type: Boolean,
default: true,
},
vapData: {
type: Object,
default: () => ({}),
},
})
const emit = defineEmits(['update:modelValue', 'loaded', 'ended', 'error', 'close'])
const containerRef = ref(null)
const loading = ref(false)
const errorMessage = ref('')
let player = null
let playToken = 0
function destroyPlayer() {
if (player) {
player.destroy()
player = null
}
if (containerRef.value) {
containerRef.value.innerHTML = ''
}
}
function close() {
destroyPlayer()
emit('update:modelValue', false)
emit('close')
}
function handleEnded() {
emit('ended')
if (props.autoClose && !props.loop) {
close()
}
}
function formatError(error) {
if (error?.message?.includes('vapc box not found')) {
return 'VAP config not found in this mp4'
}
if (error?.message?.includes('request failed')) {
return 'Failed to load mp4'
}
return 'Failed to play animation'
}
async function startPlay() {
const currentToken = (playToken += 1)
destroyPlayer()
errorMessage.value = ''
if (!props.modelValue || !props.src) {
return
}
loading.value = true
try {
await nextTick()
if (!containerRef.value || currentToken !== playToken) {
return
}
const config = await getVapConfigFromMp4(props.src)
if (!containerRef.value || currentToken !== playToken) {
return
}
player = new Vap().play({
container: containerRef.value,
src: props.src,
config,
loop: props.loop,
mute: props.mute,
accurate: props.accurate,
onLoadError: (error) => {
errorMessage.value = formatError(error)
emit('error', error)
},
onDestroy: () => {
player = null
},
onDestory: () => {
player = null
},
...props.vapData,
})
player.on('ended', handleEnded)
player.on('playing', () => {
loading.value = false
emit('loaded')
})
} catch (error) {
if (currentToken !== playToken) {
return
}
loading.value = false
errorMessage.value = formatError(error)
emit('error', error)
}
}
watch(
() => [props.modelValue, props.src],
() => {
if (props.modelValue) {
startPlay()
} else {
destroyPlayer()
loading.value = false
errorMessage.value = ''
}
},
{ immediate: true }
)
onBeforeUnmount(() => {
playToken += 1
destroyPlayer()
})
</script>
<style scoped>
.vap-overlay {
position: fixed;
inset: 0;
z-index: 3000;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background: rgba(0, 0, 0, 0.72);
}
.vap-stage {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100vw;
height: 100vh;
pointer-events: none;
}
.vap-stage :deep(canvas) {
display: block;
max-width: 100vw;
max-height: 100vh;
object-fit: contain;
}
.vap-close {
position: fixed;
top: calc(env(safe-area-inset-top) + 12px);
right: 12px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 0;
border-radius: 18px;
color: #fff;
font-size: 28px;
line-height: 1;
background: rgba(0, 0, 0, 0.36);
}
.vap-status {
position: fixed;
left: 50%;
bottom: calc(env(safe-area-inset-bottom) + 40px);
z-index: 2;
max-width: calc(100vw - 48px);
padding: 8px 12px;
border-radius: 6px;
color: #fff;
font-size: 14px;
line-height: 20px;
text-align: center;
transform: translateX(-50%);
background: rgba(0, 0, 0, 0.42);
}
.vap-status-error {
background: rgba(214, 48, 49, 0.9);
}
</style>

151
src/utils/vapMp4Config.js Normal file
View File

@ -0,0 +1,151 @@
const VAP_CONFIG_BOX = 'vapc'
const TEXT_DECODER = typeof TextDecoder !== 'undefined' ? new TextDecoder('utf-8') : null
const configCache = new Map()
const containerBoxes = new Set([
'moov',
'trak',
'mdia',
'minf',
'stbl',
'edts',
'udta',
'meta',
'ilst',
'moof',
'traf',
'mvex',
'mfra',
])
function readBoxType(view, offset) {
return String.fromCharCode(
view.getUint8(offset),
view.getUint8(offset + 1),
view.getUint8(offset + 2),
view.getUint8(offset + 3)
)
}
function readBoxSize(view, offset, end) {
const smallSize = view.getUint32(offset)
if (smallSize === 0) {
return { size: end - offset, headerSize: 8 }
}
if (smallSize === 1) {
const high = view.getUint32(offset + 8)
const low = view.getUint32(offset + 12)
return { size: high * 2 ** 32 + low, headerSize: 16 }
}
return { size: smallSize, headerSize: 8 }
}
function findBoxPayload(buffer, boxName, start = 0, end = buffer.byteLength) {
const view = new DataView(buffer)
let offset = start
while (offset + 8 <= end) {
const { size, headerSize } = readBoxSize(view, offset, end)
const type = readBoxType(view, offset + 4)
if (size < headerSize || offset + size > end) {
break
}
const payloadStart = offset + headerSize
const payloadEnd = offset + size
if (type === boxName) {
return buffer.slice(payloadStart, payloadEnd)
}
if (containerBoxes.has(type)) {
const childStart = type === 'meta' ? payloadStart + 4 : payloadStart
const payload = findBoxPayload(buffer, boxName, childStart, payloadEnd)
if (payload) {
return payload
}
}
offset += size
}
return null
}
function decodeUtf8(buffer) {
if (TEXT_DECODER) {
return TEXT_DECODER.decode(buffer)
}
const bytes = new Uint8Array(buffer)
let binary = ''
for (let i = 0; i < bytes.length; i += 1) {
binary += String.fromCharCode(bytes[i])
}
return decodeURIComponent(escape(binary))
}
function parseJsonPayload(payload) {
const rawText = decodeUtf8(payload).replace(/^\uFEFF/, '').replace(/\0+$/g, '').trim()
const jsonStart = rawText.search(/[\[{]/)
if (jsonStart < 0) {
throw new Error('vapc box does not contain json')
}
return JSON.parse(rawText.slice(jsonStart))
}
export async function getVapConfigFromMp4(mp4Url) {
if (!mp4Url) {
throw new Error('mp4 url is required')
}
if (configCache.has(mp4Url)) {
return configCache.get(mp4Url)
}
const configPromise = fetch(mp4Url)
.then((response) => {
if (!response.ok) {
throw new Error(`mp4 request failed: ${response.status}`)
}
return response.arrayBuffer()
})
.then((buffer) => {
const payload = findBoxPayload(buffer, VAP_CONFIG_BOX)
if (!payload) {
throw new Error('vapc box not found')
}
return parseJsonPayload(payload)
})
.catch((error) => {
configCache.delete(mp4Url)
throw error
})
configCache.set(mp4Url, configPromise)
return configPromise
}
export function preloadVapConfig(mp4Url) {
return getVapConfigFromMp4(mp4Url).catch(() => null)
}
export function clearVapConfigCache(mp4Url) {
if (mp4Url) {
configCache.delete(mp4Url)
return
}
configCache.clear()
}