feat(新组件): vap视频播放器
This commit is contained in:
parent
cf2e254306
commit
ba449f4681
@ -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"
|
||||
|
||||
245
src/components/VapMp4Player.vue
Normal file
245
src/components/VapMp4Player.vue
Normal 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">×</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
151
src/utils/vapMp4Config.js
Normal 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()
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user