Files
taimed-international-app/components/video-player/index.vue

520 lines
12 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 class="video-player-container">
<!-- 视频播放器 -->
<view class="video-wrapper">
<video
v-if="!player.showError.value && player.videoUrl.value"
:id="videoId"
:src="player.videoUrl.value"
:poster="player.posterUrl.value"
:controls="true"
:page-gesture="true"
:vslide-gesture="true"
:vslide-gesture-in-fullscreen="true"
:show-center-play-btn="true"
:show-fullscreen-btn="true"
:show-play-btn="true"
:show-progress="true"
:enable-play-gesture="true"
:enable-progress-gesture="true"
:object-fit="objectFit"
:autoplay="true"
:initial-time="initialTime"
class="video-element"
@play="play"
@pause="pause"
@ended="ended"
@timeupdate="timeupdate"
@error="error"
@fullscreenchange="fullscreenChange"
@waiting="waiting"
@loadedmetadata="loadedmetadata"
/>
<!-- 倒计时遮罩 - 使用 cover-view APP 中显示 -->
<cover-view v-if="showCountdown" class="countdown-overlay">
<cover-view class="countdown-content">
<cover-view class="countdown-timer">
<cover-view class="countdown-number">{{ countdownRemaining }}</cover-view>
<cover-view class="countdown-text">秒后播放下一个视频</cover-view>
</cover-view>
<cover-view class="countdown-actions">
<cover-view class="btn-cancel" @click="cancelCountdown">取消</cover-view>
<cover-view class="btn-replay" @click="replayVideo">重新播放</cover-view>
<cover-view class="btn-next" @click="playNext">立即播放</cover-view>
</cover-view>
</cover-view>
</cover-view>
<!-- 错误显示 -->
<view v-if="player.showError.value" class="error-display">
<view class="error-content">
<view class="error-message">{{ player.errorMessage.value }}</view>
<button v-if="player.canRetry.value" class="btn-retry" @click="retry">重试</button>
</view>
</view>
<!-- 加载中 -->
<view v-if="player.isLoading.value" class="loading-display">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, onUnmounted, nextTick } from 'vue'
import { useVideoPlayer } from './composables/useVideoPlayer'
import { videoStorage } from '@/utils/videoStorage'
import type { IVideoPlayerProps, IVideoInfo } from '@/types/video'
// 页面生命周期
// @ts-ignore
const onHide = typeof uni !== 'undefined' ? uni.onHide || (() => {}) : () => {}
// Props
const props = withDefaults(defineProps<IVideoPlayerProps>(), {
countdownSeconds: 5,
showWatermark: false,
watermarkText: '本课程版权归天津众妙之门科技有限公司所有,翻版必究!'
})
// Emits
const emit = defineEmits<{
play: [videoInfo: IVideoInfo]
pause: [position: number]
fullscreen: [isFullscreen: boolean]
error: [error: { type: string; message: string }]
ended: [videoInfo: IVideoInfo]
timeupdate: [data: { currentTime: number; duration: number }]
'video-change': [newIndex: number]
'update:current-index': [newIndex: number]
'countdown-start': []
'countdown-cancel': []
}>()
// 使用 composables
const player = useVideoPlayer(props)
// 视频相关状态
const videoId = 'video-player-' + Date.now()
const videoContext = ref<any>(null)
const duration = ref(0)
const isPlaying = ref(false)
const isFullScreen = ref(false)
const showCountdown = ref(false)
const countdownRemaining = ref(props.countdownSeconds)
const objectFit = ref<'contain' | 'cover'>('contain')
const initialTime = ref(0)
let countdownTimer: any = null
// 生命周期
onMounted(async () => {
// 初始化视频上下文
videoContext.value = uni.createVideoContext(videoId)
// 加载视频信息
const videoInfo = await player.loadVideo(props.currentIndex)
if (videoInfo) {
// 设置初始播放位置loadVideo 内部已经调用过 getInitialPosition
// 这里再次获取是为了确保 initialTime 和 video 组件同步
const position = player.videoProgress.getInitialPosition(videoInfo)
initialTime.value = position
console.log('Set initial time:', position, 'for video:', videoInfo.id)
// 如果有历史进度,使用 seek 跳转(因为 initial-time 可能不生效)
if (position > 0) {
setTimeout(() => {
videoContext.value?.seek(position)
console.log('Seek to position:', position)
}, 500) // 延迟 500ms 确保视频已加载
}
// 开始保存进度
player.videoProgress.startSaving(videoInfo)
}
})
// 页面隐藏时保存进度
onHide(() => {
console.log('Page hidden, saving progress...')
if (player.currentVideoData.value && player.videoProgress.currentTime.value > 0) {
// 立即保存到本地
videoStorage.saveVideoPosition(
player.currentVideoData.value.id,
Math.floor(player.videoProgress.currentTime.value),
player.currentVideoData.value
)
// 保存到服务器
player.videoProgress.saveToServer(
player.currentVideoData.value.id,
Math.floor(player.videoProgress.currentTime.value)
)
}
})
onUnmounted(() => {
console.log('Component unmounting, saving progress...')
// 停止进度保存
player.videoProgress.stopSaving()
// 立即保存当前播放进度(同步保存到本地,异步保存到服务器)
if (player.currentVideoData.value && player.videoProgress.currentTime.value > 0) {
// 立即保存到本地
videoStorage.saveVideoPosition(
player.currentVideoData.value.id,
Math.floor(player.videoProgress.currentTime.value),
player.currentVideoData.value
)
// 保存到服务器(不等待完成)
player.videoProgress.saveToServer(
player.currentVideoData.value.id,
Math.floor(player.videoProgress.currentTime.value)
)
}
// 清除倒计时
if (countdownTimer) {
clearInterval(countdownTimer)
}
// 恢复竖屏
// #ifdef APP-PLUS
plus.screen.lockOrientation('portrait-primary')
// #endif
})
// 监听 currentIndex 变化(仅当外部主动修改时才触发)
watch(
() => props.currentIndex,
async (newIndex, oldIndex) => {
// 只有当新值与组件内部维护的索引不同时才处理
// 这样可以避免组件内部切换视频时触发 watch
if (newIndex !== player.currentIndex.value) {
await player.changeVideo(newIndex)
}
}
)
// 事件处理
const play = () => {
isPlaying.value = true
if (player.currentVideoData.value) {
emit('play', player.currentVideoData.value)
}
}
const pause = () => {
isPlaying.value = false
emit('pause', player.videoProgress.currentTime.value)
// 暂停时保存进度
if (player.currentVideoData.value && player.videoProgress.currentTime.value > 0) {
videoStorage.saveVideoPosition(
player.currentVideoData.value.id,
Math.floor(player.videoProgress.currentTime.value),
player.currentVideoData.value
)
player.videoProgress.saveToServer(
player.currentVideoData.value.id,
Math.floor(player.videoProgress.currentTime.value)
)
}
}
const ended = () => {
if (player.currentVideoData.value) {
emit('ended', player.currentVideoData.value)
// 检查是否有下一个视频
if (player.currentIndex.value < props.videoList.length - 1) {
startCountdown()
}
}
}
const timeupdate = (e: any) => {
const currentTime = e.detail.currentTime
const videoDuration = e.detail.duration
player.videoProgress.updateCurrentTime(currentTime)
duration.value = videoDuration
emit('timeupdate', {
currentTime,
duration: videoDuration
})
}
const error = (e: any) => {
console.error('Video error:', e)
player.showError.value = true
player.errorMessage.value = '视频播放出错,请稍后重试'
player.canRetry.value = true
emit('error', {
type: 'VIDEO_LOAD_ERROR',
message: '视频播放出错'
})
}
const fullscreenChange = (e: any) => {
isFullScreen.value = e.detail.fullScreen
// #ifdef APP-PLUS
if (e.detail.fullScreen) {
// 进入全屏,锁定横屏
setTimeout(() => {
plus.screen.lockOrientation('landscape-primary')
}, 100)
} else {
// 退出全屏,恢复竖屏
setTimeout(() => {
plus.screen.lockOrientation('portrait-primary')
}, 100)
}
// #endif
emit('fullscreen', isFullScreen.value)
}
const waiting = () => {
// 视频缓冲中
console.log('Video waiting...')
}
const loadedmetadata = (e: any) => {
duration.value = e.detail.duration
console.log('Video metadata loaded, duration:', duration.value)
}
// 倒计时相关
const startCountdown = () => {
showCountdown.value = true
countdownRemaining.value = props.countdownSeconds
emit('countdown-start')
countdownTimer = setInterval(() => {
countdownRemaining.value--
if (countdownRemaining.value <= 0) {
clearInterval(countdownTimer)
playNext()
}
}, 1000)
}
const cancelCountdown = () => {
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
showCountdown.value = false
emit('countdown-cancel')
}
const replayVideo = () => {
cancelCountdown()
// 重置播放位置并播放
initialTime.value = 0
player.videoProgress.updateCurrentTime(0)
// 重新加载当前视频
player.loadVideo(player.currentIndex.value)
}
const playNext = async () => {
cancelCountdown()
const nextIndex = player.currentIndex.value + 1
if (nextIndex < props.videoList.length) {
await player.changeVideo(nextIndex)
// 通知父组件更新 currentIndex
emit('update:current-index', nextIndex)
emit('video-change', nextIndex)
// 获取新视频的初始播放位置
if (player.currentVideoData.value) {
initialTime.value = player.videoProgress.getInitialPosition(player.currentVideoData.value)
player.videoProgress.startSaving(player.currentVideoData.value)
}
}
}
const retry = () => {
player.retry()
}
</script>
<style lang="scss" scoped>
.video-player-container {
width: 100%;
height: 100%;
position: relative;
background-color: #000;
}
.video-wrapper {
width: 100%;
height: 100%;
position: relative;
}
.video-element {
width: 100%;
height: 400rpx;
}
.error-display {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.8);
}
.error-content {
text-align: center;
padding: 40rpx;
}
.error-icon {
font-size: 80rpx;
margin-bottom: 20rpx;
}
.error-message {
color: #fff;
font-size: 28rpx;
line-height: 1.6;
margin-bottom: 30rpx;
}
.btn-retry {
background-color: #1989fa;
color: #fff;
border: none;
padding: 20rpx 40rpx;
border-radius: 8rpx;
font-size: 28rpx;
}
.loading-display {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: rgba(0, 0, 0, 0.5);
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top-color: #fff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.loading-text {
color: #fff;
font-size: 28rpx;
margin-top: 20rpx;
}
.countdown-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.8);
z-index: 9999;
}
.countdown-content {
position: absolute;
top: 50%;
left: 50%;
width: 600rpx;
margin-left: -300rpx;
margin-top: -150rpx;
text-align: center;
}
.countdown-timer {
margin-bottom: 60rpx;
}
.countdown-number {
color: #fff;
font-size: 32px;
font-weight: bold;
line-height: 1.2;
text-align: center;
}
.countdown-text {
color: #fff;
font-size: 16px;
line-height: 50rpx;
text-align: center;
margin-top: 20rpx;
}
.countdown-actions {
display: flex;
flex-direction: row;
justify-content: center;
align-items: center;
width: 100%;
}
.btn-cancel,
.btn-replay,
.btn-next {
padding: 0 20px;
text-align: center;
border-radius: 4px;
font-size: 14px;
height: 26px;
line-height: 26px;
color: #fff;
margin: 0 5px;
background-color: #666;
}
// .btn-cancel {
// background-color: #666;
// }
// .btn-replay {
// background-color: #ff9800;
// }
// .btn-next {
// background-color: #1989fa;
// }
</style>