aslan-h5/src/components/VapMp4Player.vue

450 lines
9.4 KiB
Vue

<template>
<Teleport v-if="teleportToBody" to="body">
<div v-if="modelValue" class="vap-player vap-overlay" @touchmove.prevent>
<button
v-if="showClose"
class="vap-close"
type="button"
aria-label="Close"
@click="close"
>
&times;
</button>
<div ref="containerRef" class="vap-stage" :class="`vap-fit-${fit}`"></div>
<div v-if="showStatus && loading" class="vap-status">Loading...</div>
<div v-else-if="showStatus && errorMessage" class="vap-status vap-status-error">
{{ errorMessage }}
</div>
</div>
</Teleport>
<div v-else-if="modelValue" class="vap-player vap-inline">
<div ref="containerRef" class="vap-stage" :class="`vap-fit-${fit}`"></div>
<div v-if="showStatus && loading" class="vap-status">Loading...</div>
<div v-else-if="showStatus && errorMessage" class="vap-status vap-status-error">
{{ errorMessage }}
</div>
</div>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
import Vap from 'video-animation-player'
import { getVapConfigFromMp4 } from '@/utils/vapMp4Config'
import { resolveProtectedAssetUrl } from '@/utils/protectedAssets.js'
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,
},
mode: {
type: String,
default: 'overlay',
validator: (value) => ['overlay', 'inline'].includes(value),
},
showClose: {
type: Boolean,
default: true,
},
showStatus: {
type: Boolean,
default: true,
},
fit: {
type: String,
default: 'contain',
validator: (value) => ['contain', 'cover', 'fill'].includes(value),
},
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
let cleanupPlayerVideoGuards = null
let vapInlinePlaybackPatched = false
const teleportToBody = computed(() => props.mode === 'overlay')
const resolvedSrc = computed(() => resolveProtectedAssetUrl(props.src))
function patchVapInlinePlayback() {
if (vapInlinePlaybackPatched) {
return
}
try {
const probePlayer = new Vap()
const proto = Object.getPrototypeOf(probePlayer)
if (!proto || typeof proto.initVideo !== 'function') {
return
}
if (proto.__likeiInlinePlaybackPatched) {
vapInlinePlaybackPatched = true
return
}
const originalInitVideo = proto.initVideo
proto.initVideo = function patchedInitVideo(...args) {
const result = originalInitVideo.apply(this, args)
enforceVideoPlaybackAttributes(this?.video, this?.options?.mute)
return result
}
proto.__likeiInlinePlaybackPatched = true
vapInlinePlaybackPatched = true
if (typeof probePlayer.destroy === 'function') {
probePlayer.destroy()
}
} catch (error) {
console.warn('Failed to patch VAP inline playback hooks:', error)
}
}
function enforceVideoPlaybackAttributes(video, shouldMute) {
if (!video) {
return
}
video.autoplay = false
video.controls = false
video.playsInline = true
video.setAttribute('playsinline', '')
video.setAttribute('webkit-playsinline', '')
video.setAttribute('x5-playsinline', '')
video.setAttribute('controlslist', 'nofullscreen noplaybackrate noremoteplayback')
video.setAttribute('disablepictureinpicture', '')
video.setAttribute('x-webkit-airplay', 'deny')
video.setAttribute('preload', 'auto')
if ('disablePictureInPicture' in video) {
video.disablePictureInPicture = true
}
if ('disableRemotePlayback' in video) {
video.disableRemotePlayback = true
}
// Hide the source video completely so iOS does not surface the raw mp4
// in the native fullscreen player instead of keeping the VAP in-canvas.
video.style.display = 'none'
video.style.position = ''
video.style.left = ''
video.style.top = ''
video.style.width = ''
video.style.height = ''
video.style.opacity = ''
video.style.pointerEvents = 'none'
video.style.zIndex = ''
if (shouldMute) {
video.muted = true
video.defaultMuted = true
video.volume = 0
video.setAttribute('muted', '')
} else {
video.defaultMuted = false
video.removeAttribute('muted')
}
}
function installInlinePlaybackGuards(video) {
if (!video) {
return () => {}
}
const keepInlinePlayback = () => {
enforceVideoPlaybackAttributes(video, props.mute)
if (typeof video.webkitSetPresentationMode === 'function') {
try {
video.webkitSetPresentationMode('inline')
} catch (error) {
console.warn('Failed to force inline presentation mode:', error)
}
}
}
keepInlinePlayback()
const guardEvents = [
'loadedmetadata',
'canplay',
'play',
'playing',
'webkitbeginfullscreen',
'webkitpresentationmodechanged',
]
guardEvents.forEach((eventName) => {
video.addEventListener(eventName, keepInlinePlayback)
})
return () => {
guardEvents.forEach((eventName) => {
video.removeEventListener(eventName, keepInlinePlayback)
})
}
}
function destroyPlayer() {
if (cleanupPlayerVideoGuards) {
cleanupPlayerVideoGuards()
cleanupPlayerVideoGuards = null
}
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 || !resolvedSrc.value) {
return
}
loading.value = true
try {
await nextTick()
patchVapInlinePlayback()
if (!containerRef.value || currentToken !== playToken) {
return
}
const config = await getVapConfigFromMp4(resolvedSrc.value)
if (!containerRef.value || currentToken !== playToken) {
return
}
player = new Vap().play({
container: containerRef.value,
src: resolvedSrc.value,
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,
})
enforceVideoPlaybackAttributes(player?.video, props.mute)
cleanupPlayerVideoGuards = installInlinePlaybackGuards(player?.video)
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-player {
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.vap-overlay {
position: fixed;
inset: 0;
z-index: 3000;
background: rgba(0, 0, 0, 0.72);
}
.vap-inline {
position: absolute;
inset: 0;
z-index: 0;
pointer-events: none;
}
.vap-stage {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
pointer-events: none;
}
.vap-stage :deep(canvas) {
display: block;
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.vap-fit-contain :deep(canvas) {
width: 100%;
height: 100%;
object-fit: contain;
}
.vap-fit-cover :deep(canvas),
.vap-fit-fill :deep(canvas) {
width: 100%;
height: 100%;
max-width: none;
max-height: none;
}
.vap-fit-cover :deep(canvas) {
object-fit: cover;
}
.vap-fit-fill :deep(canvas) {
object-fit: fill;
}
.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>