feat: 更新视频播放器组件,添加章节视频功能并优化返回逻辑
This commit is contained in:
@@ -1,162 +0,0 @@
|
||||
<template>
|
||||
<view class="control-bar" :class="{ 'control-bar--visible': showControls }" @click.stop>
|
||||
<view class="control-bar__top">
|
||||
<!-- 进度条 -->
|
||||
<ProgressBar
|
||||
:current="currentTime"
|
||||
:duration="duration"
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="control-bar__bottom">
|
||||
<!-- 播放/暂停按钮 -->
|
||||
<view class="control-button" @click="togglePlay">
|
||||
<text class="icon">{{ isPlaying ? '⏸' : '▶️' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 时间显示 -->
|
||||
<view class="time-display">
|
||||
<text>{{ formatTime(currentTime) }} / {{ formatTime(duration) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="control-bar__right">
|
||||
<!-- 速率控制 -->
|
||||
<SpeedControl
|
||||
:rate="playbackRate"
|
||||
@change="handleSpeedChange"
|
||||
/>
|
||||
|
||||
<!-- 音量控制 -->
|
||||
<VolumeControl
|
||||
:volume="volume"
|
||||
@change="handleVolumeChange"
|
||||
/>
|
||||
|
||||
<!-- 全屏按钮 -->
|
||||
<view class="control-button" @click="toggleFullscreen">
|
||||
<text class="icon">{{ isFullscreen ? '⛶' : '⛶' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
import SpeedControl from './SpeedControl.vue'
|
||||
import VolumeControl from './VolumeControl.vue'
|
||||
|
||||
interface Props {
|
||||
currentTime: number
|
||||
duration: number
|
||||
isPlaying: boolean
|
||||
isFullscreen: boolean
|
||||
playbackRate: number
|
||||
volume: number
|
||||
showControls: boolean
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
isPlaying: false,
|
||||
isFullscreen: false,
|
||||
playbackRate: 1.0,
|
||||
volume: 100,
|
||||
showControls: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
'toggle-play': []
|
||||
'seek': [time: number]
|
||||
'toggle-fullscreen': []
|
||||
'speed-change': [rate: number]
|
||||
'volume-change': [volume: number]
|
||||
}>()
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!seconds || isNaN(seconds)) return '00:00'
|
||||
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const togglePlay = () => {
|
||||
emit('toggle-play')
|
||||
}
|
||||
|
||||
const handleSeek = (time: number) => {
|
||||
emit('seek', time)
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
emit('toggle-fullscreen')
|
||||
}
|
||||
|
||||
const handleSpeedChange = (rate: number) => {
|
||||
emit('speed-change', rate)
|
||||
}
|
||||
|
||||
const handleVolumeChange = (volume: number) => {
|
||||
emit('volume-change', volume)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.control-bar {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.8), transparent);
|
||||
padding: 20rpx;
|
||||
opacity: 0;
|
||||
transition: opacity 0.3s;
|
||||
z-index: 10;
|
||||
|
||||
&--visible {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.control-bar__top {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.control-bar__bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.control-bar__right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.control-button {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
font-size: 40rpx;
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.time-display {
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -1,183 +0,0 @@
|
||||
<template>
|
||||
<view class="progress-bar-container">
|
||||
<view
|
||||
class="progress-bar"
|
||||
@touchstart="handleTouchStart"
|
||||
@touchmove="handleTouchMove"
|
||||
@touchend="handleTouchEnd"
|
||||
@click="handleClick"
|
||||
>
|
||||
<!-- 背景轨道 -->
|
||||
<view class="track"></view>
|
||||
|
||||
<!-- 已播放进度 -->
|
||||
<view class="played" :style="{ width: playedPercent + '%' }"></view>
|
||||
|
||||
<!-- 拖动滑块 -->
|
||||
<view class="thumb" :style="{ left: playedPercent + '%' }"></view>
|
||||
|
||||
<!-- 时间提示 -->
|
||||
<view
|
||||
v-if="showTimeTooltip"
|
||||
class="time-tooltip"
|
||||
:style="{ left: tooltipPosition + '%' }"
|
||||
>
|
||||
{{ formatTime(tooltipTime) }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
current: number
|
||||
duration: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
current: 0,
|
||||
duration: 0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
seek: [time: number]
|
||||
}>()
|
||||
|
||||
const isDragging = ref(false)
|
||||
const showTimeTooltip = ref(false)
|
||||
const tooltipPosition = ref(0)
|
||||
const tooltipTime = ref(0)
|
||||
const progressBarWidth = ref(0)
|
||||
|
||||
const playedPercent = computed(() => {
|
||||
if (!props.duration) return 0
|
||||
return (props.current / props.duration) * 100
|
||||
})
|
||||
|
||||
const formatTime = (seconds: number): string => {
|
||||
if (!seconds || isNaN(seconds)) return '00:00'
|
||||
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = Math.floor(seconds % 60)
|
||||
|
||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const handleTouchStart = (e: any) => {
|
||||
isDragging.value = true
|
||||
showTimeTooltip.value = true
|
||||
|
||||
// 获取进度条宽度
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('.progress-bar').boundingClientRect()
|
||||
query.exec((res) => {
|
||||
if (res[0]) {
|
||||
progressBarWidth.value = res[0].width
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchMove = (e: any) => {
|
||||
if (!isDragging.value || !progressBarWidth.value) return
|
||||
|
||||
const touch = e.touches[0]
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('.progress-bar').boundingClientRect()
|
||||
query.exec((res) => {
|
||||
if (res[0]) {
|
||||
const rect = res[0]
|
||||
const offsetX = touch.clientX - rect.left
|
||||
const percent = Math.max(0, Math.min(100, (offsetX / rect.width) * 100))
|
||||
|
||||
tooltipPosition.value = percent
|
||||
tooltipTime.value = (percent / 100) * props.duration
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const handleTouchEnd = (e: any) => {
|
||||
if (!isDragging.value) return
|
||||
|
||||
isDragging.value = false
|
||||
showTimeTooltip.value = false
|
||||
|
||||
// 发送 seek 事件
|
||||
emit('seek', tooltipTime.value)
|
||||
}
|
||||
|
||||
const handleClick = (e: any) => {
|
||||
const query = uni.createSelectorQuery()
|
||||
query.select('.progress-bar').boundingClientRect()
|
||||
query.exec((res) => {
|
||||
if (res[0]) {
|
||||
const rect = res[0]
|
||||
const offsetX = e.detail.x - rect.left
|
||||
const percent = Math.max(0, Math.min(100, (offsetX / rect.width) * 100))
|
||||
const time = (percent / 100) * props.duration
|
||||
|
||||
emit('seek', time)
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.progress-bar-container {
|
||||
width: 100%;
|
||||
padding: 10rpx 0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 6rpx;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.track {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(255, 255, 255, 0.3);
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
|
||||
.played {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
background-color: #1989fa;
|
||||
border-radius: 3rpx;
|
||||
transition: width 0.1s;
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 24rpx;
|
||||
height: 24rpx;
|
||||
background-color: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
|
||||
transition: left 0.1s;
|
||||
}
|
||||
|
||||
.time-tooltip {
|
||||
position: absolute;
|
||||
bottom: 30rpx;
|
||||
transform: translateX(-50%);
|
||||
padding: 8rpx 16rpx;
|
||||
background-color: rgba(0, 0, 0, 0.8);
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
border-radius: 8rpx;
|
||||
white-space: nowrap;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -1,122 +0,0 @@
|
||||
<template>
|
||||
<view class="speed-control">
|
||||
<view class="speed-button" @click="toggleMenu">
|
||||
<text class="speed-text">{{ currentRate }}x</text>
|
||||
<text class="arrow">▼</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showMenu" class="speed-menu">
|
||||
<view
|
||||
v-for="rate in rateOptions"
|
||||
:key="rate"
|
||||
class="speed-option"
|
||||
:class="{ active: rate === currentRate }"
|
||||
@click="selectRate(rate)"
|
||||
>
|
||||
{{ rate }}x
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
rate: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rate: 1.0
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [rate: number]
|
||||
}>()
|
||||
|
||||
const rateOptions = [0.5, 0.75, 1.0, 1.25, 1.5, 2.0]
|
||||
const showMenu = ref(false)
|
||||
const currentRate = ref(props.rate)
|
||||
|
||||
watch(
|
||||
() => props.rate,
|
||||
(newRate) => {
|
||||
currentRate.value = newRate
|
||||
}
|
||||
)
|
||||
|
||||
const toggleMenu = () => {
|
||||
showMenu.value = !showMenu.value
|
||||
}
|
||||
|
||||
const selectRate = (rate: number) => {
|
||||
currentRate.value = rate
|
||||
showMenu.value = false
|
||||
emit('change', rate)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.speed-control {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.speed-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
padding: 10rpx 20rpx;
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8rpx;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.speed-text {
|
||||
color: #fff;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
color: #fff;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.speed-menu {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
right: 0;
|
||||
margin-bottom: 10rpx;
|
||||
background-color: rgba(0, 0, 0, 0.9);
|
||||
border-radius: 8rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.speed-option {
|
||||
padding: 20rpx 40rpx;
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
|
||||
&:hover {
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
&.active {
|
||||
color: #1989fa;
|
||||
background-color: rgba(25, 137, 250, 0.1);
|
||||
}
|
||||
|
||||
&:not(:last-child) {
|
||||
border-bottom: 1rpx solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<view class="volume-control">
|
||||
<view class="volume-icon" @click="toggleMute">
|
||||
<text class="icon">{{ isMuted ? '🔇' : '🔊' }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showSlider" class="volume-slider-wrapper">
|
||||
<slider
|
||||
class="volume-slider"
|
||||
:value="currentVolume"
|
||||
:min="0"
|
||||
:max="100"
|
||||
@change="handleVolumeChange"
|
||||
@changing="handleVolumeChanging"
|
||||
activeColor="#1989fa"
|
||||
backgroundColor="rgba(255, 255, 255, 0.3)"
|
||||
block-size="12"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
interface Props {
|
||||
volume: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
volume: 100
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
change: [volume: number]
|
||||
}>()
|
||||
|
||||
const showSlider = ref(false)
|
||||
const currentVolume = ref(props.volume)
|
||||
const isMuted = ref(false)
|
||||
const volumeBeforeMute = ref(props.volume)
|
||||
|
||||
watch(
|
||||
() => props.volume,
|
||||
(newVolume) => {
|
||||
currentVolume.value = newVolume
|
||||
isMuted.value = newVolume === 0
|
||||
}
|
||||
)
|
||||
|
||||
const toggleMute = () => {
|
||||
if (isMuted.value) {
|
||||
// 取消静音
|
||||
const volume = volumeBeforeMute.value || 50
|
||||
currentVolume.value = volume
|
||||
emit('change', volume)
|
||||
isMuted.value = false
|
||||
} else {
|
||||
// 静音
|
||||
volumeBeforeMute.value = currentVolume.value
|
||||
currentVolume.value = 0
|
||||
emit('change', 0)
|
||||
isMuted.value = true
|
||||
}
|
||||
}
|
||||
|
||||
const handleVolumeChange = (e: any) => {
|
||||
const volume = e.detail.value
|
||||
currentVolume.value = volume
|
||||
isMuted.value = volume === 0
|
||||
emit('change', volume)
|
||||
}
|
||||
|
||||
const handleVolumeChanging = (e: any) => {
|
||||
currentVolume.value = e.detail.value
|
||||
}
|
||||
|
||||
// 点击音量图标时切换滑块显示
|
||||
const toggleSlider = () => {
|
||||
showSlider.value = !showSlider.value
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.volume-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.volume-icon {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
.icon {
|
||||
font-size: 36rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.volume-slider-wrapper {
|
||||
width: 150rpx;
|
||||
}
|
||||
|
||||
.volume-slider {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,73 +0,0 @@
|
||||
// components/video-player/composables/useVideoAPI.ts
|
||||
import { ref } from 'vue'
|
||||
import { videoApi } from '@/api/modules/video'
|
||||
import type { IVideoInfo, VideoErrorType } from '@/types/video'
|
||||
|
||||
/**
|
||||
* 视频 API 调用管理
|
||||
*/
|
||||
export function useVideoAPI() {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<{ type: VideoErrorType; message: string } | null>(null)
|
||||
|
||||
/**
|
||||
* 获取视频播放信息
|
||||
*/
|
||||
const fetchVideoInfo = async (params: {
|
||||
id: number
|
||||
}): Promise<IVideoInfo | null> => {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const response = await videoApi.checkVideo(params)
|
||||
|
||||
if (response.code === 0 && response.video) {
|
||||
console.log('Video info fetched:', response.video)
|
||||
return response.video
|
||||
} else {
|
||||
throw new Error(response.msg || '获取视频信息失败')
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error('Failed to fetch video info:', err)
|
||||
error.value = {
|
||||
type: 'API_ERROR' as VideoErrorType,
|
||||
message: err.message || '获取视频信息失败,请稍后重试'
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 报告错误视频
|
||||
*/
|
||||
const reportErrorVideo = async (params: {
|
||||
chapterId: number
|
||||
videoId: number
|
||||
sort: number
|
||||
}): Promise<void> => {
|
||||
try {
|
||||
await videoApi.addErrorCourse(params)
|
||||
console.log('Error video reported:', params)
|
||||
} catch (err) {
|
||||
console.error('Failed to report error video:', err)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除错误
|
||||
*/
|
||||
const clearError = () => {
|
||||
error.value = null
|
||||
}
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
fetchVideoInfo,
|
||||
reportErrorVideo,
|
||||
clearError
|
||||
}
|
||||
}
|
||||
@@ -1,189 +0,0 @@
|
||||
// components/video-player/composables/useVideoPlayer.ts
|
||||
import { ref, computed } from 'vue'
|
||||
import { useVideoAPI } from './useVideoAPI'
|
||||
import { useVideoProgress } from './useVideoProgress'
|
||||
import type { IVideoInfo, IVideoPlayerProps, VideoErrorType } from '@/types/video'
|
||||
|
||||
/**
|
||||
* 视频播放器核心逻辑
|
||||
*/
|
||||
export function useVideoPlayer(props: IVideoPlayerProps) {
|
||||
const videoAPI = useVideoAPI()
|
||||
const videoProgress = useVideoProgress()
|
||||
|
||||
// 状态
|
||||
const currentVideoData = ref<IVideoInfo | null>(null)
|
||||
const currentIndex = ref(props.currentIndex)
|
||||
const isSetFirstTime = ref(false)
|
||||
const isChanging = ref(false)
|
||||
const showError = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const canRetry = ref(false)
|
||||
const platform = ref('')
|
||||
|
||||
// 获取平台信息
|
||||
// #ifdef APP-PLUS
|
||||
platform.value = uni.getSystemInfoSync().platform
|
||||
// #endif
|
||||
|
||||
// #ifdef H5
|
||||
platform.value = 'h5'
|
||||
// #endif
|
||||
|
||||
/**
|
||||
* 计算视频 URL
|
||||
*/
|
||||
const videoUrl = computed(() => {
|
||||
if (!currentVideoData.value) return ''
|
||||
|
||||
if (currentVideoData.value.type === 0) {
|
||||
// MP4 视频
|
||||
return currentVideoData.value.mp4Url || currentVideoData.value.videoUrl || ''
|
||||
} else {
|
||||
// M3U8 视频
|
||||
return currentVideoData.value.m3u8Url || ''
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 计算封面图 URL
|
||||
*/
|
||||
const posterUrl = computed(() => {
|
||||
if (!currentVideoData.value) return ''
|
||||
|
||||
// 如果是 MP4 视频,可以使用 OSS 视频截图功能
|
||||
if (currentVideoData.value.type === 0 && currentVideoData.value.mp4Url) {
|
||||
return `${currentVideoData.value.mp4Url}?x-oss-process=video/snapshot,t_1,f_jpg`
|
||||
}
|
||||
|
||||
return ''
|
||||
})
|
||||
|
||||
/**
|
||||
* 检查视频是否可以播放
|
||||
*/
|
||||
const canPlayVideo = (videoInfo: IVideoInfo): boolean => {
|
||||
// iOS 平台检查
|
||||
if (platform.value === 'ios') {
|
||||
if (videoInfo.type === 1 && !videoInfo.m3u8Url) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载视频
|
||||
*/
|
||||
const loadVideo = async (index: number) => {
|
||||
if (index < 0 || index >= props.videoList.length) {
|
||||
showError.value = true
|
||||
errorMessage.value = '视频索引超出范围'
|
||||
canRetry.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const videoItem = props.videoList[index]
|
||||
currentIndex.value = index
|
||||
|
||||
// 重置状态
|
||||
isSetFirstTime.value = false
|
||||
showError.value = false
|
||||
errorMessage.value = ''
|
||||
canRetry.value = false
|
||||
|
||||
// 调用 API 获取视频信息
|
||||
const videoInfo = await videoAPI.fetchVideoInfo({
|
||||
id: videoItem.id
|
||||
})
|
||||
|
||||
if (!videoInfo) {
|
||||
showError.value = true
|
||||
errorMessage.value = videoAPI.error.value?.message || '获取视频信息失败'
|
||||
canRetry.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 检查视频类型和 URL
|
||||
if (videoInfo.type === 1 && !videoInfo.m3u8Url) {
|
||||
// M3U8 视频但没有 URL,报告错误
|
||||
await videoAPI.reportErrorVideo({
|
||||
chapterId: videoInfo.chapterId,
|
||||
videoId: videoInfo.id,
|
||||
sort: videoInfo.sort
|
||||
})
|
||||
|
||||
showError.value = true
|
||||
errorMessage.value = '抱歉,本视频加密信息错误,已经提交错误信息,请过一段时间再来观看或联系客服'
|
||||
canRetry.value = false
|
||||
return
|
||||
}
|
||||
|
||||
// 设置当前视频数据
|
||||
currentVideoData.value = videoInfo
|
||||
|
||||
// 获取初始播放位置
|
||||
const initialPosition = videoProgress.getInitialPosition(videoInfo)
|
||||
videoProgress.updateCurrentTime(initialPosition)
|
||||
|
||||
return videoInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 切换视频
|
||||
*/
|
||||
const changeVideo = async (newIndex: number) => {
|
||||
if (isChanging.value) return
|
||||
|
||||
isChanging.value = true
|
||||
|
||||
try {
|
||||
// 保存当前视频进度
|
||||
if (currentVideoData.value) {
|
||||
await videoProgress.saveNow(currentVideoData.value)
|
||||
}
|
||||
|
||||
// 停止进度保存
|
||||
videoProgress.stopSaving()
|
||||
|
||||
// 加载新视频
|
||||
await loadVideo(newIndex)
|
||||
} finally {
|
||||
isChanging.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 重试加载视频
|
||||
*/
|
||||
const retry = () => {
|
||||
loadVideo(currentIndex.value)
|
||||
}
|
||||
|
||||
return {
|
||||
// 状态
|
||||
currentVideoData,
|
||||
currentIndex,
|
||||
isSetFirstTime,
|
||||
isChanging,
|
||||
showError,
|
||||
errorMessage,
|
||||
canRetry,
|
||||
platform,
|
||||
isLoading: videoAPI.isLoading,
|
||||
|
||||
// 计算属性
|
||||
videoUrl,
|
||||
posterUrl,
|
||||
|
||||
// 方法
|
||||
loadVideo,
|
||||
changeVideo,
|
||||
retry,
|
||||
canPlayVideo,
|
||||
|
||||
// 进度管理
|
||||
videoProgress
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
// components/video-player/composables/useVideoProgress.ts
|
||||
import { ref } from 'vue'
|
||||
import { videoStorage } from '@/utils/videoStorage'
|
||||
import { videoApi } from '@/api/modules/video'
|
||||
import type { IVideoInfo } from '@/types/video'
|
||||
|
||||
/**
|
||||
* 视频播放进度管理
|
||||
*/
|
||||
export function useVideoProgress() {
|
||||
const currentTime = ref(0)
|
||||
const isSaving = ref(false)
|
||||
let saveTimer: any = null
|
||||
|
||||
/**
|
||||
* 获取初始播放位置
|
||||
* 合并本地和服务器的播放位置,取较大值
|
||||
* 如果视频已经看完(播放位置 >= 总时长 - 5秒),则从头开始
|
||||
*/
|
||||
const getInitialPosition = (videoInfo: IVideoInfo): number => {
|
||||
// 从服务器获取播放位置
|
||||
const serverPosition = videoInfo.userCourseVideoPositionEntity?.position || 0
|
||||
|
||||
// 从本地存储获取播放位置
|
||||
const localPosition = videoStorage.getVideoPosition(videoInfo.id) || 0
|
||||
|
||||
// 返回较大的值
|
||||
// let position = Math.max(serverPosition, localPosition)
|
||||
// 采用服务器记录的播放位置
|
||||
let position = serverPosition
|
||||
|
||||
// 如果播放位置接近视频结尾(最后5秒内),则从头开始
|
||||
const videoDuration = videoInfo.duration || 0
|
||||
if (videoDuration > 0 && position >= videoDuration - 5) {
|
||||
position = 0
|
||||
console.log('Video already finished, reset to start')
|
||||
}
|
||||
|
||||
console.log('Initial position:', {
|
||||
server: serverPosition,
|
||||
local: localPosition,
|
||||
duration: videoDuration,
|
||||
final: position
|
||||
})
|
||||
|
||||
return position
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始保存进度
|
||||
* 每秒保存到本地
|
||||
*/
|
||||
const startSaving = (videoInfo: IVideoInfo) => {
|
||||
// 清除之前的定时器
|
||||
stopSaving()
|
||||
|
||||
// 每秒保存到本地
|
||||
saveTimer = setInterval(() => {
|
||||
if (currentTime.value > 0) {
|
||||
videoStorage.saveVideoPosition(
|
||||
videoInfo.id,
|
||||
Math.floor(currentTime.value),
|
||||
videoInfo
|
||||
)
|
||||
}
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止保存进度
|
||||
*/
|
||||
const stopSaving = () => {
|
||||
if (saveTimer) {
|
||||
clearInterval(saveTimer)
|
||||
saveTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存进度到服务器
|
||||
*/
|
||||
const saveToServer = async (videoId: number, position: number) => {
|
||||
if (isSaving.value) return
|
||||
|
||||
try {
|
||||
isSaving.value = true
|
||||
await videoApi.saveCoursePosition({
|
||||
videoId,
|
||||
position
|
||||
})
|
||||
console.log('Progress saved to server:', { videoId, position })
|
||||
} catch (error) {
|
||||
console.error('Failed to save progress to server:', error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 立即保存当前进度
|
||||
*/
|
||||
const saveNow = async (videoInfo: IVideoInfo) => {
|
||||
if (currentTime.value > 0) {
|
||||
// 保存到本地 注释掉: 不再保存到本地
|
||||
// videoStorage.saveVideoPosition(
|
||||
// videoInfo.id,
|
||||
// Math.floor(currentTime.value),
|
||||
// videoInfo
|
||||
// )
|
||||
|
||||
// 保存到服务器
|
||||
await saveToServer(videoInfo.id, Math.floor(currentTime.value))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新当前播放时间
|
||||
*/
|
||||
const updateCurrentTime = (time: number) => {
|
||||
currentTime.value = time
|
||||
}
|
||||
|
||||
return {
|
||||
currentTime,
|
||||
isSaving,
|
||||
getInitialPosition,
|
||||
startSaving,
|
||||
stopSaving,
|
||||
saveToServer,
|
||||
saveNow,
|
||||
updateCurrentTime
|
||||
}
|
||||
}
|
||||
@@ -1,523 +1,466 @@
|
||||
<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>
|
||||
<view>
|
||||
<yb-video
|
||||
ref="video" :title="videoTitle" height="400rpx"
|
||||
autoplay
|
||||
render-type="renderjs"
|
||||
:crossOrigin="crossOrigin"
|
||||
:src="src"
|
||||
:initialTime="initialTime"
|
||||
:custom="custom"
|
||||
:poster="currentVideo.type == 2 ? poster : ''"
|
||||
:alwaysShowControls="currentVideo.type == 2"
|
||||
header
|
||||
controls
|
||||
open-direction="landscape-primary"
|
||||
exitDirection="portrait"
|
||||
object-fit="contain"
|
||||
@loadeddata="handleLoaded"
|
||||
@timeupdate="handleTimeUpdate"
|
||||
@ended="handleEnded"
|
||||
@loadstart="handleLoadStart"
|
||||
@canplay="handleCanPlay"
|
||||
@play="handlePlay"
|
||||
@fullscreenchange="handleFullscreenChange"
|
||||
@error="handleError"
|
||||
>
|
||||
<view v-if="!canPlay && !videoError" class="video-mask">{{ loadingText }}</view>
|
||||
<view v-if="canNext && !videoError" class="video-mask">
|
||||
<view>播放完成,倒计时 <text style="font-size: 40rpx; font-weight: bold;">{{ countdown }}</text>秒 后播放下一条</view>
|
||||
<view class="video-control-btn-group">
|
||||
<view class="video-control-btn" @click="handleReplay">重播</view>
|
||||
<view class="video-control-btn" @click="handleNext">立即播放</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="videoError && videoErrorMsg !== ''" class="video-mask">
|
||||
<view>{{ videoErrorMsg }}</view>
|
||||
</view>
|
||||
</yb-video>
|
||||
</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'
|
||||
<script>
|
||||
import { BEFORE_NAVIGATE_BACK_EVENT } from "@/components/nav-bar/back-events"
|
||||
export default {
|
||||
props: {
|
||||
videoTitle: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
poster: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
currentVideoIndex: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
currentVideoList: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
http: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
canNext() {
|
||||
return this.isEnded && this.currentVideoIndex < this.currentVideoList.length - 1
|
||||
},
|
||||
currentVideo() {
|
||||
return this.currentVideoList[this.currentVideoIndex] || {}
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
currentVideo: {
|
||||
handler(newVal, oldVal) {
|
||||
if (newVal && newVal.id && newVal.id !== (oldVal && oldVal.id)) {
|
||||
if (oldVal && oldVal.id) {
|
||||
this._savePositionSnapshot(oldVal.id, parseInt(this.currentTime))
|
||||
}
|
||||
this._resetState();
|
||||
this.updateCustomConfig();
|
||||
}
|
||||
},
|
||||
immediate: true
|
||||
}
|
||||
},
|
||||
data () {
|
||||
return {
|
||||
src: '',
|
||||
isDestroyed: false,
|
||||
beforeBackHandler: null,
|
||||
canPlay: false,
|
||||
videoError: false,
|
||||
videoErrorMsg: '',
|
||||
loadingText: '媒体文件加载中...',
|
||||
currentTime: 0,
|
||||
initialTime: 0,
|
||||
isEnded: false,
|
||||
countdown: 10,
|
||||
countdownTimer: null,
|
||||
endedTimer: null,
|
||||
_requestVersion: 0,
|
||||
three: '',
|
||||
crossOrigin: '',
|
||||
danmu: [],
|
||||
quality: [],
|
||||
works: [],
|
||||
custom: {
|
||||
slots: [{
|
||||
innerHTML: '<div class="fast-progress-btn-box" style="left:15%;"><view class="yb-player-btn">-15s</view></div>',
|
||||
followControls: true,
|
||||
click: () => {
|
||||
console.log('快退 基准时间 ' + this.currentTime)
|
||||
this.$refs.video.seek(this.currentTime - 15)
|
||||
}
|
||||
},{
|
||||
innerHTML: '<div class="fast-progress-btn-box" style="right:15%;"><view class="yb-player-btn">+15s</view></div>',
|
||||
followControls: true,
|
||||
click: () => {
|
||||
console.log('快进 基准时间 ' + this.currentTime)
|
||||
this.$refs.video.seek(this.currentTime + 15)
|
||||
}
|
||||
}],
|
||||
header: {
|
||||
disableMore: true,
|
||||
disableBack: true,
|
||||
},
|
||||
controls: {
|
||||
disableDanmuSend: true,
|
||||
disableDanmuVisible: true,
|
||||
disableFullscreen: false,
|
||||
},
|
||||
progress: {
|
||||
rightSlots: [],
|
||||
disableFullscreen: false,
|
||||
},
|
||||
},
|
||||
text: ''
|
||||
}
|
||||
},
|
||||
beforeUnmount() {
|
||||
this._unbindBeforeBack()
|
||||
this._cleanup()
|
||||
},
|
||||
beforeDestroy() {
|
||||
this._unbindBeforeBack()
|
||||
this._cleanup()
|
||||
},
|
||||
created() {
|
||||
this._bindBeforeBack()
|
||||
},
|
||||
methods: {
|
||||
_resetState() {
|
||||
this.canPlay = false;
|
||||
this.isEnded = false;
|
||||
this.videoError = false;
|
||||
this.videoErrorMsg = '';
|
||||
this.loadingText = '媒体文件加载中...';
|
||||
this.currentTime = 0;
|
||||
this.initialTime = 0;
|
||||
this.isDestroyed = false;
|
||||
this._requestVersion++;
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
if (this.endedTimer) {
|
||||
clearTimeout(this.endedTimer);
|
||||
this.endedTimer = null;
|
||||
}
|
||||
},
|
||||
_bindBeforeBack() {
|
||||
if (this.beforeBackHandler || !uni.$on) return
|
||||
this.beforeBackHandler = () => {
|
||||
this._cleanup()
|
||||
}
|
||||
uni.$on(BEFORE_NAVIGATE_BACK_EVENT, this.beforeBackHandler)
|
||||
},
|
||||
_unbindBeforeBack() {
|
||||
if (!this.beforeBackHandler || !uni.$off) return
|
||||
uni.$off(BEFORE_NAVIGATE_BACK_EVENT, this.beforeBackHandler)
|
||||
this.beforeBackHandler = null
|
||||
},
|
||||
unload() {
|
||||
this._cleanup()
|
||||
},
|
||||
_cleanup() {
|
||||
if (this.isDestroyed) return
|
||||
this.isDestroyed = true
|
||||
const videoId = this.currentVideo ? this.currentVideo.id : null
|
||||
const position = parseInt(this.currentTime)
|
||||
if (this.$refs.video && this.$refs.video.unload) {
|
||||
this.$refs.video.unload()
|
||||
}
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
if (this.endedTimer) {
|
||||
clearTimeout(this.endedTimer);
|
||||
this.endedTimer = null;
|
||||
}
|
||||
this._savePositionSnapshot(videoId, position)
|
||||
},
|
||||
_savePositionSnapshot(videoId, position) {
|
||||
if (!videoId) return
|
||||
this._saveLocalVideoPosition(videoId, position)
|
||||
this._saveRemoteVideoPosition(videoId, position)
|
||||
},
|
||||
_saveLocalVideoPosition(videoId, position) {
|
||||
const videoPosition = JSON.parse(uni.getStorageSync('videoPosition') || '{"order":[], "position":{}}')
|
||||
if (!videoPosition.position) videoPosition.position = {}
|
||||
if (!videoPosition.order) videoPosition.order = []
|
||||
|
||||
// 页面生命周期
|
||||
// @ts-ignore
|
||||
const onHide = typeof uni !== 'undefined' ? uni.onHide || (() => {}) : () => {}
|
||||
const videoKeys = new Set(videoPosition.order)
|
||||
if (videoKeys.size > 20) {
|
||||
const firstId = videoKeys.values().next().value
|
||||
delete videoPosition.position[firstId]
|
||||
videoKeys.delete(firstId)
|
||||
videoPosition.order = Array.from(videoKeys)
|
||||
}
|
||||
|
||||
// Props
|
||||
const props = withDefaults(defineProps<IVideoPlayerProps>(), {
|
||||
countdownSeconds: 5,
|
||||
showWatermark: false,
|
||||
watermarkText: '本课程版权归天津众妙之门科技有限公司所有,翻版必究!'
|
||||
})
|
||||
videoPosition.position[videoId] = position
|
||||
videoKeys.add(videoId)
|
||||
uni.setStorageSync('videoPosition', JSON.stringify(videoPosition))
|
||||
},
|
||||
_saveRemoteVideoPosition(videoId, position) {
|
||||
const http = this.http
|
||||
if (!http || !http.request) return
|
||||
http.request({
|
||||
url: "sociology/course/saveCoursePosition",
|
||||
method: "POST",
|
||||
data: { videoId, position },
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
}).then(() => {
|
||||
console.log('保存视频播放位置成功:', videoId, position)
|
||||
}).catch(() => {
|
||||
console.log('保存视频播放位置失败')
|
||||
})
|
||||
},
|
||||
updateCustomConfig() {
|
||||
if (this.custom && this.custom.controls) {
|
||||
this.custom.controls.disableFullscreen = this.currentVideo.type == 2;
|
||||
this.custom.progress.disableFullscreen = this.currentVideo.type == 2;
|
||||
}
|
||||
this.changeSrc()
|
||||
},
|
||||
handleTimeUpdate (time) {
|
||||
if (this.isDestroyed) return
|
||||
if (!this.canPlay) return
|
||||
if (this.isEnded && time > 0) {
|
||||
this.isEnded = false
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer)
|
||||
this.countdownTimer = null
|
||||
}
|
||||
}
|
||||
this.currentTime = time
|
||||
this._saveLocalVideoPosition(this.currentVideo.id, parseInt(time))
|
||||
},
|
||||
handleEnded () {
|
||||
if (this.isDestroyed) return
|
||||
this.endedTimer = setTimeout(() => {
|
||||
if (this.isDestroyed) return
|
||||
this.isEnded = true;
|
||||
this.startCountdown();
|
||||
this.currentTime = 0
|
||||
}, 300);
|
||||
|
||||
// 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': []
|
||||
}>()
|
||||
this._savePositionSnapshot(this.currentVideo.id, parseInt(this.currentTime))
|
||||
|
||||
// 使用 composables
|
||||
const player = useVideoPlayer(props)
|
||||
if (!this.isDestroyed && this.$refs.video && this.$refs.video.exitFullscreen) {
|
||||
this.$refs.video.exitFullscreen()
|
||||
}
|
||||
},
|
||||
startCountdown() {
|
||||
this.countdown = 10;
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer);
|
||||
}
|
||||
this.countdownTimer = setInterval(() => {
|
||||
this.countdown--;
|
||||
if (this.countdown <= 0) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.handleNext();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
handleLoadStart () {
|
||||
this.loadingText = '媒体文件加载完成,准备播放'
|
||||
},
|
||||
handleCanPlay () {
|
||||
if (this.isDestroyed) return
|
||||
this.canPlay = true
|
||||
this.videoError = false
|
||||
},
|
||||
handlePlay () {
|
||||
if (this.isDestroyed) return
|
||||
if (this.isEnded) {
|
||||
this.isEnded = false
|
||||
}
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer)
|
||||
this.countdownTimer = null
|
||||
}
|
||||
},
|
||||
handleError () {
|
||||
this.videoError = true
|
||||
this.canPlay = false
|
||||
},
|
||||
handleNext () {
|
||||
if (this.canNext) {
|
||||
this.$emit('changeVideo', this.currentVideoIndex, 'next');
|
||||
}
|
||||
},
|
||||
handleReplay () {
|
||||
if (this.countdownTimer) {
|
||||
clearInterval(this.countdownTimer);
|
||||
this.countdownTimer = null;
|
||||
}
|
||||
this.$refs.video.seek(0)
|
||||
this.isEnded = false
|
||||
this.$refs.video.play()
|
||||
},
|
||||
changeSrc () {
|
||||
this.getPlayAuth()
|
||||
},
|
||||
handleFullscreenChange (data) {
|
||||
console.log('全屏状态改变:' + JSON.stringify(data))
|
||||
this.$emit('fullScreenChange', data.fullscreen)
|
||||
},
|
||||
handleLoaded (e) {
|
||||
console.log('视频加载状态:'+JSON.stringify(e))
|
||||
if (e.type == 'init') {
|
||||
this.loadingText = '媒体文件加载完成,立即播放'
|
||||
}
|
||||
},
|
||||
async getPlayAuth () {
|
||||
const version = ++this._requestVersion
|
||||
const videoId = this.currentVideo.id
|
||||
this.loadingText = '获取播放凭证...'
|
||||
|
||||
// 视频相关状态
|
||||
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
|
||||
const http = this.http
|
||||
if (!http || !http.request) return
|
||||
|
||||
// 生命周期
|
||||
onMounted(async () => {
|
||||
// 初始化视频上下文
|
||||
videoContext.value = uni.createVideoContext(videoId)
|
||||
let res_check
|
||||
try {
|
||||
res_check = await http.request({
|
||||
url: `sociology/course/checkVideo`,
|
||||
method: "Post",
|
||||
data: { id: videoId },
|
||||
header: { "Content-Type": "application/json" },
|
||||
})
|
||||
} catch (e) {
|
||||
if (version !== this._requestVersion) return
|
||||
this.videoError = true
|
||||
this.videoErrorMsg = '获取播放凭证失败'
|
||||
return
|
||||
}
|
||||
|
||||
// 加载视频信息
|
||||
const videoInfo = await player.loadVideo(props.currentIndex)
|
||||
if (version !== this._requestVersion) return
|
||||
|
||||
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) // 注释掉 不再保存到本地
|
||||
}
|
||||
})
|
||||
this.loadingText = '验证媒体文件格式...'
|
||||
console.log("checkVideo接口返回视频信息" + JSON.stringify(res_check.video));
|
||||
if (this.$platform == 'ios') {
|
||||
if (res_check.video.type == 1 && (res_check.video.m3u8Url == null || res_check.video.m3u8Url == '')) {
|
||||
await this.recordErrorVideo(res_check.video, http)
|
||||
this.videoErrorMsg = '抱歉,苹果手机不支持此加密视频格式,您可以在安卓端观看本视频'
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 页面隐藏时保存进度
|
||||
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)
|
||||
)
|
||||
}
|
||||
})
|
||||
if (version !== this._requestVersion) return
|
||||
|
||||
onUnmounted(() => {
|
||||
console.log('Component unmounting, saving progress...')
|
||||
|
||||
// 停止进度保存
|
||||
player.videoProgress.stopSaving()
|
||||
this.loadingText = '媒体文件加载中...'
|
||||
const options = {
|
||||
...res_check.video,
|
||||
videoId: res_check.video.video,
|
||||
}
|
||||
this.handlePlayAuth(options)
|
||||
},
|
||||
handlePlayAuth (options) {
|
||||
if (this.currentVideo.type == 1) {
|
||||
this.src = options.m3u8Url
|
||||
} else {
|
||||
this.src = options.videoUrl
|
||||
}
|
||||
|
||||
// 立即保存当前播放进度(同步保存到本地,异步保存到服务器)
|
||||
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()
|
||||
}
|
||||
const localPosition = JSON.parse(uni.getStorageSync('videoPosition') || '{"order":[], "position":{}}')
|
||||
this.initialTime = localPosition.position[this.currentVideo.id] !== undefined
|
||||
? localPosition.position[this.currentVideo.id]
|
||||
: (options.userCourseVideoPositionEntity ? options.userCourseVideoPositionEntity.position || 0 : 0)
|
||||
},
|
||||
recordErrorVideo (video, http) {
|
||||
if (!http || !http.request) return
|
||||
http.request({
|
||||
url: "medical/course/addErrorCourse",
|
||||
method: "POST",
|
||||
data: {
|
||||
"chapterId": video.chapterId,
|
||||
"videoId": video.id,
|
||||
"sort": video.sort
|
||||
},
|
||||
header: { 'Content-Type': 'application/json' },
|
||||
}).then(() => {
|
||||
console.log('记录IOS不能看的视频成功 ' + JSON.stringify(video))
|
||||
}).catch(() => { console.log('数据报错') })
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.video-player-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
background-color: #000;
|
||||
}
|
||||
<style lang="scss">
|
||||
::v-deep .fast-progress-btn-box {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
border-radius: 14rpx;
|
||||
padding: 2px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
|
||||
.video-wrapper {
|
||||
width: 100%;
|
||||
height: 400rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.video-element {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: inline-block;
|
||||
background-color: #1989fa;
|
||||
color: #fff;
|
||||
border: none;
|
||||
padding: 20rpx 40rpx;
|
||||
line-height: 1;
|
||||
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);
|
||||
.yb-player-btn {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
line-height: 1;
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 10rpx;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
::v-deep .video-mask {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: rgba(0, 0, 0, 1);
|
||||
color: #fff;
|
||||
z-index: 2;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
|
||||
.loading-text {
|
||||
color: #fff;
|
||||
font-size: 28rpx;
|
||||
margin-top: 20rpx;
|
||||
}
|
||||
.video-control-btn-group {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 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;
|
||||
// }
|
||||
.video-control-btn {
|
||||
line-height: 1;
|
||||
padding: 5px 10px;
|
||||
border-radius: 5px;
|
||||
border: 1px solid #fff;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user