520 lines
12 KiB
Vue
520 lines
12 KiB
Vue
<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>
|