671 lines
16 KiB
Vue
671 lines
16 KiB
Vue
|
|
<template>
|
|||
|
|
<view
|
|||
|
|
v-if="visible"
|
|||
|
|
class="vv-root"
|
|||
|
|
:class="{ 'vv-root--closing': animPhase === 'exiting' }"
|
|||
|
|
:style="{ ...rootStyle, ...animationStyles.root }"
|
|||
|
|
@click="animPhase !== 'exiting' && onOverlayClick()"
|
|||
|
|
>
|
|||
|
|
<view
|
|||
|
|
class="vv-backdrop"
|
|||
|
|
:class="{ 'vv-backdrop--expanded': expanded, 'vv-backdrop--closing': animPhase === 'exiting' }"
|
|||
|
|
:style="animationStyles.backdrop"
|
|||
|
|
@click.stop
|
|||
|
|
/>
|
|||
|
|
<view
|
|||
|
|
class="vv-transform-wrap"
|
|||
|
|
:style="{ ...transformWrapStyle, ...animationStyles.transformWrap }"
|
|||
|
|
@click.stop="animPhase !== 'exiting' && requestClose()"
|
|||
|
|
>
|
|||
|
|
<!-- ✨ 结构稳如磐石,不再依据 expanded 动态销毁,彻底根除闪屏 -->
|
|||
|
|
<view
|
|||
|
|
v-if="showSingleContent"
|
|||
|
|
class="vv-pinch-wrap"
|
|||
|
|
:style="pinchWrapStyle"
|
|||
|
|
>
|
|||
|
|
<!-- 封面图坚挺防线,直到视频放出第一帧才隐退 -->
|
|||
|
|
<image
|
|||
|
|
v-if="currentItem?.thumb_url && !isVideoStarted"
|
|||
|
|
class="vv-thumb"
|
|||
|
|
:src="currentItem.thumb_url"
|
|||
|
|
mode="aspectFit"
|
|||
|
|
/>
|
|||
|
|
<!-- 视频常驻 DOM,但用隐藏类名代替销毁 -->
|
|||
|
|
<video
|
|||
|
|
:id="getVideoId(currentIndex)"
|
|||
|
|
class="vv-video"
|
|||
|
|
:class="{ 'vv-video--hide': animPhase !== 'displaying' }"
|
|||
|
|
:src="currentItem?.media_url"
|
|||
|
|
object-fit="contain"
|
|||
|
|
:autoplay="false"
|
|||
|
|
playsinline="true"
|
|||
|
|
webkit-playsinline="true"
|
|||
|
|
x5-playsinline="true"
|
|||
|
|
x5-video-player-type="h5"
|
|||
|
|
x5-video-player-fullscreen="false"
|
|||
|
|
:controls="false"
|
|||
|
|
:show-center-play-btn="false"
|
|||
|
|
:show-fullscreen-btn="false"
|
|||
|
|
:show-play-btn="false"
|
|||
|
|
:show-progress="false"
|
|||
|
|
:enable-progress-gesture="false"
|
|||
|
|
:loop="false"
|
|||
|
|
:muted="false"
|
|||
|
|
@play="onVideoPlay"
|
|||
|
|
@pause="onVideoPause"
|
|||
|
|
@ended="onVideoEnded"
|
|||
|
|
@waiting="onVideoWaiting"
|
|||
|
|
@error="onVideoError"
|
|||
|
|
/>
|
|||
|
|
</view>
|
|||
|
|
|
|||
|
|
<view v-if="showSwiper" class="vv-slides-view">
|
|||
|
|
<view class="vv-slides-track" :style="slidesTrackStyle">
|
|||
|
|
<view
|
|||
|
|
v-for="(item, i) in items"
|
|||
|
|
:key="i"
|
|||
|
|
class="vv-slide"
|
|||
|
|
:style="{ width: slideWidthPx + 'px' }"
|
|||
|
|
v-show="isSlideVisible(i)"
|
|||
|
|
@touchstart="onSlideTouchStart"
|
|||
|
|
@touchmove="onSlideTouchMove"
|
|||
|
|
@touchend="onSlideTouchEnd"
|
|||
|
|
@touchcancel="onSlideTouchEnd"
|
|||
|
|
>
|
|||
|
|
<view class="vv-pinch-wrap">
|
|||
|
|
<image
|
|||
|
|
v-if="item?.thumb_url && (i !== currentIndex || !isVideoStarted)"
|
|||
|
|
class="vv-thumb"
|
|||
|
|
:src="item.thumb_url"
|
|||
|
|
mode="aspectFit"
|
|||
|
|
/>
|
|||
|
|
<video
|
|||
|
|
v-if="i === currentIndex"
|
|||
|
|
:id="getVideoId(i)"
|
|||
|
|
class="vv-video"
|
|||
|
|
:class="{ 'vv-video--hide': animPhase !== 'displaying' }"
|
|||
|
|
:src="item.media_url"
|
|||
|
|
object-fit="contain"
|
|||
|
|
:autoplay="false"
|
|||
|
|
playsinline="true"
|
|||
|
|
webkit-playsinline="true"
|
|||
|
|
x5-playsinline="true"
|
|||
|
|
x5-video-player-type="h5"
|
|||
|
|
x5-video-player-fullscreen="false"
|
|||
|
|
:controls="false"
|
|||
|
|
:show-center-play-btn="false"
|
|||
|
|
:show-fullscreen-btn="false"
|
|||
|
|
:show-play-btn="false"
|
|||
|
|
:show-progress="false"
|
|||
|
|
:enable-progress-gesture="false"
|
|||
|
|
:loop="false"
|
|||
|
|
@play="onVideoPlay"
|
|||
|
|
@pause="onVideoPause"
|
|||
|
|
@ended="onVideoEnded"
|
|||
|
|
@waiting="onVideoWaiting"
|
|||
|
|
@error="onVideoError"
|
|||
|
|
/>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
<view v-show="showLoading" class="vv-loading">
|
|||
|
|
<view class="vv-loading-spinner"></view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
<view v-show="showSwiper" class="vv-dots-wrap" @click.stop="requestClose">
|
|||
|
|
<view class="vv-dots">
|
|||
|
|
<text class="vv-dots__text">{{ currentIndex + 1 }} / {{ items.length }}</text>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</view>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<script setup lang="ts">
|
|||
|
|
import { ref, computed, watch, inject, nextTick, onUnmounted, shallowRef } from 'vue'
|
|||
|
|
|
|||
|
|
export type InitialRect = { left: number; top: number; width: number; height: number }
|
|||
|
|
export type VideoItem = { media_url: string; thumb_url?: string }
|
|||
|
|
|
|||
|
|
const SLIDE_DRAG_THRESHOLD = 60
|
|||
|
|
const SLIDE_ANIMATION_MS = 200
|
|||
|
|
const SLIDE_EASE = 'cubic-bezier(0.16, 1, 0.3, 1)'
|
|||
|
|
const SLIDE_DRAG_FACTOR = 1.35
|
|||
|
|
const OPEN_BLUR_S = 0.25
|
|||
|
|
const OPEN_ANIMATION_EASE = 'cubic-bezier(0.25, 0.46, 0.45, 0.94)'
|
|||
|
|
const EXIT_BLUR_S = 0.25
|
|||
|
|
|
|||
|
|
const props = withDefaults(
|
|||
|
|
defineProps<{
|
|||
|
|
visible: boolean
|
|||
|
|
items: VideoItem[]
|
|||
|
|
current?: number
|
|||
|
|
initialRect?: InitialRect | null
|
|||
|
|
zIndex?: number
|
|||
|
|
}>(),
|
|||
|
|
{ current: 0, initialRect: null, zIndex: 9999 }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{ close: [] }>()
|
|||
|
|
|
|||
|
|
const tabRootBackPress = inject<{
|
|||
|
|
register: (h: { check: () => boolean; close: () => void }) => () => void
|
|||
|
|
}>('tabRootBackPress', undefined as any)
|
|||
|
|
|
|||
|
|
type AnimPhase = 'entering' | 'displaying' | 'exiting'
|
|||
|
|
const animPhase = ref<AnimPhase | null>(null)
|
|||
|
|
const enterPaintReady = ref(false)
|
|||
|
|
const enterTargetPainted = ref(false)
|
|||
|
|
let enterCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
|||
|
|
let exitCompleteTimer: ReturnType<typeof setTimeout> | null = null
|
|||
|
|
|
|||
|
|
const expanded = ref(false)
|
|||
|
|
const currentIndex = ref(0)
|
|||
|
|
const windowSize = ref({ width: 375, height: 667 })
|
|||
|
|
const isVideoPlaying = ref(false)
|
|||
|
|
const isVideoLoading = ref(false)
|
|||
|
|
const isVideoStarted = ref(false)
|
|||
|
|
|
|||
|
|
const videoContextRef = shallowRef<UniApp.VideoContext | null>(null)
|
|||
|
|
|
|||
|
|
let unregisterBack: (() => void) | null = null
|
|||
|
|
let videoContextMap = new Map<number, UniApp.VideoContext>()
|
|||
|
|
|
|||
|
|
const uid = Date.now()
|
|||
|
|
|
|||
|
|
function getVideoId(index: number): string {
|
|||
|
|
return `vv-video-${uid}-${index}`
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function getVideoContext(index: number): UniApp.VideoContext | null {
|
|||
|
|
if (!videoContextMap.has(index)) {
|
|||
|
|
const id = getVideoId(index)
|
|||
|
|
videoContextMap.set(index, uni.createVideoContext(id))
|
|||
|
|
}
|
|||
|
|
return videoContextMap.get(index) || null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const showLoading = computed(() => isVideoLoading.value && !isVideoPlaying.value)
|
|||
|
|
|
|||
|
|
function clearAnimTimers() {
|
|||
|
|
if (enterCompleteTimer) {
|
|||
|
|
clearTimeout(enterCompleteTimer)
|
|||
|
|
enterCompleteTimer = null
|
|||
|
|
}
|
|||
|
|
if (exitCompleteTimer) {
|
|||
|
|
clearTimeout(exitCompleteTimer)
|
|||
|
|
exitCompleteTimer = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function nextFrame(fn: () => void) {
|
|||
|
|
if (typeof requestAnimationFrame !== 'undefined') requestAnimationFrame(fn)
|
|||
|
|
else setTimeout(fn, 0)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startEnterPhase() {
|
|||
|
|
animPhase.value = 'entering'
|
|||
|
|
enterPaintReady.value = false
|
|||
|
|
enterTargetPainted.value = false
|
|||
|
|
clearAnimTimers()
|
|||
|
|
nextFrame(() => {
|
|||
|
|
enterPaintReady.value = true
|
|||
|
|
nextFrame(() => { enterTargetPainted.value = true })
|
|||
|
|
})
|
|||
|
|
enterCompleteTimer = setTimeout(() => {
|
|||
|
|
enterCompleteTimer = null
|
|||
|
|
animPhase.value = 'displaying'
|
|||
|
|
// ✨ 动画结束,节点归位,由代码安全唤起播放
|
|||
|
|
playCurrentVideo()
|
|||
|
|
}, OPEN_BLUR_S * 1000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function startExitPhase(onComplete: () => void) {
|
|||
|
|
animPhase.value = 'exiting'
|
|||
|
|
clearAnimTimers()
|
|||
|
|
exitCompleteTimer = setTimeout(() => {
|
|||
|
|
exitCompleteTimer = null
|
|||
|
|
onComplete()
|
|||
|
|
}, EXIT_BLUR_S * 1000)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function playCurrentVideo() {
|
|||
|
|
nextTick(() => {
|
|||
|
|
const ctx = getVideoContext(currentIndex.value)
|
|||
|
|
if (ctx) {
|
|||
|
|
ctx.play()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function stopAllVideo() {
|
|||
|
|
videoContextMap.forEach(ctx => {
|
|||
|
|
if (ctx) {
|
|||
|
|
try { ctx.pause() } catch {}
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onVideoPlay() {
|
|||
|
|
isVideoPlaying.value = true
|
|||
|
|
isVideoLoading.value = false
|
|||
|
|
isVideoStarted.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onVideoPause() {
|
|||
|
|
isVideoPlaying.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onVideoEnded() {
|
|||
|
|
isVideoPlaying.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onVideoWaiting() {
|
|||
|
|
isVideoLoading.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onVideoError(e: any) {
|
|||
|
|
console.error('[VideoViewer] 视频播放错误:', e)
|
|||
|
|
isVideoLoading.value = false
|
|||
|
|
isVideoPlaying.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dragOffsetPx = ref(0)
|
|||
|
|
const slideTransitionOn = ref(false)
|
|||
|
|
let panStart: { startX: number; baseDragOffset: number } | null = null
|
|||
|
|
|
|||
|
|
const slideWidthPx = computed(() => windowSize.value.width)
|
|||
|
|
const slidesTrackStyle = computed(() => {
|
|||
|
|
const w = windowSize.value.width
|
|||
|
|
const n = props.items?.length ?? 0
|
|||
|
|
const tx = n <= 0 ? 0 : -currentIndex.value * w + dragOffsetPx.value
|
|||
|
|
return {
|
|||
|
|
width: `${n * w}px`,
|
|||
|
|
transform: `translateX(${tx}px)`,
|
|||
|
|
transition: slideTransitionOn.value ? `transform ${SLIDE_ANIMATION_MS}ms ${SLIDE_EASE}` : 'none'
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function clamp(v: number, min: number, max: number) {
|
|||
|
|
return Math.max(min, Math.min(max, v))
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function isSlideVisible(i: number) {
|
|||
|
|
const c = currentIndex.value
|
|||
|
|
return i >= c - 1 && i <= c + 1
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pinchWrapStyle = computed(() => ({ transform: 'translate(0, 0) scale(1)' }))
|
|||
|
|
|
|||
|
|
function handleSlideDrag(t: { clientX: number }) {
|
|||
|
|
if (!panStart) return
|
|||
|
|
const dx = t.clientX - panStart.startX
|
|||
|
|
const w = windowSize.value.width
|
|||
|
|
dragOffsetPx.value = clamp((panStart.baseDragOffset ?? 0) + dx * SLIDE_DRAG_FACTOR, -w, w)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function goToSlide(delta: -1 | 1) {
|
|||
|
|
const n = props.items?.length || 0
|
|||
|
|
if (n <= 1) return
|
|||
|
|
const oldIdx = currentIndex.value
|
|||
|
|
const nextIdx = delta < 0 ? Math.max(0, oldIdx - 1) : Math.min(n - 1, oldIdx + 1)
|
|||
|
|
if (nextIdx === oldIdx) return
|
|||
|
|
|
|||
|
|
stopAllVideo()
|
|||
|
|
isVideoPlaying.value = false
|
|||
|
|
isVideoStarted.value = false
|
|||
|
|
|
|||
|
|
currentIndex.value = nextIdx
|
|||
|
|
slideTransitionOn.value = true
|
|||
|
|
dragOffsetPx.value = 0
|
|||
|
|
|
|||
|
|
setTimeout(() => {
|
|||
|
|
slideTransitionOn.value = false
|
|||
|
|
if (animPhase.value === 'displaying') {
|
|||
|
|
playCurrentVideo()
|
|||
|
|
}
|
|||
|
|
}, SLIDE_ANIMATION_MS)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSlideTouchStart(e: any) {
|
|||
|
|
if (e.touches.length === 1) {
|
|||
|
|
panStart = { startX: e.touches[0].clientX, baseDragOffset: dragOffsetPx.value }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSlideTouchMove(e: any) {
|
|||
|
|
if (e.touches.length === 1 && panStart) {
|
|||
|
|
e.preventDefault()
|
|||
|
|
handleSlideDrag(e.touches[0])
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onSlideTouchEnd(e: any) {
|
|||
|
|
if (e.touches.length === 0) {
|
|||
|
|
const n = props.items?.length ?? 0
|
|||
|
|
if (n <= 1) { panStart = null; return }
|
|||
|
|
const th = SLIDE_DRAG_THRESHOLD
|
|||
|
|
if (dragOffsetPx.value > th && currentIndex.value > 0) goToSlide(-1)
|
|||
|
|
else if (dragOffsetPx.value < -th && currentIndex.value < n - 1) goToSlide(1)
|
|||
|
|
else {
|
|||
|
|
slideTransitionOn.value = true
|
|||
|
|
dragOffsetPx.value = 0
|
|||
|
|
setTimeout(() => { slideTransitionOn.value = false }, SLIDE_ANIMATION_MS)
|
|||
|
|
}
|
|||
|
|
panStart = null
|
|||
|
|
} else if (e.touches.length === 1 && panStart) {
|
|||
|
|
panStart = { ...panStart, baseDragOffset: dragOffsetPx.value }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const isMulti = computed(() => (props.items?.length || 0) > 1)
|
|||
|
|
|
|||
|
|
const currentItem = computed(() => {
|
|||
|
|
const list = props.items || []
|
|||
|
|
const i = Math.max(0, Math.min(currentIndex.value, list.length - 1))
|
|||
|
|
return list[i] || null
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// ✨ 单轨与多轨完全基于数据长度固定,生命周期内再无结构突变,杜绝 OS 重排闪屏
|
|||
|
|
const showSwiper = computed(() => isMulti.value)
|
|||
|
|
const showSingleContent = computed(() => !isMulti.value)
|
|||
|
|
|
|||
|
|
const rootStyle = computed(() => ({ zIndex: props.zIndex }))
|
|||
|
|
|
|||
|
|
const animationStyles = computed(() => {
|
|||
|
|
const phase = animPhase.value
|
|||
|
|
const transOpen = `opacity ${OPEN_BLUR_S}s ${OPEN_ANIMATION_EASE}`
|
|||
|
|
const transExit = `opacity ${EXIT_BLUR_S}s ease-out`
|
|||
|
|
|
|||
|
|
if (phase === 'exiting') {
|
|||
|
|
return {
|
|||
|
|
root: { transition: transExit, opacity: 0, pointerEvents: 'none' as const },
|
|||
|
|
backdrop: { transition: `opacity ${EXIT_BLUR_S}s ease-out`, opacity: 0 },
|
|||
|
|
transformWrap: { transition: transExit, opacity: 0, pointerEvents: 'none' as const }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (phase === 'entering' || phase === 'displaying') {
|
|||
|
|
const applyTarget = phase === 'displaying' || (phase === 'entering' && enterPaintReady.value)
|
|||
|
|
if (!applyTarget) {
|
|||
|
|
return { root: { opacity: 0 }, backdrop: { opacity: 0 }, transformWrap: { opacity: 0 } }
|
|||
|
|
}
|
|||
|
|
return {
|
|||
|
|
root: { transition: transOpen, opacity: 1 },
|
|||
|
|
backdrop: { transition: transOpen, opacity: 1 },
|
|||
|
|
transformWrap: { transition: transOpen, opacity: 1 }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return { root: {}, backdrop: {}, transformWrap: {} }
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
const transformWrapStyle = computed(() => {
|
|||
|
|
return {
|
|||
|
|
left: '0',
|
|||
|
|
top: '0',
|
|||
|
|
right: '0',
|
|||
|
|
bottom: '0',
|
|||
|
|
width: '100%',
|
|||
|
|
height: '100%',
|
|||
|
|
opacity: 0
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
function registerBack() {
|
|||
|
|
if (tabRootBackPress) {
|
|||
|
|
unregisterBack = tabRootBackPress.register({
|
|||
|
|
check: () => props.visible,
|
|||
|
|
close
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function unregisterBackHandler() {
|
|||
|
|
if (unregisterBack) {
|
|||
|
|
unregisterBack()
|
|||
|
|
unregisterBack = null
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function onOverlayClick() {
|
|||
|
|
requestClose()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function requestClose() {
|
|||
|
|
if (animPhase.value === 'exiting') return
|
|||
|
|
stopAllVideo()
|
|||
|
|
isVideoPlaying.value = false
|
|||
|
|
isVideoLoading.value = false
|
|||
|
|
isVideoStarted.value = false
|
|||
|
|
nextFrame(() => {
|
|||
|
|
startExitPhase(() => {
|
|||
|
|
expanded.value = false
|
|||
|
|
animPhase.value = null
|
|||
|
|
emit('close')
|
|||
|
|
})
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function close() {
|
|||
|
|
requestClose()
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
watch(
|
|||
|
|
() => props.visible,
|
|||
|
|
(v) => {
|
|||
|
|
if (v) {
|
|||
|
|
try {
|
|||
|
|
const sys = uni.getSystemInfoSync()
|
|||
|
|
windowSize.value = {
|
|||
|
|
width: sys.windowWidth ?? sys.screenWidth ?? 375,
|
|||
|
|
height: sys.windowHeight ?? sys.screenHeight ?? 667
|
|||
|
|
}
|
|||
|
|
} catch {
|
|||
|
|
windowSize.value = { width: 375, height: 667 }
|
|||
|
|
}
|
|||
|
|
currentIndex.value = Math.max(0, Math.min(props.current, (props.items?.length || 1) - 1))
|
|||
|
|
isVideoPlaying.value = false
|
|||
|
|
isVideoLoading.value = false
|
|||
|
|
isVideoStarted.value = false
|
|||
|
|
videoContextMap.clear()
|
|||
|
|
expanded.value = !props.initialRect || props.initialRect.width <= 0 || props.initialRect.height <= 0
|
|||
|
|
registerBack()
|
|||
|
|
if (props.initialRect && props.initialRect.width > 0 && props.initialRect.height > 0) {
|
|||
|
|
setTimeout(() => {
|
|||
|
|
expanded.value = true
|
|||
|
|
startEnterPhase()
|
|||
|
|
}, 50)
|
|||
|
|
} else {
|
|||
|
|
expanded.value = true
|
|||
|
|
startEnterPhase()
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
expanded.value = false
|
|||
|
|
animPhase.value = null
|
|||
|
|
enterPaintReady.value = false
|
|||
|
|
enterTargetPainted.value = false
|
|||
|
|
isVideoPlaying.value = false
|
|||
|
|
isVideoLoading.value = false
|
|||
|
|
isVideoStarted.value = false
|
|||
|
|
clearAnimTimers()
|
|||
|
|
dragOffsetPx.value = 0
|
|||
|
|
slideTransitionOn.value = false
|
|||
|
|
panStart = null
|
|||
|
|
stopAllVideo()
|
|||
|
|
videoContextMap.clear()
|
|||
|
|
unregisterBackHandler()
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
{ immediate: true }
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
watch(() => props.current, (v) => {
|
|||
|
|
currentIndex.value = Math.max(0, Math.min(v, (props.items?.length || 1) - 1))
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
onUnmounted(() => {
|
|||
|
|
clearAnimTimers()
|
|||
|
|
unregisterBackHandler()
|
|||
|
|
stopAllVideo()
|
|||
|
|
videoContextMap.clear()
|
|||
|
|
})
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.vv-root {
|
|||
|
|
position: fixed;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-root--closing {
|
|||
|
|
opacity: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-backdrop {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
background: rgba(0, 0, 0, 0.4);
|
|||
|
|
opacity: 0;
|
|||
|
|
transition: opacity 0.3s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-backdrop--expanded {
|
|||
|
|
background: rgba(0, 0, 0, 0.94);
|
|||
|
|
opacity: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-backdrop--closing {
|
|||
|
|
opacity: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-transform-wrap {
|
|||
|
|
position: fixed;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
z-index: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-pinch-wrap {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
position: relative;
|
|||
|
|
display: block;
|
|||
|
|
touch-action: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-video {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: block;
|
|||
|
|
z-index: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ✨ 修复:进入和退出期间,利用极端的缩放和位移将 video 丢出屏幕 */
|
|||
|
|
.vv-video--hide {
|
|||
|
|
transform: translateX(-9999px) scale(0.001);
|
|||
|
|
opacity: 0;
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-thumb {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
display: block;
|
|||
|
|
z-index: 2;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-slides-view {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
top: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-slides-track {
|
|||
|
|
display: flex;
|
|||
|
|
height: 100%;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
will-change: transform;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-slide {
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
height: 100%;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-dots-wrap {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
z-index: 2;
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
padding-bottom: calc(60rpx + env(safe-area-inset-bottom));
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-dots {
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-dots__text {
|
|||
|
|
font-size: 28rpx;
|
|||
|
|
color: rgba(255, 255, 255, 0.9);
|
|||
|
|
background: rgba(0, 0, 0, 0.4);
|
|||
|
|
padding: 8rpx 24rpx;
|
|||
|
|
border-radius: 24rpx;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-loading {
|
|||
|
|
position: absolute;
|
|||
|
|
left: 50%;
|
|||
|
|
top: 50%;
|
|||
|
|
transform: translate(-50%, -50%);
|
|||
|
|
z-index: 10;
|
|||
|
|
pointer-events: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.vv-loading-spinner {
|
|||
|
|
width: 60rpx;
|
|||
|
|
height: 60rpx;
|
|||
|
|
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
|||
|
|
border-top-color: #fff;
|
|||
|
|
border-radius: 50%;
|
|||
|
|
animation: vv-spin 0.8s linear infinite;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
@keyframes vv-spin {
|
|||
|
|
to { transform: rotate(360deg); }
|
|||
|
|
}
|
|||
|
|
</style>
|