更新:课程视频播放改成原生video组件
This commit is contained in:
@@ -1,35 +1,24 @@
|
|||||||
// api/modules/video.ts
|
// api/modules/video.ts
|
||||||
/**
|
|
||||||
* 视频相关 API
|
|
||||||
* 完全保持原项目接口路径和调用方式
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createRequestClient } from '../request'
|
import { createRequestClient } from '../request'
|
||||||
import { SERVICE_MAP } from '../config'
|
import { SERVICE_MAP } from '../config'
|
||||||
import type {
|
import type { IApiResponse } from '../types'
|
||||||
ICheckVideoRequest,
|
import type { IVideoCheckResponse } from '@/types/video'
|
||||||
ICheckVideoResponse,
|
|
||||||
ISaveCoursePositionRequest,
|
|
||||||
ISaveCoursePositionResponse,
|
|
||||||
IAddErrorCourseRequest,
|
|
||||||
IAddErrorCourseResponse
|
|
||||||
} from '@/types/video'
|
|
||||||
|
|
||||||
const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN })
|
const client = createRequestClient({ baseURL: SERVICE_MAP.MAIN })
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视频相关 API
|
* 视频相关API
|
||||||
*/
|
*/
|
||||||
export const videoApi = {
|
export const videoApi = {
|
||||||
/**
|
/**
|
||||||
* 检查视频并获取播放凭证
|
* 获取视频播放信息
|
||||||
* 原接口: sociology/course/checkVideo
|
* 接口: sociology/course/checkVideo
|
||||||
*
|
* 方法: POST
|
||||||
* @param data 视频信息(id, video, courseId, catalogueId, chapterId 等)
|
|
||||||
* @returns 视频详细信息,包含 playAuth, videoId, m3u8Url, videoUrl 等
|
|
||||||
*/
|
*/
|
||||||
checkVideo(data: ICheckVideoRequest) {
|
checkVideo(data: {
|
||||||
return client.request<ICheckVideoResponse>({
|
id: number
|
||||||
|
}) {
|
||||||
|
return client.request<IVideoCheckResponse>({
|
||||||
url: 'sociology/course/checkVideo',
|
url: 'sociology/course/checkVideo',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data
|
data
|
||||||
@@ -37,14 +26,15 @@ export const videoApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 保存课程播放位置
|
* 保存播放进度
|
||||||
* 原接口: sociology/course/saveCoursePosition
|
* 接口: sociology/course/saveCoursePosition
|
||||||
*
|
* 方法: POST
|
||||||
* @param data 包含 videoId 和 position(秒数)
|
|
||||||
* @returns 保存结果
|
|
||||||
*/
|
*/
|
||||||
saveCoursePosition(data: ISaveCoursePositionRequest) {
|
saveCoursePosition(data: {
|
||||||
return client.request<ISaveCoursePositionResponse>({
|
videoId: number
|
||||||
|
position: number
|
||||||
|
}) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
url: 'sociology/course/saveCoursePosition',
|
url: 'sociology/course/saveCoursePosition',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data
|
data
|
||||||
@@ -52,14 +42,16 @@ export const videoApi = {
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 记录 iOS 不支持的视频
|
* 报告错误视频
|
||||||
* 原接口: medical/course/addErrorCourse
|
* 接口: medical/course/addErrorCourse
|
||||||
*
|
* 方法: POST
|
||||||
* @param data 包含 chapterId, videoId, sort
|
|
||||||
* @returns 记录结果
|
|
||||||
*/
|
*/
|
||||||
addErrorCourse(data: IAddErrorCourseRequest) {
|
addErrorCourse(data: {
|
||||||
return client.request<IAddErrorCourseResponse>({
|
chapterId: number
|
||||||
|
videoId: number
|
||||||
|
sort: number
|
||||||
|
}) {
|
||||||
|
return client.request<IApiResponse>({
|
||||||
url: 'medical/course/addErrorCourse',
|
url: 'medical/course/addErrorCourse',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data
|
data
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ interface Props {
|
|||||||
chapters: IChapter[]
|
chapters: IChapter[]
|
||||||
catalogue: ICatalogue
|
catalogue: ICatalogue
|
||||||
userVip: IVipInfo | null
|
userVip: IVipInfo | null
|
||||||
|
showRenewBtn?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -136,6 +137,8 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
click: [chapter: IChapter],
|
click: [chapter: IChapter],
|
||||||
purchase: [catalogue: ICatalogue],
|
purchase: [catalogue: ICatalogue],
|
||||||
|
renew: [catalogue: ICatalogue],
|
||||||
|
toVip: [catalogue: ICatalogue],
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -153,6 +156,16 @@ const goToVip = () => {
|
|||||||
emit('toVip', props.catalogue)
|
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 class="page-content" :style="{ height: contentHeight }">
|
||||||
<!-- 视频播放器 -->
|
<!-- 视频播放器 -->
|
||||||
<view v-if="videoList.length > 0" class="video-section">
|
<view v-if="videoList.length > 0" class="video-section">
|
||||||
<!-- <VideoPlayer
|
<VideoPlayer
|
||||||
ref="videoPlayerRef"
|
ref="videoPlayerRef"
|
||||||
:videoList="videoList"
|
v-model:current-index="currentVideoIndex"
|
||||||
:currentIndex="currentVideoIndex"
|
:video-list="videoList"
|
||||||
:noRecored="noRecored"
|
:countdown-seconds="5"
|
||||||
@end="handleVideoEnd"
|
/>
|
||||||
@fullscreen="handleFullscreen"
|
<!-- <AliyunPlayer
|
||||||
@change="handleVideoChange"
|
|
||||||
/> -->
|
|
||||||
<AliyunPlayer
|
|
||||||
ref="videoPlayerRef"
|
ref="videoPlayerRef"
|
||||||
:currentVideo="videoList[currentVideoIndex]"
|
:currentVideo="videoList[currentVideoIndex]"
|
||||||
:currentVideoList="videoList"
|
:currentVideoList="videoList"
|
||||||
@unlockChangeVideo="changeVideoLock = false"
|
@unlockChangeVideo="changeVideoLock = false"
|
||||||
/>
|
/> -->
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 课程和章节信息 -->
|
<!-- 课程和章节信息 -->
|
||||||
@@ -48,12 +45,6 @@
|
|||||||
>
|
>
|
||||||
<view class="video-info">
|
<view class="video-info">
|
||||||
<text class="video-title">【{{ video.type == "2" ? "音频" : "视频" }}】{{ index + 1 }}</text>
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -113,11 +104,10 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onLoad, onShow, onHide, onUnload } from '@dcloudio/uni-app'
|
import { onLoad, onShow, onHide } from '@dcloudio/uni-app'
|
||||||
import { courseApi } from '@/api/modules/course'
|
import { courseApi } from '@/api/modules/course'
|
||||||
// import VideoPlayer from '@/components/course/VideoPlayer.vue'
|
import VideoPlayer from '@/components/video-player/index.vue'
|
||||||
import AliyunPlayer from '@/components/ali-video/index.vue'
|
|
||||||
import type { IChapterDetail, IVideo } from '@/types/course'
|
import type { IChapterDetail, IVideo } from '@/types/course'
|
||||||
|
|
||||||
// 页面参数
|
// 页面参数
|
||||||
@@ -131,6 +121,7 @@ const noRecored = ref(false)
|
|||||||
const chapterDetail = ref<IChapterDetail | null>(null)
|
const chapterDetail = ref<IChapterDetail | null>(null)
|
||||||
const videoList = ref<IVideo[]>([])
|
const videoList = ref<IVideo[]>([])
|
||||||
const currentVideoIndex = ref(0)
|
const currentVideoIndex = ref(0)
|
||||||
|
const activeVideoIndex = ref(0)
|
||||||
const currentTab = ref(0)
|
const currentTab = ref(0)
|
||||||
const isFullScreen = ref(false)
|
const isFullScreen = ref(false)
|
||||||
|
|
||||||
@@ -184,20 +175,9 @@ const loadChapterDetail = async () => {
|
|||||||
const index = videoList.value.findIndex(v => v.id === res.data.current)
|
const index = videoList.value.findIndex(v => v.id === res.data.current)
|
||||||
if (index !== -1) {
|
if (index !== -1) {
|
||||||
currentVideoIndex.value = index
|
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) {
|
} catch (error) {
|
||||||
console.error('加载章节详情失败:', error)
|
console.error('加载章节详情失败:', error)
|
||||||
@@ -209,46 +189,7 @@ const loadChapterDetail = async () => {
|
|||||||
*/
|
*/
|
||||||
const selectVideo = async (index: number) => {
|
const selectVideo = async (index: number) => {
|
||||||
if (index === currentVideoIndex.value) return
|
if (index === currentVideoIndex.value) return
|
||||||
|
|
||||||
console.log('切换视频:', index, videoList.value[index])
|
|
||||||
currentVideoIndex.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
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -362,7 +267,7 @@ onUnload(() => {
|
|||||||
gap: 10rpx;
|
gap: 10rpx;
|
||||||
|
|
||||||
.video-item {
|
.video-item {
|
||||||
padding: 20rpx;
|
padding: 18rpx;
|
||||||
margin-bottom: 10rpx;
|
margin-bottom: 10rpx;
|
||||||
background-color: #f7f8f9;
|
background-color: #f7f8f9;
|
||||||
border-radius: 8rpx;
|
border-radius: 8rpx;
|
||||||
|
|||||||
@@ -32,8 +32,10 @@
|
|||||||
:chapters="chapterList"
|
:chapters="chapterList"
|
||||||
:catalogue="currentCatalogue"
|
:catalogue="currentCatalogue"
|
||||||
:userVip="userVip"
|
:userVip="userVip"
|
||||||
|
:showRenewBtn="showRenewBtn"
|
||||||
@purchase="handlePurchase"
|
@purchase="handlePurchase"
|
||||||
@toVip="goToVip"
|
@toVip="goToVip"
|
||||||
|
@renew="handleRenew"
|
||||||
@click="handleChapterClick"
|
@click="handleChapterClick"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|||||||
@@ -8,7 +8,6 @@
|
|||||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||||
"Courier New", monospace;
|
"Courier New", monospace;
|
||||||
--color-red-500: oklch(63.7% 0.237 25.331);
|
--color-red-500: oklch(63.7% 0.237 25.331);
|
||||||
--spacing: 0.25rem;
|
|
||||||
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
--ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
--default-transition-duration: 150ms;
|
--default-transition-duration: 150ms;
|
||||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
@@ -237,9 +236,6 @@
|
|||||||
.flex-shrink {
|
.flex-shrink {
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
}
|
}
|
||||||
.border-collapse {
|
|
||||||
border-collapse: collapse;
|
|
||||||
}
|
|
||||||
.transform {
|
.transform {
|
||||||
transform: var(--tw-rotate-x,) var(--tw-rotate-y,) var(--tw-rotate-z,) var(--tw-skew-x,) var(--tw-skew-y,);
|
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;
|
--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,);
|
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 {
|
.ring {
|
||||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentcolor);
|
--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);
|
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
|
// types/video.d.ts
|
||||||
/**
|
|
||||||
* 视频相关类型定义
|
|
||||||
* 完全基于原项目 medicine_app 的数据结构
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { IApiResponse } from './book'
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视频基本信息
|
* 视频信息
|
||||||
* 对应原项目中的视频数据结构
|
|
||||||
*/
|
*/
|
||||||
export interface IVideoInfo {
|
export interface IVideoInfo {
|
||||||
id: number // 视频 ID
|
id: number
|
||||||
title: string // 视频标题
|
chapterId: number
|
||||||
type: '1' | '2' | string // 类型: 1-视频 2-音频
|
type: 0 | 1 // 0: MP4, 1: M3U8
|
||||||
video?: string // 阿里云视频 ID(原始)
|
video: string // 视频ID
|
||||||
videoId?: string // 阿里云视频 ID(处理后,移除等号)
|
sort: number
|
||||||
videoUrl?: string // 视频 URL(普通视频)
|
duration: number // 视频时长(秒)
|
||||||
m3u8Url?: string | null // M3U8 地址(标准加密)
|
createTime: string
|
||||||
playAuth?: string // 播放凭证
|
delFlag: number
|
||||||
sort?: number // 排序
|
playAuth?: string // 阿里云播放授权
|
||||||
chapterId?: number // 章节 ID
|
videoUrl?: string // 视频URL(兼容字段)
|
||||||
courseId?: number // 课程 ID
|
m3u8Url?: string // M3U8流地址
|
||||||
catalogueId?: number // 目录 ID
|
mp4Url?: string // MP4视频地址
|
||||||
userCourseVideoPositionEntity?: {
|
mtsHlsUriToken?: string // M3U8 Token
|
||||||
position: number // 服务器记录的播放位置(秒)
|
userCourseVideoPositionEntity?: IVideoPosition
|
||||||
}
|
|
||||||
[key: string]: any // 允许其他字段
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 视频数据(扩展)
|
* 视频播放位置
|
||||||
* 包含播放时间等额外信息
|
|
||||||
*/
|
*/
|
||||||
export interface IVideoData extends IVideoInfo {
|
export interface IVideoPosition {
|
||||||
firstTime?: number // 初始播放时间(秒)
|
id: number
|
||||||
time?: number // 当前播放时间(秒)
|
userId: number
|
||||||
|
videoId: number
|
||||||
|
position: number // 播放位置(秒)
|
||||||
|
createTime: string
|
||||||
|
updateTime: string
|
||||||
|
delFlag: number
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 本地存储的视频列表项
|
* 视频检查接口响应
|
||||||
* 存储在 localStorage 的 videoOssList 中
|
|
||||||
*/
|
*/
|
||||||
export interface IVideoStorageItem {
|
export interface IVideoCheckResponse {
|
||||||
id: number // 视频 ID
|
msg: string
|
||||||
time: number // 播放时间(秒)
|
code: number
|
||||||
[key: string]: any // 其他视频信息
|
video: IVideoInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查视频请求参数
|
* 视频播放器组件 Props
|
||||||
* 对应 sociology/course/checkVideo 接口
|
|
||||||
*/
|
*/
|
||||||
export interface ICheckVideoRequest {
|
export interface IVideoPlayerProps {
|
||||||
id?: number // 视频 ID
|
videoList: Array<{
|
||||||
video?: string // 视频标识
|
id: number
|
||||||
courseId?: number // 课程 ID
|
chapterId: number
|
||||||
catalogueId?: number // 目录 ID
|
video: string
|
||||||
chapterId?: number // 章节 ID
|
sort: number
|
||||||
[key: string]: any // 允许其他参数
|
type?: 0 | 1
|
||||||
}
|
duration?: number
|
||||||
|
|
||||||
/**
|
|
||||||
* 检查视频响应
|
|
||||||
* 对应 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
|
|
||||||
}>
|
|
||||||
}>
|
}>
|
||||||
|
currentIndex: number
|
||||||
|
countdownSeconds?: number
|
||||||
|
showWatermark?: boolean
|
||||||
|
watermarkText?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 全屏状态变化数据
|
* 视频播放器组件状态
|
||||||
*/
|
*/
|
||||||
export interface IScreenChangeData {
|
export interface IVideoPlayerState {
|
||||||
status: boolean // 全屏状态
|
currentVideoData: IVideoInfo | null
|
||||||
primary: ScreenOrientationType // 屏幕方向
|
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 {
|
export enum VideoErrorType {
|
||||||
time: number // 播放时间(秒)
|
NETWORK_ERROR = 'NETWORK_ERROR',
|
||||||
status?: string // 播放状态
|
VIDEO_LOAD_ERROR = 'VIDEO_LOAD_ERROR',
|
||||||
}
|
ENCRYPTION_ERROR = 'ENCRYPTION_ERROR',
|
||||||
|
PLATFORM_NOT_SUPPORTED = 'PLATFORM_NOT_SUPPORTED',
|
||||||
/**
|
INVALID_PARAMS = 'INVALID_PARAMS',
|
||||||
* 错误提示数据
|
API_ERROR = 'API_ERROR'
|
||||||
*/
|
|
||||||
export interface IOpenShowData {
|
|
||||||
msg?: string // 错误消息
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 初始化数据
|
|
||||||
*/
|
|
||||||
export interface IInitData {
|
|
||||||
currentVideo: IVideoInfo // 当前视频
|
|
||||||
currentVideoList?: IVideoInfo[] // 视频列表
|
|
||||||
}
|
}
|
||||||
|
|||||||
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