更新:课程视频播放改成原生video组件
This commit is contained in:
@@ -1,35 +1,24 @@
|
||||
// api/modules/video.ts
|
||||
/**
|
||||
* 视频相关 API
|
||||
* 完全保持原项目接口路径和调用方式
|
||||
*/
|
||||
|
||||
import { createRequestClient } from '../request'
|
||||
import { SERVICE_MAP } from '../config'
|
||||
import type {
|
||||
ICheckVideoRequest,
|
||||
ICheckVideoResponse,
|
||||
ISaveCoursePositionRequest,
|
||||
ISaveCoursePositionResponse,
|
||||
IAddErrorCourseRequest,
|
||||
IAddErrorCourseResponse
|
||||
} from '@/types/video'
|
||||
import type { IApiResponse } from '../types'
|
||||
import type { IVideoCheckResponse } from '@/types/video'
|
||||
|
||||
const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN })
|
||||
|
||||
/**
|
||||
* 视频相关 API
|
||||
* 视频相关API
|
||||
*/
|
||||
export const videoApi = {
|
||||
/**
|
||||
* 检查视频并获取播放凭证
|
||||
* 原接口: sociology/course/checkVideo
|
||||
*
|
||||
* @param data 视频信息(id, video, courseId, catalogueId, chapterId 等)
|
||||
* @returns 视频详细信息,包含 playAuth, videoId, m3u8Url, videoUrl 等
|
||||
* 获取视频播放信息
|
||||
* 接口: sociology/course/checkVideo
|
||||
* 方法: POST
|
||||
*/
|
||||
checkVideo(data: ICheckVideoRequest) {
|
||||
return client.request<ICheckVideoResponse>({
|
||||
checkVideo(data: {
|
||||
id: number
|
||||
}) {
|
||||
return client.request<IVideoCheckResponse>({
|
||||
url: 'sociology/course/checkVideo',
|
||||
method: 'POST',
|
||||
data
|
||||
@@ -37,14 +26,15 @@ export const videoApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 保存课程播放位置
|
||||
* 原接口: sociology/course/saveCoursePosition
|
||||
*
|
||||
* @param data 包含 videoId 和 position(秒数)
|
||||
* @returns 保存结果
|
||||
* 保存播放进度
|
||||
* 接口: sociology/course/saveCoursePosition
|
||||
* 方法: POST
|
||||
*/
|
||||
saveCoursePosition(data: ISaveCoursePositionRequest) {
|
||||
return client.request<ISaveCoursePositionResponse>({
|
||||
saveCoursePosition(data: {
|
||||
videoId: number
|
||||
position: number
|
||||
}) {
|
||||
return client.request<IApiResponse>({
|
||||
url: 'sociology/course/saveCoursePosition',
|
||||
method: 'POST',
|
||||
data
|
||||
@@ -52,14 +42,16 @@ export const videoApi = {
|
||||
},
|
||||
|
||||
/**
|
||||
* 记录 iOS 不支持的视频
|
||||
* 原接口: medical/course/addErrorCourse
|
||||
*
|
||||
* @param data 包含 chapterId, videoId, sort
|
||||
* @returns 记录结果
|
||||
* 报告错误视频
|
||||
* 接口: medical/course/addErrorCourse
|
||||
* 方法: POST
|
||||
*/
|
||||
addErrorCourse(data: IAddErrorCourseRequest) {
|
||||
return client.request<IAddErrorCourseResponse>({
|
||||
addErrorCourse(data: {
|
||||
chapterId: number
|
||||
videoId: number
|
||||
sort: number
|
||||
}) {
|
||||
return client.request<IApiResponse>({
|
||||
url: 'medical/course/addErrorCourse',
|
||||
method: 'POST',
|
||||
data
|
||||
|
||||
@@ -129,6 +129,7 @@ interface Props {
|
||||
chapters: IChapter[]
|
||||
catalogue: ICatalogue
|
||||
userVip: IVipInfo | null
|
||||
showRenewBtn?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
@@ -136,6 +137,8 @@ const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
click: [chapter: IChapter],
|
||||
purchase: [catalogue: ICatalogue],
|
||||
renew: [catalogue: ICatalogue],
|
||||
toVip: [catalogue: ICatalogue],
|
||||
}>()
|
||||
|
||||
/**
|
||||
@@ -153,6 +156,16 @@ const goToVip = () => {
|
||||
emit('toVip', props.catalogue)
|
||||
}
|
||||
|
||||
// 续费/复读
|
||||
const handleRenew = () => {
|
||||
emit('renew', props.catalogue)
|
||||
}
|
||||
|
||||
// 领取免费课程
|
||||
const handleGetFreeCourse = () => {
|
||||
emit('purchase', props.catalogue)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断章节是否可以访问
|
||||
*/
|
||||
|
||||
162
components/video-player/components/ControlBar.vue
Normal file
162
components/video-player/components/ControlBar.vue
Normal file
@@ -0,0 +1,162 @@
|
||||
<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>
|
||||
183
components/video-player/components/ProgressBar.vue
Normal file
183
components/video-player/components/ProgressBar.vue
Normal file
@@ -0,0 +1,183 @@
|
||||
<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>
|
||||
122
components/video-player/components/SpeedControl.vue
Normal file
122
components/video-player/components/SpeedControl.vue
Normal file
@@ -0,0 +1,122 @@
|
||||
<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>
|
||||
112
components/video-player/components/VolumeControl.vue
Normal file
112
components/video-player/components/VolumeControl.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<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>
|
||||
73
components/video-player/composables/useVideoAPI.ts
Normal file
73
components/video-player/composables/useVideoAPI.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
// 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',
|
||||
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
|
||||
}
|
||||
}
|
||||
189
components/video-player/composables/useVideoPlayer.ts
Normal file
189
components/video-player/composables/useVideoPlayer.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
// 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
|
||||
}
|
||||
}
|
||||
131
components/video-player/composables/useVideoProgress.ts
Normal file
131
components/video-player/composables/useVideoProgress.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
// 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)
|
||||
|
||||
// 如果播放位置接近视频结尾(最后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
|
||||
}
|
||||
}
|
||||
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>
|
||||
@@ -7,21 +7,18 @@
|
||||
<view class="page-content" :style="{ height: contentHeight }">
|
||||
<!-- 视频播放器 -->
|
||||
<view v-if="videoList.length > 0" class="video-section">
|
||||
<!-- <VideoPlayer
|
||||
<VideoPlayer
|
||||
ref="videoPlayerRef"
|
||||
:videoList="videoList"
|
||||
:currentIndex="currentVideoIndex"
|
||||
:noRecored="noRecored"
|
||||
@end="handleVideoEnd"
|
||||
@fullscreen="handleFullscreen"
|
||||
@change="handleVideoChange"
|
||||
/> -->
|
||||
<AliyunPlayer
|
||||
v-model:current-index="currentVideoIndex"
|
||||
:video-list="videoList"
|
||||
:countdown-seconds="5"
|
||||
/>
|
||||
<!-- <AliyunPlayer
|
||||
ref="videoPlayerRef"
|
||||
:currentVideo="videoList[currentVideoIndex]"
|
||||
:currentVideoList="videoList"
|
||||
@unlockChangeVideo="changeVideoLock = false"
|
||||
/>
|
||||
/> -->
|
||||
</view>
|
||||
|
||||
<!-- 课程和章节信息 -->
|
||||
@@ -48,12 +45,6 @@
|
||||
>
|
||||
<view class="video-info">
|
||||
<text class="video-title">【{{ video.type == "2" ? "音频" : "视频" }}】{{ index + 1 }}</text>
|
||||
<!-- <wd-icon
|
||||
v-if="currentVideoIndex === index"
|
||||
name="play-circle"
|
||||
color="#258feb"
|
||||
size="14px"
|
||||
/> -->
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -113,11 +104,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
||||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'
|
||||
import { courseApi } from '@/api/modules/course'
|
||||
// import VideoPlayer from '@/components/course/VideoPlayer.vue'
|
||||
import AliyunPlayer from '@/components/ali-video/index.vue'
|
||||
import VideoPlayer from '@/components/video-player/index.vue'
|
||||
import type { IChapterDetail, IVideo } from '@/types/course'
|
||||
|
||||
// 页面参数
|
||||
@@ -131,6 +121,7 @@ const noRecored = ref(false)
|
||||
const chapterDetail = ref<IChapterDetail | null>(null)
|
||||
const videoList = ref<IVideo[]>([])
|
||||
const currentVideoIndex = ref(0)
|
||||
const activeVideoIndex = ref(0)
|
||||
const currentTab = ref(0)
|
||||
const isFullScreen = ref(false)
|
||||
|
||||
@@ -184,20 +175,9 @@ const loadChapterDetail = async () => {
|
||||
const index = videoList.value.findIndex(v => v.id === res.data.current)
|
||||
if (index !== -1) {
|
||||
currentVideoIndex.value = index
|
||||
activeVideoIndex.value = index
|
||||
}
|
||||
}
|
||||
|
||||
// 使用 nextTick 确保组件已经渲染完成
|
||||
await nextTick()
|
||||
if (videoPlayerRef.value) {
|
||||
console.log('准备调用 init 方法')
|
||||
await videoPlayerRef.value.init({
|
||||
currentVideo: videoList.value[currentVideoIndex.value],
|
||||
currentVideoList: videoList.value
|
||||
}, false)
|
||||
} else {
|
||||
console.error('videoPlayerRef.value 为空,无法初始化播放器')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('加载章节详情失败:', error)
|
||||
@@ -209,46 +189,7 @@ const loadChapterDetail = async () => {
|
||||
*/
|
||||
const selectVideo = async (index: number) => {
|
||||
if (index === currentVideoIndex.value) return
|
||||
|
||||
console.log('切换视频:', index, videoList.value[index])
|
||||
currentVideoIndex.value = index
|
||||
|
||||
// 调用播放器的 changeVideo 方法
|
||||
if (videoPlayerRef.value) {
|
||||
await videoPlayerRef.value.changeVideo(videoList.value[index])
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频切换
|
||||
*/
|
||||
const handleVideoChange = (index: number) => {
|
||||
currentVideoIndex.value = index
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放结束
|
||||
*/
|
||||
const handleVideoEnd = () => {
|
||||
// 视频播放结束的处理已在 VideoPlayer 组件中完成
|
||||
}
|
||||
|
||||
/**
|
||||
* 全屏变化
|
||||
*/
|
||||
const handleFullscreen = (fullscreen: boolean) => {
|
||||
isFullScreen.value = fullscreen
|
||||
|
||||
// 全屏时锁定屏幕方向
|
||||
// #ifdef APP-PLUS
|
||||
if (fullscreen) {
|
||||
plus.screen.unlockOrientation()
|
||||
plus.screen.lockOrientation('landscape-primary')
|
||||
} else {
|
||||
plus.screen.unlockOrientation()
|
||||
plus.screen.lockOrientation('portrait-primary')
|
||||
}
|
||||
// #endif
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -267,42 +208,6 @@ const previewImage = (url: string) => {
|
||||
current: url
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面显示
|
||||
*/
|
||||
onShow(() => {
|
||||
// 锁定竖屏
|
||||
// #ifdef APP-PLUS
|
||||
plus.screen.unlockOrientation()
|
||||
plus.screen.lockOrientation('portrait-primary')
|
||||
// #endif
|
||||
})
|
||||
|
||||
/**
|
||||
* 页面隐藏
|
||||
*/
|
||||
onHide(() => {
|
||||
// 暂停视频
|
||||
if (videoPlayerRef.value) {
|
||||
videoPlayerRef.value.pause()
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 页面卸载
|
||||
*/
|
||||
onUnload(() => {
|
||||
// 停止视频
|
||||
if (videoPlayerRef.value) {
|
||||
videoPlayerRef.value.stop()
|
||||
}
|
||||
|
||||
// 解锁屏幕方向
|
||||
// #ifdef APP-PLUS
|
||||
plus.screen.unlockOrientation()
|
||||
// #endif
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -362,7 +267,7 @@ onUnload(() => {
|
||||
gap: 10rpx;
|
||||
|
||||
.video-item {
|
||||
padding: 20rpx;
|
||||
padding: 18rpx;
|
||||
margin-bottom: 10rpx;
|
||||
background-color: #f7f8f9;
|
||||
border-radius: 8rpx;
|
||||
|
||||
@@ -32,8 +32,10 @@
|
||||
:chapters="chapterList"
|
||||
:catalogue="currentCatalogue"
|
||||
:userVip="userVip"
|
||||
:showRenewBtn="showRenewBtn"
|
||||
@purchase="handlePurchase"
|
||||
@toVip="goToVip"
|
||||
@renew="handleRenew"
|
||||
@click="handleChapterClick"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||
--spacing: 0.25rem;
|
||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
@@ -237,9 +236,6 @@
|
||||
.flex-shrink {
|
||||
flex-shrink: 1;
|
||||
}
|
||||
.border-collapse {
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.transform {
|
||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
||||
}
|
||||
@@ -287,9 +283,6 @@
|
||||
--tw-ordinal: ordinal;
|
||||
font-variant-numeric: var(--tw-ordinal,) var(--tw-slashed-zero,) var(--tw-numeric-figure,) var(--tw-numeric-spacing,) var(--tw-numeric-fraction,);
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.ring {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
|
||||
248
types/video.d.ts
vendored
248
types/video.d.ts
vendored
@@ -1,210 +1,92 @@
|
||||
// types/video.d.ts
|
||||
/**
|
||||
* 视频相关类型定义
|
||||
* 完全基于原项目 medicine_app 的数据结构
|
||||
*/
|
||||
|
||||
import type { IApiResponse } from './book'
|
||||
|
||||
/**
|
||||
* 视频基本信息
|
||||
* 对应原项目中的视频数据结构
|
||||
* 视频信息
|
||||
*/
|
||||
export interface IVideoInfo {
|
||||
id: number // 视频 ID
|
||||
title: string // 视频标题
|
||||
type: '1' | '2' | string // 类型: 1-视频 2-音频
|
||||
video?: string // 阿里云视频 ID(原始)
|
||||
videoId?: string // 阿里云视频 ID(处理后,移除等号)
|
||||
videoUrl?: string // 视频 URL(普通视频)
|
||||
m3u8Url?: string | null // M3U8 地址(标准加密)
|
||||
playAuth?: string // 播放凭证
|
||||
sort?: number // 排序
|
||||
chapterId?: number // 章节 ID
|
||||
courseId?: number // 课程 ID
|
||||
catalogueId?: number // 目录 ID
|
||||
userCourseVideoPositionEntity?: {
|
||||
position: number // 服务器记录的播放位置(秒)
|
||||
}
|
||||
[key: string]: any // 允许其他字段
|
||||
id: number
|
||||
chapterId: number
|
||||
type: 0 | 1 // 0: MP4, 1: M3U8
|
||||
video: string // 视频ID
|
||||
sort: number
|
||||
duration: number // 视频时长(秒)
|
||||
createTime: string
|
||||
delFlag: number
|
||||
playAuth?: string // 阿里云播放授权
|
||||
videoUrl?: string // 视频URL(兼容字段)
|
||||
m3u8Url?: string // M3U8流地址
|
||||
mp4Url?: string // MP4视频地址
|
||||
mtsHlsUriToken?: string // M3U8 Token
|
||||
userCourseVideoPositionEntity?: IVideoPosition
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频数据(扩展)
|
||||
* 包含播放时间等额外信息
|
||||
* 视频播放位置
|
||||
*/
|
||||
export interface IVideoData extends IVideoInfo {
|
||||
firstTime?: number // 初始播放时间(秒)
|
||||
time?: number // 当前播放时间(秒)
|
||||
export interface IVideoPosition {
|
||||
id: number
|
||||
userId: number
|
||||
videoId: number
|
||||
position: number // 播放位置(秒)
|
||||
createTime: string
|
||||
updateTime: string
|
||||
delFlag: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地存储的视频列表项
|
||||
* 存储在 localStorage 的 videoOssList 中
|
||||
* 视频检查接口响应
|
||||
*/
|
||||
export interface IVideoStorageItem {
|
||||
id: number // 视频 ID
|
||||
time: number // 播放时间(秒)
|
||||
[key: string]: any // 其他视频信息
|
||||
export interface IVideoCheckResponse {
|
||||
msg: string
|
||||
code: number
|
||||
video: IVideoInfo
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查视频请求参数
|
||||
* 对应 sociology/course/checkVideo 接口
|
||||
* 视频播放器组件 Props
|
||||
*/
|
||||
export interface ICheckVideoRequest {
|
||||
id?: number // 视频 ID
|
||||
video?: string // 视频标识
|
||||
courseId?: number // 课程 ID
|
||||
catalogueId?: number // 目录 ID
|
||||
chapterId?: number // 章节 ID
|
||||
[key: string]: any // 允许其他参数
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查视频响应
|
||||
* 对应 sociology/course/checkVideo 接口返回
|
||||
*/
|
||||
export interface ICheckVideoResponse extends IApiResponse {
|
||||
video: IVideoInfo // 视频信息
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存播放位置请求参数
|
||||
* 对应 sociology/course/saveCoursePosition 接口
|
||||
*/
|
||||
export interface ISaveCoursePositionRequest {
|
||||
videoId: number // 视频 ID
|
||||
position: number // 播放位置(秒)
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存播放位置响应
|
||||
* 对应 sociology/course/saveCoursePosition 接口返回
|
||||
*/
|
||||
export interface ISaveCoursePositionResponse extends IApiResponse {
|
||||
// 继承基础响应结构
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误视频请求参数
|
||||
* 对应 medical/course/addErrorCourse 接口
|
||||
* 用于记录 iOS 不支持的视频
|
||||
*/
|
||||
export interface IAddErrorCourseRequest {
|
||||
chapterId: number // 章节 ID
|
||||
videoId: number // 视频 ID
|
||||
sort: number // 排序
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录错误视频响应
|
||||
* 对应 medical/course/addErrorCourse 接口返回
|
||||
*/
|
||||
export interface IAddErrorCourseResponse extends IApiResponse {
|
||||
// 继承基础响应结构
|
||||
}
|
||||
|
||||
/**
|
||||
* 章节详情
|
||||
* 用于视频列表组件
|
||||
*/
|
||||
export interface IChapterDetail {
|
||||
id: number // 章节 ID
|
||||
title: string // 章节标题
|
||||
imgUrl?: string // 章节图片
|
||||
content?: string // 章节内容
|
||||
questions?: string // 思考题
|
||||
[key: string]: any // 其他字段
|
||||
}
|
||||
|
||||
/**
|
||||
* 平台类型
|
||||
*/
|
||||
export type PlatformType = 'ios' | 'android' | 'h5' | null
|
||||
|
||||
/**
|
||||
* 屏幕方向类型
|
||||
*/
|
||||
export type ScreenOrientationType = 'portrait-primary' | 'landscape-primary'
|
||||
|
||||
/**
|
||||
* 播放器配置选项
|
||||
* 对应阿里云播放器配置
|
||||
*/
|
||||
export interface IPlayerOptions {
|
||||
id: string // 容器 ID
|
||||
width: string // 宽度
|
||||
height: string // 高度
|
||||
autoplay?: boolean // 自动播放
|
||||
playsinline?: boolean // 内联播放
|
||||
controlBarVisibility?: 'hover' | 'click' | 'always' // 控制栏显示方式
|
||||
useH5Prism?: boolean // 使用 H5 播放器
|
||||
qualitySort?: 'asc' | 'desc' // 清晰度排序
|
||||
isLive?: boolean // 是否直播
|
||||
rePlay?: boolean // 是否重播
|
||||
cover?: string // 封面图
|
||||
|
||||
// 私有加密视频配置
|
||||
vid?: string // 视频 ID
|
||||
playauth?: string // 播放凭证
|
||||
encryptType?: number // 加密类型: 1-私有加密
|
||||
playConfig?: {
|
||||
EncryptType: string // 加密类型名称
|
||||
}
|
||||
|
||||
// 标准加密/普通视频配置
|
||||
source?: string // 视频地址
|
||||
|
||||
// 组件配置
|
||||
components?: Array<{
|
||||
name: string
|
||||
type: any
|
||||
args?: any[]
|
||||
}>
|
||||
|
||||
// 皮肤布局
|
||||
skinLayout?: Array<{
|
||||
name: string
|
||||
align?: string
|
||||
x?: number
|
||||
y?: number
|
||||
children?: Array<{
|
||||
name: string
|
||||
align?: string
|
||||
x?: number
|
||||
y?: number
|
||||
}>
|
||||
export interface IVideoPlayerProps {
|
||||
videoList: Array<{
|
||||
id: number
|
||||
chapterId: number
|
||||
video: string
|
||||
sort: number
|
||||
type?: 0 | 1
|
||||
duration?: number
|
||||
}>
|
||||
currentIndex: number
|
||||
countdownSeconds?: number
|
||||
showWatermark?: boolean
|
||||
watermarkText?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 全屏状态变化数据
|
||||
* 视频播放器组件状态
|
||||
*/
|
||||
export interface IScreenChangeData {
|
||||
status: boolean // 全屏状态
|
||||
primary: ScreenOrientationType // 屏幕方向
|
||||
export interface IVideoPlayerState {
|
||||
currentVideoData: IVideoInfo | null
|
||||
currentTime: number
|
||||
firstTime: number
|
||||
isSetFirstTime: boolean
|
||||
isFullScreen: boolean
|
||||
showCountdown: boolean
|
||||
countdownRemaining: number
|
||||
showError: boolean
|
||||
errorMessage: string
|
||||
isLoading: boolean
|
||||
isChanging: boolean
|
||||
playbackRate: number
|
||||
volume: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 播放时间记录数据
|
||||
* 视频错误类型
|
||||
*/
|
||||
export interface IRecordTimeData {
|
||||
time: number // 播放时间(秒)
|
||||
status?: string // 播放状态
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误提示数据
|
||||
*/
|
||||
export interface IOpenShowData {
|
||||
msg?: string // 错误消息
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化数据
|
||||
*/
|
||||
export interface IInitData {
|
||||
currentVideo: IVideoInfo // 当前视频
|
||||
currentVideoList?: IVideoInfo[] // 视频列表
|
||||
export enum VideoErrorType {
|
||||
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||
VIDEO_LOAD_ERROR = 'VIDEO_LOAD_ERROR',
|
||||
ENCRYPTION_ERROR = 'ENCRYPTION_ERROR',
|
||||
PLATFORM_NOT_SUPPORTED = 'PLATFORM_NOT_SUPPORTED',
|
||||
INVALID_PARAMS = 'INVALID_PARAMS',
|
||||
API_ERROR = 'API_ERROR'
|
||||
}
|
||||
|
||||
118
utils/videoStorage.ts
Normal file
118
utils/videoStorage.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
// utils/videoStorage.ts
|
||||
|
||||
interface VideoPosition {
|
||||
id: number
|
||||
time: number
|
||||
updateTime: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
/**
|
||||
* 视频播放位置本地存储服务
|
||||
*/
|
||||
class VideoStorageService {
|
||||
private readonly STORAGE_KEY = 'videoOssList'
|
||||
private readonly MAX_STORAGE_COUNT = 50
|
||||
|
||||
/**
|
||||
* 获取视频播放位置
|
||||
* @param videoId 视频ID
|
||||
* @returns 播放位置(秒)或 null
|
||||
*/
|
||||
getVideoPosition(videoId: number): number | null {
|
||||
const list = this.getVideoList()
|
||||
const video = list.find((v) => v.id === videoId)
|
||||
return video?.time || null
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存视频播放位置
|
||||
* @param videoId 视频ID
|
||||
* @param time 播放位置(秒)
|
||||
* @param videoData 视频数据
|
||||
*/
|
||||
saveVideoPosition(videoId: number, time: number, videoData: any): void {
|
||||
const list = this.getVideoList()
|
||||
const index = list.findIndex((v) => v.id === videoId)
|
||||
|
||||
const newItem = {
|
||||
...videoData,
|
||||
id: videoId,
|
||||
time,
|
||||
updateTime: new Date().toISOString()
|
||||
}
|
||||
|
||||
if (index >= 0) {
|
||||
list[index] = newItem
|
||||
} else {
|
||||
list.push(newItem)
|
||||
}
|
||||
|
||||
this.setVideoList(list)
|
||||
|
||||
// 自动清理旧数据
|
||||
this.cleanOldData()
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理旧数据(保留最近 50 个)
|
||||
*/
|
||||
cleanOldData(): void {
|
||||
const list = this.getVideoList()
|
||||
|
||||
if (list.length > this.MAX_STORAGE_COUNT) {
|
||||
// 按更新时间排序,保留最新的
|
||||
const sorted = list.sort((a, b) => {
|
||||
const timeA = new Date(b.updateTime || 0).getTime()
|
||||
const timeB = new Date(a.updateTime || 0).getTime()
|
||||
return timeA - timeB
|
||||
})
|
||||
|
||||
this.setVideoList(sorted.slice(0, this.MAX_STORAGE_COUNT))
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除指定视频的播放位置
|
||||
* @param videoId 视频ID
|
||||
*/
|
||||
clearVideoPosition(videoId: number): void {
|
||||
const list = this.getVideoList()
|
||||
const filtered = list.filter((v) => v.id !== videoId)
|
||||
this.setVideoList(filtered)
|
||||
}
|
||||
|
||||
/**
|
||||
* 清除所有播放位置
|
||||
*/
|
||||
clearAll(): void {
|
||||
uni.removeStorageSync(this.STORAGE_KEY)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取视频列表
|
||||
*/
|
||||
private getVideoList(): VideoPosition[] {
|
||||
try {
|
||||
const data = uni.getStorageSync(this.STORAGE_KEY)
|
||||
return data ? JSON.parse(data) : []
|
||||
} catch (error) {
|
||||
console.error('Failed to get video list from storage:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置视频列表
|
||||
*/
|
||||
private setVideoList(list: VideoPosition[]): void {
|
||||
try {
|
||||
uni.setStorageSync(this.STORAGE_KEY, JSON.stringify(list))
|
||||
} catch (error) {
|
||||
console.error('Failed to set video list to storage:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const videoStorage = new VideoStorageService()
|
||||
Reference in New Issue
Block a user