更新:课程视频播放改成原生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

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