diff --git a/api/modules/video.ts b/api/modules/video.ts index a6a284e..134d2a1 100644 --- a/api/modules/video.ts +++ b/api/modules/video.ts @@ -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({ + checkVideo(data: { + id: number + }) { + return client.request({ 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({ + saveCoursePosition(data: { + videoId: number + position: number + }) { + return client.request({ 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({ + addErrorCourse(data: { + chapterId: number + videoId: number + sort: number + }) { + return client.request({ url: 'medical/course/addErrorCourse', method: 'POST', data diff --git a/components/course/ChapterList.vue b/components/course/ChapterList.vue index dd7801d..8a3eaf0 100644 --- a/components/course/ChapterList.vue +++ b/components/course/ChapterList.vue @@ -129,6 +129,7 @@ interface Props { chapters: IChapter[] catalogue: ICatalogue userVip: IVipInfo | null + showRenewBtn?: boolean } const props = defineProps() @@ -136,6 +137,8 @@ const props = defineProps() 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) +} + /** * 判断章节是否可以访问 */ diff --git a/components/video-player/components/ControlBar.vue b/components/video-player/components/ControlBar.vue new file mode 100644 index 0000000..4982e25 --- /dev/null +++ b/components/video-player/components/ControlBar.vue @@ -0,0 +1,162 @@ + + + + + diff --git a/components/video-player/components/ProgressBar.vue b/components/video-player/components/ProgressBar.vue new file mode 100644 index 0000000..188346b --- /dev/null +++ b/components/video-player/components/ProgressBar.vue @@ -0,0 +1,183 @@ + + + + + diff --git a/components/video-player/components/SpeedControl.vue b/components/video-player/components/SpeedControl.vue new file mode 100644 index 0000000..f81b420 --- /dev/null +++ b/components/video-player/components/SpeedControl.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/components/video-player/components/VolumeControl.vue b/components/video-player/components/VolumeControl.vue new file mode 100644 index 0000000..fb00b1b --- /dev/null +++ b/components/video-player/components/VolumeControl.vue @@ -0,0 +1,112 @@ + + + + + diff --git a/components/video-player/composables/useVideoAPI.ts b/components/video-player/composables/useVideoAPI.ts new file mode 100644 index 0000000..28feff7 --- /dev/null +++ b/components/video-player/composables/useVideoAPI.ts @@ -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 => { + 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 => { + 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 + } +} diff --git a/components/video-player/composables/useVideoPlayer.ts b/components/video-player/composables/useVideoPlayer.ts new file mode 100644 index 0000000..da09384 --- /dev/null +++ b/components/video-player/composables/useVideoPlayer.ts @@ -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(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 + } +} diff --git a/components/video-player/composables/useVideoProgress.ts b/components/video-player/composables/useVideoProgress.ts new file mode 100644 index 0000000..f474a34 --- /dev/null +++ b/components/video-player/composables/useVideoProgress.ts @@ -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 + } +} diff --git a/components/video-player/index.vue b/components/video-player/index.vue new file mode 100644 index 0000000..df78e1f --- /dev/null +++ b/components/video-player/index.vue @@ -0,0 +1,519 @@ + + + + + diff --git a/pages/course/details/chapter.vue b/pages/course/details/chapter.vue index 41d8299..3e5206c 100644 --- a/pages/course/details/chapter.vue +++ b/pages/course/details/chapter.vue @@ -7,21 +7,18 @@ - - + @@ -48,12 +45,6 @@ > 【{{ video.type == "2" ? "音频" : "视频" }}】{{ index + 1 }} - @@ -113,11 +104,10 @@