更新:课程视频播放改成原生video组件
This commit is contained in:
519
components/video-player/index.vue
Normal file
519
components/video-player/index.vue
Normal file
@@ -0,0 +1,519 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user