feat(新组件): vap视频播放器
This commit is contained in:
parent
cf2e254306
commit
ba449f4681
@ -27,6 +27,7 @@
|
|||||||
"pinia": "^3.0.3",
|
"pinia": "^3.0.3",
|
||||||
"pinia-plugin-persistedstate": "^4.7.1",
|
"pinia-plugin-persistedstate": "^4.7.1",
|
||||||
"vant": "^4.9.21",
|
"vant": "^4.9.21",
|
||||||
|
"video-animation-player": "^1.0.5",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-i18n": "^11.1.12",
|
"vue-i18n": "^11.1.12",
|
||||||
"vue-router": "^4.5.1"
|
"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