更新:课程视频播放改成原生video组件

This commit is contained in:
2025-11-21 18:09:24 +08:00
parent 754865e23e
commit ac60a863e3
15 changed files with 1729 additions and 333 deletions

View File

@@ -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

View File

@@ -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)
}
/**
* 判断章节是否可以访问
*/

View 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>

View 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>

View 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>

View 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>

View 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
}
}

View 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
}
}

View 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
}
}

View 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>

View File

@@ -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;

View File

@@ -32,8 +32,10 @@
:chapters="chapterList"
:catalogue="currentCatalogue"
:userVip="userVip"
:showRenewBtn="showRenewBtn"
@purchase="handlePurchase"
@toVip="goToVip"
@renew="handleRenew"
@click="handleChapterClick"
/>
</view>

View File

@@ -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
View File

@@ -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
View 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()