FrontTools/VideoViewer/VideoViewer.vue
Rain 3ab1f2db24 [入库]初次入库各种工具
- DBManager DB创建管理器
- FormatTimeTool
- ImageViewer工具
- InputArear工具
- OverlayPage工具
- 下拉刷新容器工具
- 视频查看器工具
2026-04-02 16:09:00 +08:00

671 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>