450 lines
9.4 KiB
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"
|
|
>
|
|
×
|
|
</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>
|